SLOPSHOPPER

jtasks

Deterministic task list with dependencies and file ownership (jlock): the jtasks tool, a Tasks pane, /jtasks, and the ownership guard for subagents.

newpaneguardcommandprompttool
v0.2.0MITupdated 2026-10-26rodrigojager/jtasks/claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jtasks
│ ┃ Tasks ✕ › fix the failing auth test and add an audit log call │ ┃ [fn] │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /jtasks │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tasks
[fn]
README

jtasks for Claude Code

The Claude Code mod of jtasks: the jtasks tool (mcp__jtasks__jtasks, the same contract as the CLI), the run bound to the session ($.state, shared by subagents), the execution protocol in the system prompt while a run is bound, a "Tasks" pane with the checklist and /jtasks (Claude Code has its own /tasks), the model of each subagent from its task's profile, the ownership guard on Edit/Write/NotebookEdit/Bash of subagents, and the keepalive: when the main loop's turn ends with a task ready and no subagent alive, the mod sends a message ($.prompt.submit) that reopens the loop; after two with no progress it stops and tells you (toast, notification, status line, a line in the pane). jtasks pause (how the orchestrator stops to ask you) and jtasks keepalive off keep it quiet. Every minute the mod also renews the locks of the tasks this session started (in place with jlock 0.2.0's jlock renew), before the run's holdTtl expires them, so a task that runs for hours keeps its files. A subagent (or the main loop) that dies on a transient failure of the API (a usage or session limit, a rate limit, an overloaded API, the network: read from the classic StopFailure event) pauses the run with its task kept in progress and its locks held; at the reset, at Claude Code's own quota_auto_resume_fired or on jtasks unpause, the mod tells the orchestrator to resume that same subagent with SendMessage (see Transient failures).

The folder is self-contained: core/core.js is the core (generated from ../src by bun scripts/build.ts), and the hooks module runs it with no Node and no node_modules. It needs git; jlock comes by itself: when it is missing, the mod installs it through jlock's official installer as soon as it loads, and the first tool answer says where (JTASKS_NO_JLOCK_INSTALL=1 turns this off).

Install

In Claude Code, one line installs the mod from this repository's marketplace (Claude Code 2.1.275 or later):

/plugin install jtasks --marketplace rodrigojager/jtasks

Or, for one session from a clone, without installing anything:

claude --plugin-dir <jtasks>/claude

To update: claude plugin marketplace update jtasks, then claude plugin update jtasks@jtasks, and restart Claude Code. /plugin compares the version in .claude-plugin/plugin.json, so each release bumps it (see CONTRIBUTING).

Language

The pane, /jtasks and the tool's answers speak the language picked when the session starts: JTASKS_LANG, then JLOCK_LANG, then the system's language, then English (en and pt-BR today). A single call can ask for another with --lang <tag> ({ "command": "status --lang en" }). The tool's description and the protocol in the system prompt are English, for the model; --json data is the same in every language. Set the variable before starting Claude Code, for example JTASKS_LANG=en claude --plugin-dir D:/jtasks/claude.

Tasks pane

The Tasks pane shows the bound run's progress in a claude-hud-style bar, then one row per task. For example:

▾ Tasks · pane-redesign-261024-hjr6
██████░░░░ 60% · 3/5

✓ T1 - Base
✻ T2 - Pane model
○ T3 - Pane documentation

The bar has ten cells in claude-hud's Usage blue, the empty ones in the same blue, dim; the percentage and the done/total count follow it. A green ✓ marks a completed task, its label in the normal text color. Claude Code's orange spinner (✻ ✶ ✽ ✢ ·, shown here as ✻) marks a running task, its label orange too. Upcoming tasks are a dim ○. A failed task has a red ✗; a skipped one a dim ✓ with its label struck through. Use the header button to fold or unfold the pane, or toggle it with /jtasks.

Agents

The mod ships three subagents, jtasks:execute, jtasks:execute-deep and jtasks:review (agents/, generated by bun scripts/build.ts from the OpenCode ones, the same text). They are saved with the mod: nothing to install. When the model opens a subagent for a task (--task <id> in its prompt), the task's profile turns the call into its agent (claude.agent), on its model, with its role; a profile that names no agent keeps the type the call asked for. The profile's effort (low, medium, high, xhigh or max) is set on every model request of that subagent, where the model takes one. The defaults: execute on sonnet, effort medium; execute-deep on opus, high; review on sonnet, high.

Check it

claude plugin validate claude          # manifest, hooks module, state contract
claude plugin test claude              # the tests in claude/tests (a fake machine: no git, jlock or model calls)
bunx tsc -p tsconfig.claude.json       # types, against vendor/claude-code.d.ts (Claude Code 2.1.310)

The tool

tasks { "command": "next" }
tasks { "command": "start T04" }
tasks { "command": "claim T04 src/x.ts --timeout 2m" }
tasks { "command": "yield T05" }
tasks { "command": "done T04 --evidence \"bun test: 12 passed\"" }
tasks { "command": "plan --add", "json": [ { "id": "T05", "title": "…", "files": ["src/y.ts"] } ] }

help lists every action. A session bound to a run refuses --run naming another one. A claim refused for a deadlock is answered with yield: the task gives back only the contested files, its edits kept as patches, and stays in progress; the pane shows "T4 - title · espera c.ts" until it gets them back, which happens by itself as soon as they are free (each minute, or when another task finishes) or with claim T05. A subagent whose loop ends with its task still in progress gives the task back (release --agent-ended): its edits are kept as patches, the tree is left clean, and the next start of the task reapplies them (start T04 --fresh leaves them out). A plan may list in shared the registry files several tasks touch; with "sharedPolicy": "serialize" two tasks touching the same one never run together, and status --claims shows the most contested files of a run.

Source 9 files
hooks/register.tsx 503 lines
1// jtasks for Claude Code: the `jtasks` tool (the core's runTool on a host made of $.fs and $.process), the run
2// bound to the session in $.state (subagents share it), the protocol section in the system prompt while a run is
3// bound (prompt.compose runs at every request, so it is back after a compaction too), and the "Tasks" pane with
4// /jtasks (Claude Code ships its own /tasks), refreshed every 2 s while open; and the keepalive: at the end of the
5// main loop's turn, with ready tasks and no subagent alive, a message that reopens the loop (./keepalive.ts); and
6// the holds of this session's tasks in progress renewed before the run's holdTtl expires them (core's renew.ts); and
7// a loop that dies on a transient failure of the API pauses the run with its task kept (./transient.ts, rev. 5.7).
8
9import { atom, read, update } from 'claude-code'
10import type { Register } from 'claude-code'
11import { ensureJlock, parseArgs, pauseForFailure, type Classification, pauseNotice, readRun, readTasks, renewRun, RENEW_EVERY_MS, resumeIfDue, resumeNow, runDir, runTool, select, splitCommand, systemLocale, tr, type Resident, type ToolContext, type ToolResult } from '../core/core.js'
12import { createModHost, type Port } from './mod-host.ts'
13import { serializedCommand, spawnDecision } from './agents.ts'
14import { judgeCall, type GuardedCall } from './guard.ts'
15import { Keepalive } from './keepalive.ts'
16import { FailureWatch, STOP_FAILURE_WAIT_MS } from './transient.ts'
17import { BAR_COLOR, computeView, hudBar, isLive, paneLines, paneRows, PANE, PANE_TITLE, ROW_MARK, ROW_MARK_COLOR, SPINNER_FRAMES, TRACK_COLOR } from './pane.tsx'
18import type { AgentTask, PaneView } from '../types'
19import { protocolSection, SECTION_ID, TOOL_DESCRIPTION, TOOL_NAME, TOOL_SCHEMA } from './protocol.ts'
20
21const RUN = { plugin: 'jtasks', key: 'run' } as const
22// Change the tag when the view's fields change: it declines what an earlier version stored.
23const viewAtom = atom({ plugin: 'jtasks', key: 'view' } as const, null as PaneView | null, { shape: 'view-2' })
24const collapsedAtom = atom({ plugin: 'jtasks', key: 'collapsed' } as const, false)
25const AGENTS = { plugin: 'jtasks', key: 'agents' } as const
26const FULL_NAME = 'mcp__jtasks__jtasks'
27
28/** Actions that may name another run than the bound one (they create, bind or only list). */
29const ANY_RUN = new Set(['plan', 'resume', 'import', 'runs', 'ledger', 'gc', 'help', 'version', 'validate', 'clean', 'archive', 'undo'])
30
31/** Residents this session keeps (none for holds: the mod uses `jlock hold --detach`, so a reload loses nothing). */
32const residents = new Map<string, Resident[]>()
33
34/** Set at session.start: the closures over $ the rest of the module works through. */
35let port: Port | undefined
36/** Set at session.start: recomputes the pane's view now (a closure over that hook's $). */
37let refresh: ((force: boolean) => Promise<void>) | undefined
38/** Set at session.start: reopens the loop when the orchestrator stops with ready tasks (a closure over that hook's $). */
39let keepalive: Keepalive | undefined
40/** Set at session.start: the StopFailures of each loop, classified at the loop's end (rev. 5.7). */
41let failures: FailureWatch | undefined
42/** Set at session.start: tells the person the run was paused by a transient failure, and clears it. */
43let tell: { paused(notice: string): void; clear(): void } | undefined
44/** The status line shows a transient pause. */
45let pauseShown = false
46let paneOpen = false
47let spinnerFrame = 0
48
49function needPort(): Port {
50  if (!port) throw new Error(tr('mod.no_session'))
51  return port
52}
53
54const TASK_END = new Set(['done', 'skip', 'fail', 'release'])
55
56/** The context of a call of the core for the bound run, as the session (not one of its subagents). */
57async function sessionCtx(p: Port, run: string): Promise<ToolContext> {
58  const cwd = await p.cwd()
59  return { host: createModHost(p, cwd), cwd, tool: 'claude', session: await p.sessionId(), residents, holdMode: 'detach', boundRun: run }
60}
61
62/** Runs one command of the tool for the session (or for one of its subagents). */
63export async function callTool(p: Port, command: string, json: unknown, agentId?: string): Promise<ToolResult> {
64  const argv = splitCommand(command.replace(/^\s*tasks\s+/, ''))
65  let parsed
66  try {
67    parsed = parseArgs(argv)
68  } catch (error) {
69    return { ok: false, code: 2, text: (error as Error).message }
70  }
71  const bound = await p.getRun()
72  const asked = typeof parsed.options.run === 'string' ? parsed.options.run : undefined
73  if (bound && asked && asked !== bound.run && !ANY_RUN.has(parsed.action))
74    return { ok: false, code: 1, text: tr('binding.other_run', { run: bound.run, asked }) }
75  const cwd = await p.cwd()
76  const ctx: ToolContext = { host: createModHost(p, cwd), cwd, tool: 'claude', session: await p.sessionId(), residents, holdMode: 'detach' }
77  if (bound) ctx.boundRun = bound.run
78  if (agentId) ctx.agent = agentId
79  if (json !== undefined) ctx.json = json
80  const result = await runTool(ctx, argv)
81  const id = parsed.positionals[0]
82  if (agentId && result.ok && id && (bound || result.bind)) {
83    const agents = await p.getAgents()
84    if (parsed.action === 'start' || parsed.action === 'claim') await p.setAgents({ ...agents, [agentId]: { ...agents[agentId], run: result.bind ?? bound!.run, task: id } })
85  }
86  // A task that ended (or was given back) no longer waits for the subagent a transient pause kept for it.
87  if (result.ok && id && TASK_END.has(parsed.action)) {
88    const agents = await p.getAgents()
89    if (Object.values(agents).some((a) => a.paused && a.task === id))
90      await p.setAgents(Object.fromEntries(Object.entries(agents).filter(([, a]) => !(a.paused && a.task === id))))
91  }
92  if (result.ok && result.bind) {
93    const dir = (result.data as { dir?: string } | undefined)?.dir?.replace(/\\/g, '/')
94    const root = dir ? dir.replace(/\/\.scratch\/jtasks\/runs\/[^/]+$/, '') : (bound?.root ?? cwd)
95    await p.setRun({ run: result.bind, root })
96  }
97  return result
98}
99
100let renewing = false
101
102/** One pass over the bound run: the holds of the tasks this session (or one of its subagents) started. */
103export async function renewHolds(p: Port): Promise<void> {
104  const bound = await p.getRun()
105  if (!bound || renewing) return
106  renewing = true
107  try {
108    const cwd = await p.cwd()
109    const session = await p.sessionId()
110    const ctx: ToolContext = { host: createModHost(p, cwd), cwd, tool: 'claude', session, residents, holdMode: 'detach', boundRun: bound.run }
111    const actor = `claude:${session}`
112    await renewRun(ctx, bound.run, (owner) => owner === actor || owner.startsWith(`${actor}/`))
113  } finally {
114    renewing = false
115  }
116}
117
118/**
119 * The end of a loop on an API error (rev. 5.7): a transient failure pauses the run, with the task of the subagent
120 * (`mine`) kept in progress. Answers the notice for the person when it paused (then the caller keeps the subagent's
121 * place), or undefined (the error goes on as before).
122 */
123export async function pauseOnFailure(p: Port, agentId: string | undefined, answer: string, last: () => Promise<string | undefined>, mine?: AgentTask): Promise<string | undefined> {
124  if (!failures) return undefined
125  const failure = await failures.classify(agentId, answer, last, agentId === undefined ? 0 : STOP_FAILURE_WAIT_MS)
126  return pauseFor(p, failure, agentId, mine)
127}
128
129/** Pauses the bound run for a classified failure (only a transient one); the notice for the person, or undefined. */
130async function pauseFor(p: Port, failure: Classification, agentId: string | undefined, mine?: AgentTask): Promise<string | undefined> {
131  const bound = await p.getRun()
132  if (!bound || !failure.transient || (mine && mine.run !== bound.run)) return undefined
133  const ctx = await sessionCtx(p, bound.run)
134  if (mine) {
135    // Only a task still in progress is worth keeping; one the subagent finished already goes on as before.
136    const tasks = await readTasks(ctx.host, runDir(bound.root, bound.run)).catch(() => undefined)
137    if (tasks?.tasks.find((t) => t.id === mine.task)?.status !== 'in_progress') return undefined
138  }
139  const pause = await pauseForFailure(ctx, { run: bound.run, failure, ...(mine ? { task: mine.task } : {}), ...(agentId ? { agent: agentId } : {}) })
140  return pause ? pauseNotice(bound.run, pause, ctx.host.now()) : undefined
141}
142
143/**
144 * The clock (`resumeAt` has come) and Claude Code's own reset (`how`): leaves a transient pause, and the orchestrator
145 * is told to resume the same agents.
146 */
147async function leaveTransientPause(p: Port, how?: string): Promise<void> {
148  const bound = await p.getRun()
149  if (!bound) return
150  const ctx = await sessionCtx(p, bound.run)
151  // Cheap first: run.json alone (no git, no jlock) every two seconds.
152  const run = await readRun(ctx.host, runDir(bound.root, bound.run)).catch(() => undefined)
153  if (pauseShown && !(run?.status === 'paused' && run.pause)) {
154    pauseShown = false
155    tell?.clear()
156  }
157  if (run?.status !== 'paused' || !run.pause?.resumeAt) return
158  if (!how && Date.parse(run.pause.resumeAt) > ctx.host.now().getTime()) return
159  const left = how ? await resumeNow(ctx, bound.run, how) : await resumeIfDue(ctx, bound.run)
160  if (!left) return
161  if (pauseShown) {
162    pauseShown = false
163    tell?.clear()
164  }
165  keepalive?.deliver(left.text)
166  await refresh?.(true).catch(() => undefined)
167}
168
169async function lastMessage(read: () => Promise<readonly { role: string; text: string }[] | { deny: string }>): Promise<string | undefined> {
170  const messages = await read()
171  return Array.isArray(messages) ? messages.filter((m) => m.role === 'assistant').at(-1)?.text : undefined
172}
173
174export const register: Register = (on) => {
175  on('session.start', async ($, e, next) => {
176    const started = await next(e)
177    port = {
178      read: async (path) => (await $.fs.read(path)) as string,
179      write: (path, text) => $.fs.write(path, text),
180      list: (path) => $.fs.list(path),
181      stat: (path) => $.fs.stat(path),
182      run: (argv, init) => $.process.run(argv, init),
183      spawn: (request) => $.process.spawn(request) as never,
184      sleep: (ms) => $.clock.sleep(ms),
185      getRun: async () => (await $.state.get(RUN)).value ?? null,
186      setRun: async (run) => void (await $.state.set(RUN, run)),
187      getAgents: async () => (await $.state.get(AGENTS)).value ?? {},
188      setAgents: async (agents) => void (await $.state.set(AGENTS, agents)),
189      sessionId: () => $.session.id(),
190      cwd: () => $.session.cwd(),
191      env: {
192        JTASKS_HOME: await $.env.get('JTASKS_HOME'),
193        USERPROFILE: await $.env.get('USERPROFILE'),
194        HOME: await $.env.get('HOME'),
195        JLOCK_BIN: await $.env.get('JLOCK_BIN'),
196        JLOCK_INSTALL_DIR: await $.env.get('JLOCK_INSTALL_DIR'),
197        LOCALAPPDATA: await $.env.get('LOCALAPPDATA'),
198        JTASKS_NO_JLOCK_INSTALL: await $.env.get('JTASKS_NO_JLOCK_INSTALL'),
199        JTASKS_LANG: await $.env.get('JTASKS_LANG'),
200        JLOCK_LANG: await $.env.get('JLOCK_LANG'),
201        JTASKS_KEEPALIVE_TRIES: await $.env.get('JTASKS_KEEPALIVE_TRIES'),
202      },
203    }
204    keepalive = new Keepalive(
205      port,
206      {
207        agents: async () => (await $.agent.list()).map((a) => ({ status: a.status })),
208        draft: async () => (await $.prompt.read()).text,
209        submit: (text) => $.prompt.submit({ text }),
210        // The status line carries the plugin's name already; the toast and the notification do not.
211        stalled: (notice) => {
212          $.ui.toast(`jtasks: ${notice}`, { timeoutMs: 15000 })
213          $.ui.status(notice)
214          void $.ui.notify(`jtasks: ${notice}`).catch(() => undefined)
215          void refresh?.(true).catch(() => undefined)
216        },
217        clear: () => $.ui.status(undefined),
218      },
219      e.isInteractive,
220    )
221    await keepalive.restore((text) => $.ui.status(text)).catch(() => undefined)
222    // A StopFailure after its loop's end (the end went on as an ordinary error): a transient one still pauses the
223    // run, so nothing else is started into the same limit; a subagent's task already given back stays so.
224    failures = new FailureWatch(
225      (ms) => $.clock.sleep(ms),
226      () => new Date(),
227      (agentId, failure) => {
228        const p = port
229        if (!p || !failure.transient) return
230        void (async () => {
231          const mine = agentId ? (await p.getAgents())[agentId] : undefined
232          const notice = await pauseFor(p, failure, agentId, mine)
233          if (!notice) return
234          if (agentId && mine) await p.setAgents({ ...(await p.getAgents()), [agentId]: { ...mine, paused: true } })
235          tell?.paused(notice)
236          await refresh?.(true).catch(() => undefined)
237        })().catch(() => undefined)
238      },
239    )
240    tell = {
241      paused: (notice) => {
242        pauseShown = true
243        $.ui.toast(`jtasks: ${notice}`, { timeoutMs: 15000 })
244        $.ui.status(notice)
245        void $.ui.notify(`jtasks: ${notice}`).catch(() => undefined)
246      },
247      clear: () => $.ui.status(undefined),
248    }
249    // The session's language (plan section 21): what the pane and the tool's answers speak until a --lang.
250    const env = port.env
251    select({ env: (name) => env[name as keyof typeof env] ?? undefined, system: systemLocale() })
252    // jlock comes with jtasks: a missing one is installed in the background (the first tool call tells the user).
253    void ensureJlock(createModHost(port, await port.cwd())).catch(() => undefined)
254    await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA, isDeferred: false })
255    // A /jtasks of the person's own (the skill `jtasks doctor --fix` puts in ~/.claude/skills) refuses the
256    // registration; the command.run hook below still answers /jtasks, so the pane's refresh is set up anyway.
257    await $.command.register({ name: 'jtasks', description: tr('mod.command') }).catch(() => undefined)
258
259    const stored = await $.store.get('collapsed')
260    if (typeof stored === 'boolean') await update($, collapsedAtom, () => stored)
261    let signature = ''
262    refresh = async (force) => {
263      const p = port
264      if (!p || !paneOpen) return
265      const bound = await p.getRun()
266      const files = bound ? [`${bound.root}/.scratch/jtasks/runs/${bound.run}/tasks.json`, `${bound.root}/.scratch/jtasks/runs/${bound.run}/run.json`] : []
267      const stats = await Promise.all(files.map((f) => p.stat(f).then((x) => x.mtimeMs, () => 0)))
268      const current = `${bound?.run ?? ''}:${stats.join(',')}`
269      if (!force && current === signature && !isLive(await read($, viewAtom))) return
270      signature = current
271      const view = await computeView(p)
272      await update($, viewAtom, () => view)
273    }
274    $.clock.every(2000, () => {
275      void refresh?.(false).catch(() => undefined)
276      void keepalive?.tick().catch(() => undefined)
277      const p = port
278      if (p) void leaveTransientPause(p).catch(() => undefined)
279    })
280    $.clock.every(RENEW_EVERY_MS, () => {
281      const p = port
282      if (p) void renewHolds(p).catch(() => undefined)
283    })
284    $.clock.every(120, async () => {
285      const view = await read($, viewAtom)
286      if (!paneOpen || !view || !paneRows(view).some((row) => row.state === 'running')) return
287      spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length
288      await update($, viewAtom, () => view)
289    })
290    if (await port.getRun()) {
291      paneOpen = (await $.ui.open({ id: PANE, title: PANE_TITLE })).isPlaced
292      await refresh(true)
293    }
294    return started
295  })
296
297  on('command.run', { command: 'jtasks' }, async ($) => {
298    if (paneOpen) {
299      await $.ui.close({ id: PANE })
300      paneOpen = false
301      return { text: tr('mod.pane_closed') }
302    }
303    const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
304    paneOpen = opened.isPlaced
305    await refresh?.(true)
306    return { text: opened.isPlaced ? tr('mod.pane_open') : tr('mod.pane_no_room', { reason: String(opened.reason) }) }
307  })
308
309  on('ui.close', { id: 'tasks' }, async ($, e, next) => {
310    paneOpen = false
311    return next(e)
312  })
313
314  on('ui.render', { component: 'Pane', requestId: 'tasks' }, async ($, e) => {
315    const { Box, Text, Button } = $.ui.resolve(e)
316    const view = await read($, viewAtom)
317    const collapsed = await read($, collapsedAtom)
318    if (!view) return <Text dimColor>{tr('view.loading')}</Text>
319    const { header } = paneLines(view, collapsed, e.props.bodyColumns ?? 60)
320    const toggle = async () => {
321      const folded = !collapsed
322      await update($, collapsedAtom, () => folded)
323      await $.store.set('collapsed', folded)
324    }
325    return (
326      <Box flexDirection="column">
327        <Button key="toggle" plain onPress={toggle}>
328          {header}
329        </Button>
330        {!collapsed && view.kind === 'runs' ? paneLines(view, false, e.props.bodyColumns ?? 60).body.map((line) => <Text>{line}</Text>) : null}
331        {!collapsed && view.kind === 'run' ? (() => {
332          const bar = hudBar(view.percent, view.done, view.total)
333          const rows = paneRows(view)
334          return <Box flexDirection="column">
335            <Box key="summary" marginBottom={1}>
336              <Text><Text color={BAR_COLOR}>{bar.filled}</Text><Text color={TRACK_COLOR}>{bar.empty}</Text><Text color={BAR_COLOR}>{` ${bar.text}`}</Text></Text>
337            </Box>
338            {rows.map((row, index) => <Box key={`row-${index}`} flexDirection="column">
339              <Text>
340                <Text color={ROW_MARK_COLOR[row.state]} dimColor={row.state === 'pending' || row.state === 'skipped'}>{row.state === 'running' ? SPINNER_FRAMES[spinnerFrame] : ROW_MARK[row.state]}</Text>
341                {' '}<Text color={row.state === 'running' ? 'claude' : undefined} dimColor={row.state === 'pending' || row.state === 'skipped'} strikethrough={row.state === 'skipped'}>{row.label}</Text>
342              </Text>
343            </Box>)}
344            {view.notice ? <Box key="notice" marginTop={1}><Text color="warning">{view.notice}</Text></Box> : null}
345          </Box>
346        })() : null}
347      </Box>
348    )
349  })
350
351  on('tool.call', { tool: FULL_NAME }, async ($, e) => {
352    const input = e as unknown as { command?: unknown; json?: unknown; agentId?: string }
353    const result = await callTool(needPort(), String(input.command ?? 'help'), input.json, input.agentId)
354    if (result.ok && result.bind && !paneOpen) paneOpen = (await $.ui.open({ id: PANE, title: PANE_TITLE })).isPlaced
355    await refresh?.(true).catch(() => undefined)
356    return { result: result.text }
357  }).catch(($, e, next) => (next.called ? next(e) : { result: tr('mod.tool_failed') }))
358
359  // A subagent of a task: the agent, model, role and effort of the task's profile; an unknown profile refuses it.
360  on('agent.spawn', async ($, e, next) => {
361    const p = port
362    const bound = p ? await p.getRun() : null
363    if (!p || !bound) return next(e)
364    const decision = await spawnDecision(createModHost(p, await p.cwd()), bound, e.prompt)
365    if (decision.error) return { deny: `jtasks: ${decision.error}` }
366    if (!decision.task) return next(e)
367    // A task a transient pause kept for its subagent: that subagent is resumed, never a second one opened.
368    const kept = Object.entries(await p.getAgents()).find(([, a]) => a.paused && a.run === bound.run && a.task === decision.task)
369    if (kept) return { deny: `jtasks: ${tr('spawn.kept', { task: decision.task, agent: kept[0] })}` }
370    const spawned = await next({ ...e, prompt: decision.prompt ?? e.prompt, ...(decision.agent ? { subagentType: decision.agent } : {}), ...(decision.model ? { model: decision.model } : {}) })
371    if (spawned.agentId)
372      await p.setAgents({ ...(await p.getAgents()), [spawned.agentId]: { run: bound.run, task: decision.task, ...(decision.effort ? { effort: decision.effort } : {}) } })
373    return spawned
374  }).catch(($, e, next) => (next.called ? next(e) : { deny: tr('mod.profile_failed') }))
375
376  // The effort of the task's profile on every model request of its subagent, where the model takes one.
377  on('turn.step', async function* ($, e, next) {
378    const p = port
379    const agents = p && e.agentId && e.effort !== undefined ? await p.getAgents().catch(() => undefined) : undefined
380    const effort = e.agentId ? agents?.[e.agentId]?.effort : undefined
381    return yield* next(effort ? { ...e, effort } : e)
382  })
383
384  // The guard: edits outside the ownership, mutating git of a subagent, and direct edits of the run's files.
385  const guard = async (call: GuardedCall, agentId: string | undefined): Promise<string | undefined> => {
386    const p = port
387    const bound = p ? await p.getRun() : null
388    if (!p || !bound) return undefined
389    const agent = agentId ? (await p.getAgents())[agentId] : undefined
390    const verdict = await judgeCall(createModHost(p, await p.cwd()), await p.cwd(), bound, agent, call)
391    return verdict.allow ? undefined : `jtasks: ${verdict.reason}`
392  }
393  const refused = () => ({ deny: tr('mod.guard_failed') })
394  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
395    const deny = await guard({ tool: 'Edit', file_path: e.file_path }, e.agentId)
396    return deny ? { deny } : next(e)
397  }).catch(($, e, next) => (next.called ? next(e) : refused()))
398  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
399    const deny = await guard({ tool: 'Write', file_path: e.file_path }, e.agentId)
400    return deny ? { deny } : next(e)
401  }).catch(($, e, next) => (next.called ? next(e) : refused()))
402  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
403    const deny = await guard({ tool: 'NotebookEdit', notebook_path: e.notebook_path }, e.agentId)
404    return deny ? { deny } : next(e)
405  }).catch(($, e, next) => (next.called ? next(e) : refused()))
406  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
407    const deny = await guard({ tool: 'Bash', command: e.command }, e.agentId)
408    if (deny) return { deny }
409    const p = port
410    const bound = p && e.agentId ? await p.getRun() : null
411    if (!p || !bound || !e.agentId) return next(e)
412    const agent = (await p.getAgents())[e.agentId]
413    const owner = `gt:${bound.run}/${agent?.task ?? 'exec'}-bash`
414    const wrapped = await serializedCommand(createModHost(p, await p.cwd()), bound, e.command, owner)
415    return next(wrapped ? { ...e, command: wrapped } : e)
416  }).catch(($, e, next) => (next.called ? next(e) : refused()))
417
418  on('turn.start', async ($, e, next) => {
419    if (e.agentId === undefined) keepalive?.turnStarted()
420    // A subagent a transient pause kept is working again (resumed by a message): its place is an ordinary one.
421    const p = port
422    if (e.agentId !== undefined && p) {
423      const agents = await p.getAgents().catch(() => undefined)
424      const mine = agents?.[e.agentId]
425      if (agents && mine?.paused) await p.setAgents({ ...agents, [e.agentId]: { ...mine, paused: false } }).catch(() => undefined)
426    }
427    return next(e)
428  })
429
430  // What a loop that dies on an API error says about it (kind, details, the rendered error), kept for its end.
431  on('classic.StopFailure', async ($, e, next) => {
432    failures?.stopFailure(e)
433    return next(e)
434  }).catch(($, e, next) => next(e))
435
436  // Claude Code went on by itself after a usage limit reset: a transient pause the host would end later ends now.
437  on('classic.Notification', async ($, e, next) => {
438    const p = port
439    if (p && e.notification_type === 'quota_auto_resume_fired') void leaveTransientPause(p, 'quota_auto_resume_fired').catch(() => undefined)
440    return next(e)
441  }).catch(($, e, next) => next(e))
442
443  // The keepalive's own message, checked again right before it enters: dropped when the run moved meanwhile (in
444  // doubt, or past 3 s, it goes).
445  on('prompt.submit', async ($, e, next) => {
446    const p = port
447    if (e.origin?.kind !== 'plugin' || e.origin.name !== 'jtasks' || !keepalive || !p) return next(e)
448    const wanted = await Promise.race([keepalive.stillWanted().catch(() => true), p.sleep(3000).then(() => true)])
449    return wanted ? next(e) : { drop: tr('keepalive.dropped') }
450  })
451
452  // The main loop's end: the keepalive decides (the turn's end waits 3 s at most; the rest goes on in the
453  // background). A subagent whose loop ended with its task still in progress: the task goes back to the queue.
454  on('turn.complete', async ($, e, next) => {
455    const done = await next(e)
456    const p = port
457    if (!p) return done
458    if (e.agentId === undefined) {
459      // A transient failure of the orchestrator itself (rev. 5.7): the run is paused and the keepalive stays quiet.
460      const paused = e.reason === 'error'
461        ? await Promise.race([pauseOnFailure(p, undefined, e.answer, () => lastMessage(() => $.session.messages())).catch(() => undefined), p.sleep(2500).then(() => undefined)])
462        : undefined
463      if (keepalive) await Promise.race([keepalive.turnEnded(e.reason).catch(() => undefined), p.sleep(paused ? 500 : 3000)])
464      if (paused) {
465        tell?.paused(paused)
466        await refresh?.(true).catch(() => undefined)
467      }
468      return done
469    }
470    const agents = await p.getAgents()
471    const mine = agents[e.agentId]
472    if (!mine) return done
473    // A subagent dead on a transient failure (rev. 5.7): the run is paused and its task kept in progress with its
474    // locks; its place here stays (the guard still knows its task), for the orchestrator to resume it.
475    if (e.reason === 'error') {
476      const agentId = e.agentId
477      const paused = await pauseOnFailure(p, agentId, e.answer, () => lastMessage(() => $.session.messages({ agentId })), mine).catch(() => undefined)
478      if (paused) {
479        await p.setAgents({ ...(await p.getAgents()), [agentId]: { ...mine, paused: true } })
480        tell?.paused(paused)
481        await refresh?.(true).catch(() => undefined)
482        return done
483      }
484    }
485    const { [e.agentId]: _gone, ...rest } = agents
486    await p.setAgents(rest)
487    const bound = await p.getRun()
488    if (bound?.run === mine.run) await callTool(p, `release ${mine.task} --agent-ended`, undefined, e.agentId).catch(() => undefined)
489    await refresh?.(true).catch(() => undefined)
490    return done
491  })
492
493  on('prompt.compose', async ($, e, next) => {
494    const composed = await next(e)
495    const p = port
496    const bound = p ? await p.getRun() : null
497    if (!p || !bound) return composed
498    const text = await protocolSection(createModHost(p, await p.cwd()), bound).catch(() => undefined)
499    const sections = composed.sections.filter((s) => s.id !== SECTION_ID)
500    return { sections: text ? [...sections, { id: SECTION_ID, text, scope: 'session' as const }] : sections }
501  })
502}
503
hooks/mod-host.ts 203 lines
1// The core's Host made of the mod's $, through a Port: files through $.fs; what $.fs lacks (rename, remove, an
2// empty folder, an exclusive folder) through $.process.run; processes through $.process.run, the `jlock hold
3// --detach` of a task included (one call; jlock's own keeper holds the lock until `jlock release` or the run's ttl,
4// so a reload of the mod loses nothing). $.process.spawn stays for long-running children (docs/DEVIATIONS.md).
5// The Port is built in session.start out of closures spelled `$.noun.call(...)`, since $ itself is never passed.
6
7import type { FsEntry, FsStat, ProcessRunInit, ProcessRunResult, ProcessSpawnRequest } from 'claude-code'
8import type { DirEntry, FileStat, Host, Resident, RunOptions, RunResult, SpawnOptions } from '../core/core.js'
9import type { AgentTask, BoundRun } from '../types'
10
11export type Env = Record<string, string | undefined>
12
13/** What the mod's code needs from $, as plain functions (built where $ is in hand). */
14export interface Port {
15  read(path: string): Promise<string>
16  write(path: string, text: string): Promise<void>
17  list(path: string): Promise<FsEntry[]>
18  stat(path: string): Promise<FsStat>
19  run(argv: readonly string[], init: ProcessRunInit): Promise<ProcessRunResult>
20  spawn(request: ProcessSpawnRequest): AsyncGenerator<{ stream: 'stdout' | 'stderr'; text: string }, { code: number | null }>
21  sleep(ms: number): Promise<void>
22  getRun(): Promise<BoundRun | null>
23  setRun(run: BoundRun | null): Promise<void>
24  getAgents(): Promise<Record<string, AgentTask>>
25  setAgents(agents: Record<string, AgentTask>): Promise<void>
26  sessionId(): Promise<string>
27  cwd(): Promise<string>
28  env: Env
29}
30
31/** The platform, from the shape of the working directory (the mod has no `process`). */
32export function platformOf(cwd: string): string {
33  if (/^[A-Za-z]:[\\/]/.test(cwd)) return 'win32'
34  return cwd.startsWith('/Users/') ? 'darwin' : 'linux'
35}
36
37const win = (p: string) => p.replace(/\//g, '\\')
38
39class SpawnedResident implements Resident {
40  /** $.process.spawn closes standard input at the start: nothing to close later. */
41  async closeInput(): Promise<void> {}
42
43  private readonly out: string[] = []
44  private partial = ''
45  private done = false
46  private wakers: (() => void)[] = []
47  readonly exited: Promise<number>
48  private readonly stream: AsyncGenerator<{ stream: 'stdout' | 'stderr'; text: string }, { code: number | null }>
49
50  constructor(
51    private readonly port: Port,
52    argv: readonly string[],
53    cwd: string | undefined,
54  ) {
55    this.stream = port.spawn(cwd ? { argv, cwd } : { argv })
56    this.exited = this.pump()
57  }
58
59  private async pump(): Promise<number> {
60    let code = 1
61    try {
62      for (;;) {
63        const r = await this.stream.next()
64        if (r.done) {
65          code = r.value?.code ?? 1
66          break
67        }
68        if (r.value.stream !== 'stdout') continue
69        this.partial += r.value.text
70        let cut: number
71        while ((cut = this.partial.indexOf('\n')) >= 0) {
72          this.out.push(this.partial.slice(0, cut).replace(/\r$/, ''))
73          this.partial = this.partial.slice(cut + 1)
74        }
75        this.wake()
76      }
77    } catch {
78      code = 127
79    }
80    if (this.partial.trim()) this.out.push(this.partial.trim())
81    this.done = true
82    this.wake()
83    return code
84  }
85
86  private wake() {
87    const w = this.wakers
88    this.wakers = []
89    for (const f of w) f()
90  }
91
92  async firstLine(timeoutMs = 30_000): Promise<string | undefined> {
93    const deadline = Date.now() + timeoutMs
94    while (!this.out.length && !this.done && Date.now() < deadline) {
95      await Promise.race([new Promise<void>((resolve) => this.wakers.push(resolve)), this.port.sleep(Math.min(1000, deadline - Date.now()))])
96    }
97    return this.out[0]
98  }
99
100  lines(): string[] {
101    return [...this.out]
102  }
103
104  async send(): Promise<boolean> {
105    return false
106  }
107
108  async stop(): Promise<void> {
109    if (this.done) return
110    await this.stream.return({ code: null } as never).catch(() => undefined)
111    await Promise.race([this.exited, this.port.sleep(3000)])
112  }
113}
114
115export function createModHost(port: Port, cwd: string): Host {
116  const env = port.env
117  const platform = platformOf(cwd)
118  const isWin = platform === 'win32'
119  const run = async (argv: readonly string[], options: RunOptions = {}): Promise<RunResult> => {
120    try {
121      const init: { cwd?: string; env?: Record<string, string>; stdin?: string; timeoutMs?: number } = {}
122      if (options.cwd) init.cwd = options.cwd
123      if (options.env) init.env = options.env
124      if (options.stdin !== undefined) init.stdin = options.stdin
125      init.timeoutMs = options.timeoutMs ?? 120_000
126      const r = await port.run(argv, init)
127      return { code: r.exitCode, stdout: r.stdout, stderr: r.stderr }
128    } catch (error) {
129      return { code: 127, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
130    }
131  }
132  const stat = async (path: string): Promise<FileStat | undefined> => {
133    try {
134      const s = await port.stat(path)
135      return { kind: s.isLink ? 'other' : s.kind === 'file' ? 'file' : s.kind === 'dir' ? 'dir' : 'other', size: s.size, mtimeMs: s.mtimeMs }
136    } catch {
137      return undefined
138    }
139  }
140  const readText = async (path: string): Promise<string | undefined> => {
141    try {
142      return await port.read(path)
143    } catch {
144      return undefined
145    }
146  }
147  const mkdirOne = async (path: string): Promise<boolean> =>
148    (await run(isWin ? ['cmd', '/d', '/c', 'mkdir', win(path)] : ['mkdir', path])).code === 0
149  return {
150    platform,
151    cwd: () => cwd,
152    runtime: 'claude-code mod',
153    env: (name) => env[name],
154    now: () => new Date(),
155    random: () => Math.random(),
156    sleep: (ms) => port.sleep(ms),
157    readText,
158    writeText: (path, text) => port.write(path, text),
159    async appendText(path, text) {
160      await port.write(path, ((await readText(path)) ?? '') + text)
161    },
162    async rename(from, to) {
163      const r = await run(isWin ? ['cmd', '/d', '/c', 'move', '/y', win(from), win(to)] : ['mv', '-f', from, to])
164      if (r.code !== 0) throw new Error(`rename ${from} → ${to}: ${(r.stderr || r.stdout).trim()}`)
165    },
166    async remove(path, options) {
167      const s = await stat(path)
168      if (!s) return
169      const argv =
170        s.kind === 'dir'
171          ? isWin
172            ? ['cmd', '/d', '/c', 'rmdir', options?.recursive ? '/s' : '', '/q', win(path)].filter(Boolean)
173            : ['rm', options?.recursive ? '-rf' : '-d', path]
174          : isWin
175            ? ['cmd', '/d', '/c', 'del', '/f', '/q', win(path)]
176            : ['rm', '-f', path]
177      const r = await run(argv)
178      if (r.code !== 0 && (await stat(path))) throw new Error(`remove ${path}: ${(r.stderr || r.stdout).trim()}`)
179    },
180    async mkdir(path) {
181      if ((await stat(path))?.kind === 'dir') return
182      if (isWin) await mkdirOne(path)
183      else await run(['mkdir', '-p', path])
184    },
185    async mkdirExclusive(path) {
186      if (await stat(path)) return false
187      return mkdirOne(path)
188    },
189    async list(path): Promise<DirEntry[]> {
190      try {
191        return (await port.list(path)).map((e) => ({ name: e.name, kind: e.isLink ? 'other' : e.kind }))
192      } catch {
193        return []
194      }
195    },
196    stat,
197    run,
198    async spawn(argv, options: SpawnOptions = {}): Promise<Resident> {
199      return new SpawnedResident(port, argv, options.cwd)
200    },
201  }
202}
203
hooks/agents.ts 65 lines
1// Subagents of a task (section 13): the spawn of a subagent whose prompt carries `--task <id>` becomes the agent and
2// gets the model of the task's profile, with the profile's role in its prompt (`--role <id>`, the convention the roles mod
3// reads) and the profile's effort on every model request of its loop; a profile the run does not have refuses the spawn. The `serialize` commands of the run, in a subagent's
4// Bash, run through `jlock run` (one at a time on the machine).
5
6import { readRun, readTasks, runDir, serializeEntry, serializeKey, type Host, tr } from '../core/core.js'
7import type { BoundRun } from '../types'
8
9export function taskOfPrompt(prompt: string): { run?: string; task?: string } {
10  const task = /--task[=\s]+([^\s]+)/.exec(prompt)?.[1]
11  const run = /--run[=\s]+([^\s]+)/.exec(prompt)?.[1]
12  return { run, task }
13}
14
15export interface SpawnDecision {
16  task?: string
17  agent?: string
18  model?: string
19  effort?: Effort
20  prompt?: string
21  error?: string
22}
23
24export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
25const EFFORTS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
26
27/** A profile's effort as a level Claude Code takes, or nothing for any other text. */
28export function effortOf(text: string | undefined): Effort | undefined {
29  return text && EFFORTS.includes(text) ? (text as Effort) : undefined
30}
31
32/** What the spawn of a task's subagent must become, or why it is refused; `{}` for a spawn of no task. */
33export async function spawnDecision(host: Host, bound: BoundRun, prompt: string): Promise<SpawnDecision> {
34  const { run, task } = taskOfPrompt(prompt)
35  if (!task) return {}
36  if (run && run !== bound.run) return { error: tr('spawn.other_run', { run, bound: bound.run }) }
37  const tasks = await readTasks(host, runDir(bound.root, bound.run)).catch(() => undefined)
38  const t = tasks?.tasks.find((x) => x.id === task)
39  if (!tasks || !t) return { error: tr('task.not_found', { run: bound.run, id: task }) }
40  const profile = tasks.profiles[t.profile]
41  if (!profile) return { error: tr('spawn.unknown_profile', { profile: t.profile, profiles: Object.keys(tasks.profiles).join(', ') }) }
42  const c = profile.claude ?? {}
43  const role = c.role ? `--role ${c.role} ` : ''
44  const out: SpawnDecision = { task, prompt: `${role}${prompt}` }
45  if (c.agent) out.agent = c.agent
46  if (c.model) out.model = c.model
47  const effort = effortOf(c.effort)
48  if (effort) out.effort = effort
49  return out
50}
51
52/** A POSIX single-quoted word. */
53function quote(text: string): string {
54  return `'${text.replace(/'/g, `'\\''`)}'`
55}
56
57/** The command a subagent's Bash runs: through `jlock run` when it falls under the run's `serialize`. */
58export async function serializedCommand(host: Host, bound: BoundRun, command: string, owner: string): Promise<string | undefined> {
59  const run = await readRun(host, runDir(bound.root, bound.run)).catch(() => undefined)
60  const entry = run ? serializeEntry(run.serialize, command) : undefined
61  if (!entry) return undefined
62  const key = serializeKey(bound.root, entry)
63  return `jlock run ${quote(key)} --owner ${quote(owner)} --group ${quote(bound.run)} --intent ${quote(command)} -- sh -c ${quote(command)}`
64}
65
hooks/guard.ts 24 lines
1// The ownership guard of the mod: the core's rule (src/guard.ts), decided from tasks.json without asking jlock. A
2// subagent bound to a task edits only inside its ownership and runs no mutating git; nobody edits the run's own
3// files by hand.
4
5import { checkEdit, checkShell, readTasks, runDir, type Host, type Verdict } from '../core/core.js'
6import type { AgentTask, BoundRun } from '../types'
7
8export type GuardedCall =
9  | { tool: 'Edit' | 'Write' | 'MultiEdit'; file_path: string }
10  | { tool: 'NotebookEdit'; notebook_path: string }
11  | { tool: 'Bash'; command: string }
12
13/** The verdict on one call of the main agent (no task) or of a subagent (its task, when it has one in progress). */
14export async function judgeCall(host: Host, cwd: string, bound: BoundRun, agent: AgentTask | undefined, call: GuardedCall): Promise<Verdict> {
15  let task
16  if (agent && agent.run === bound.run) {
17    const tasks = await readTasks(host, runDir(bound.root, bound.run))
18    task = tasks?.tasks.find((t) => t.id === agent.task && t.status === 'in_progress')
19  }
20  const ctx = { root: bound.root, platform: host.platform, cwd, task, home: host.env('HOME') ?? host.env('USERPROFILE') }
21  if (call.tool === 'Bash') return checkShell(ctx, call.command)
22  return checkEdit(ctx, call.tool === 'NotebookEdit' ? call.notebook_path : call.file_path)
23}
24
hooks/keepalive.ts 185 lines
1// The keepalive in Claude Code (plan rev. 5.3): at the end of the main loop's turn, the core's decision with the
2// session's live subagents; a "run is idle" message through $.prompt.submit when it says so, checked again right
3// before it is delivered (a turn may have moved the run meanwhile); a toast, a notification and a status line when it
4// gives up, or at once after an API error or a refusal (nothing is sent then). Never on an interrupted turn (quiet
5// until the run moves), never over a draft the person is typing (it waits until the box is empty and the session
6// idle), never in a -p run or the SDK.
7
8import { checkKeepalive, holdKeepalive, stalledNotice, type KeepaliveDecision } from '../core/core.js'
9import { createModHost, type Port } from './mod-host.ts'
10
11/** What the keepalive needs from $ besides the port (closures built in session.start). */
12export interface KeepaliveUi {
13  /** The session's agents and their status ($.agent.list), or undefined when the engine does not answer. */
14  agents(): Promise<{ status: string }[] | undefined>
15  /** The person's draft in the prompt box ($.prompt.read). */
16  draft(): Promise<string>
17  submit(text: string): Promise<unknown>
18  /** The host gave up: tell the person (toast, notification, status line). */
19  stalled(notice: string): void
20  /** Clears the status line of an earlier give-up. */
21  clear(): void
22}
23
24/** Agents whose loop can still end and wake the orchestrator. */
25const ALIVE = new Set(['pending', 'running', 'waiting'])
26
27export class Keepalive {
28  /** A message is on its way (submitted, not delivered yet): never a second one. */
29  private pending = false
30  /** A message waits for the person's draft to go (or for the session to be idle). */
31  private deferred = false
32  private busy = false
33  private shown = false
34  /** The message of a run that left a transient pause (rev. 5.7), waiting for the session to be idle and the box empty. */
35  private queued: string | undefined
36  /** That message is on its way: let it through, whatever the run looks like. */
37  private resuming = false
38
39  constructor(
40    private readonly port: Port,
41    private readonly ui: KeepaliveUi,
42    private readonly interactive: boolean,
43  ) {}
44
45  /** The subagents alive now: the engine's list, else the ones the mod saw spawned for the run. */
46  private async liveAgents(run: string): Promise<number> {
47    const listed = await this.ui.agents().catch(() => undefined)
48    if (listed) return listed.filter((a) => ALIVE.has(a.status)).length
49    return Object.values(await this.port.getAgents()).filter((a) => a.run === run).length
50  }
51
52  private async actor(): Promise<string> {
53    return `claude:${await this.port.sessionId()}`
54  }
55
56  /**
57   * The module (re)loaded: the status line an earlier load left is ours to keep or clear, by what the run's
58   * `keepalive.json` says now.
59   */
60  async restore(status: (text: string | undefined) => void): Promise<void> {
61    const bound = await this.port.getRun()
62    const notice = bound ? await stalledNotice(createModHost(this.port, await this.port.cwd()), bound.root, bound.run).catch(() => undefined) : undefined
63    this.shown = !!notice
64    status(notice?.replace(/^⚠\s*/, ''))
65  }
66
67  /** A turn of the main loop started: whatever was waiting is moot (that turn's end decides again). */
68  turnStarted(): void {
69    this.busy = true
70    this.pending = false
71    this.deferred = false
72    this.resuming = false
73  }
74
75  /**
76   * The run left a transient pause: `text` tells the orchestrator to resume the same agents. Sent when the session is
77   * idle and the box empty (a turn running, or a draft, makes it wait); never in a -p run or the SDK.
78   */
79  deliver(text: string): void {
80    if (!this.interactive) return
81    this.queued = text
82    void this.flush().catch(() => undefined)
83  }
84
85  private async flush(): Promise<boolean> {
86    const text = this.queued
87    if (!text || this.busy || this.pending) return false
88    if ((await this.ui.draft().catch(() => '')).trim()) return false
89    this.queued = undefined
90    this.pending = true
91    this.resuming = true
92    void this.ui
93      .submit(text)
94      .catch(() => undefined)
95      .finally(() => {
96        this.pending = false
97      })
98    return true
99  }
100
101  /** The main loop's turn ended; `reason` as `turn.complete` says it. */
102  async turnEnded(reason: string): Promise<KeepaliveDecision | undefined> {
103    this.busy = false
104    if (!this.interactive) return undefined
105    const bound = await this.port.getRun()
106    if (!bound) return undefined
107    if (reason === 'aborted') {
108      this.deferred = false
109      this.clearShown()
110      await holdKeepalive(createModHost(this.port, await this.port.cwd()), bound.root, bound.run)
111      return undefined
112    }
113    // An API error or a refusal: nothing is sent (it could fail the same way), but the person is told at once.
114    if (reason === 'error' || reason === 'refusal') return this.decide('error')
115    if (reason !== 'answer') return undefined
116    if (this.queued && (await this.flush())) return undefined
117    return this.decide()
118  }
119
120  /**
121   * The clock: a message that waited for the draft goes once the box is empty and the session idle; a give-up on
122   * the status line goes away once it no longer holds (the run moved, `keepalive on` or `unpause` elsewhere).
123   */
124  async tick(): Promise<KeepaliveDecision | undefined> {
125    if (this.shown) {
126      const bound = await this.port.getRun()
127      const notice = bound ? await stalledNotice(createModHost(this.port, await this.port.cwd()), bound.root, bound.run).catch(() => 'keep') : undefined
128      if (!notice) this.clearShown()
129    }
130    if (this.queued && (await this.flush())) return undefined
131    if (!this.deferred || this.busy || this.pending) return undefined
132    return this.decide()
133  }
134
135  private async decide(cause?: 'error'): Promise<KeepaliveDecision | undefined> {
136    if (this.pending) return undefined
137    const bound = await this.port.getRun()
138    if (!bound) return undefined
139    if (!cause && (await this.ui.draft().catch(() => '')).trim()) {
140      this.deferred = true
141      return undefined
142    }
143    this.deferred = false
144    const host = createModHost(this.port, await this.port.cwd())
145    const decision = await checkKeepalive(host, {
146      root: bound.root,
147      run: bound.run,
148      liveAgents: await this.liveAgents(bound.run),
149      actor: await this.actor(),
150      commit: true,
151      ...(cause ? { cause } : {}),
152    })
153    if (decision.action === 'nudge') {
154      this.clearShown()
155      this.pending = true
156      void this.ui
157        .submit(decision.text)
158        .catch(() => undefined)
159        .finally(() => {
160          this.pending = false
161        })
162    } else if (decision.action === 'stalled') {
163      this.shown = true
164      this.ui.stalled(decision.notice)
165    } else if (decision.reason !== 'stalled' && decision.reason !== 'held') this.clearShown()
166    return decision
167  }
168
169  private clearShown(): void {
170    if (!this.shown) return
171    this.shown = false
172    this.ui.clear()
173  }
174
175  /** Right before the message is delivered: still idle with work to do? (Without counting it again.) */
176  async stillWanted(): Promise<boolean> {
177    if (this.resuming) return true
178    const bound = await this.port.getRun()
179    if (!bound) return false
180    const host = createModHost(this.port, await this.port.cwd())
181    const decision = await checkKeepalive(host, { root: bound.root, run: bound.run, liveAgents: await this.liveAgents(bound.run), actor: await this.actor(), fresh: true })
182    return decision.action !== 'none'
183  }
184}
185
hooks/transient.ts 88 lines
1// Transient failures in Claude Code (plan rev. 5.7). A loop that dies on an API error ends with `turn.complete`
2// `reason: 'error'`, which carries no text; the classic StopFailure event carries the kind Claude Code gave it
3// (`rate_limit`, `overloaded`, `server_error`, `authentication_failed`…), `error_details` and, in
4// `last_assistant_message`, the error as the conversation shows it ("API Error: You've hit your session limit ·
5// resets 12:50am"); with `agent_id` when it fired inside a subagent. This module keeps the last one of each loop and,
6// at the loop's end, classifies it with the core's tables (with the turn's answer and the loop's last message as
7// more text, when they are the rendered error). A StopFailure that arrives after its loop's end is still heard: the
8// caller learns of it through `late`. A transient one pauses the run with the task kept (the caller keeps the
9// subagent's place); the clock (`resumeAt`) or Claude Code's own `quota_auto_resume_fired` notification ends it.
10
11import { classifyFailure, type Classification, type ProviderFailure } from '../core/core.js'
12
13/** The main loop's key among the failures. */
14export const MAIN = '(main)'
15
16/** How long a subagent's end waits for its StopFailure, when the end came first. */
17export const STOP_FAILURE_WAIT_MS = 1500
18/** A StopFailure this long after its loop's end still counts for it. */
19export const LATE_MS = 15_000
20
21/** An error as Claude Code shows it in the conversation (`API Error: …`, the usage-limit lines). */
22const RENDERED_ERROR = /^\s*(?:API Error\b|Claude AI usage limit reached|You've hit your \w+ limit|\S+ limit reached\s*[·∙])/i
23
24interface Seen {
25  kind?: string
26  texts: string[]
27  at: number
28}
29
30/** The classic event a hook hands us, as far as this module reads it. */
31export interface StopFailureEvent {
32  error?: string
33  error_details?: string
34  last_assistant_message?: string
35  agent_id?: string
36}
37
38export class FailureWatch {
39  private readonly seen = new Map<string, Seen>()
40  /** Loops that ended on an error before their StopFailure: when it arrives, `late` hears it. */
41  private readonly ended = new Map<string, number>()
42
43  constructor(
44    private readonly sleep: (ms: number) => Promise<void>,
45    private readonly now: () => Date,
46    /** A StopFailure that came after its loop ended on an error, classified (the main loop's: `agentId` undefined). */
47    private readonly late: (agentId: string | undefined, failure: Classification) => void,
48  ) {}
49
50  /** A StopFailure: kept for the end of its loop (the main one, or the subagent's), or handed to `late`. */
51  stopFailure(e: StopFailureEvent): void {
52    const key = e.agent_id ?? MAIN
53    const texts = [e.last_assistant_message, e.error_details].filter((t): t is string => typeof t === 'string' && !!t.trim())
54    const seen: Seen = { ...(e.error ? { kind: e.error } : {}), texts, at: this.now().getTime() }
55    const ended = this.ended.get(key)
56    this.ended.delete(key)
57    if (ended !== undefined && seen.at - ended < LATE_MS) {
58      this.late(e.agent_id, classifyFailure(this.failureOf(seen, []), this.now()))
59      return
60    }
61    this.seen.set(key, seen)
62  }
63
64  private failureOf(seen: Seen | undefined, more: string[]): ProviderFailure {
65    const failure: ProviderFailure = { texts: [...(seen?.texts ?? []), ...more] }
66    if (seen?.kind) failure.kind = seen.kind
67    return failure
68  }
69
70  /**
71   * The failure that ended a loop (`agentId` absent: the main one), classified. `answer` is the turn's answer, `last`
72   * reads the loop's last message (both count only when they are the error as Claude Code renders it: a subagent's
73   * own words about rate limits or timeouts in the code it works on must not pause the run). `wait`: how long to wait
74   * for a StopFailure that has not arrived yet (if it comes later still, `late` hears it).
75   */
76  async classify(agentId: string | undefined, answer: string, last: () => Promise<string | undefined>, wait = 0): Promise<Classification> {
77    const key = agentId ?? MAIN
78    for (let waited = 0; !this.seen.has(key) && waited < wait; waited += 100) await this.sleep(100)
79    const seen = this.seen.get(key)
80    this.seen.delete(key)
81    const fresh = seen && this.now().getTime() - seen.at < 10 * 60_000 ? seen : undefined
82    const more = [answer, await last().catch(() => undefined)].filter((t): t is string => !!t && RENDERED_ERROR.test(t))
83    const failure = classifyFailure(this.failureOf(fresh, more), this.now())
84    if (!fresh && !failure.transient) this.ended.set(key, this.now().getTime())
85    return failure
86  }
87}
88
hooks/pane.tsx 104 lines
1// The "Tasks" pane: the bound run's checklist and bar (the core's view model, the same the OpenCode sidebar draws),
2// folded to one line by its header button. The view is kept in $.state, so writing it redraws the pane.
3
4import { barCells, headerText, lineText, loadRunsView, loadView, repoRoot, summaryTail, tr, type View } from '../core/core.js'
5import type { PaneView } from '../types'
6import type { ViewLine } from '../core/types/view.js'
7import { createModHost, type Port } from './mod-host.ts'
8
9export const PANE = 'tasks'
10export const PANE_TITLE = 'Tasks'
11
12/** The view of the session: its bound run, or the runs of the repository. */
13export async function computeView(p: Port): Promise<PaneView> {
14  const cwd = await p.cwd()
15  const host = createModHost(p, cwd)
16  const bound = await p.getRun()
17  if (bound) {
18    const v = await loadView(host, bound.root, bound.run, 'claude')
19    if (v) return v as PaneView
20  }
21  const root = await repoRoot(host, cwd)
22  return (root ? await loadRunsView(host, root) : { kind: 'runs', runs: [] }) as PaneView
23}
24
25/**
26 * The bar's color, claude-hud's Usage blue (ANSI bright blue), and its track: the same blue, dimmed by hand, since
27 * the terminal draws `dimColor` as plain gray whatever the color.
28 */
29export const BAR_COLOR = 'blueBright'
30export const TRACK_COLOR = '#2e4678'
31
32/** Claude Code's spinner shapes; ✽ stands for ✳, which terminals may draw as a green emoji square. */
33export const SPINNER_FRAMES = ['✻', '✶', '✽', '✢', '·'] as const
34
35/** Each row's mark (a running row draws the spinner instead) and its color: green check, red cross, the rest dim. */
36export const ROW_MARK: Record<PaneRow['state'], string> = { done: '✓', running: '✻', pending: '○', failed: '✗', skipped: '✓' }
37export const ROW_MARK_COLOR: Record<PaneRow['state'], string | undefined> = { done: 'green', running: 'claude', pending: undefined, failed: 'error', skipped: undefined }
38
39export interface HudBar {
40  filled: string
41  empty: string
42  text: string
43}
44
45export function hudBar(percent: number, done: number, total: number): HudBar {
46  const filledCount = Math.max(0, Math.min(10, Math.round(percent / 100 * 10)))
47  return {
48    filled: '█'.repeat(filledCount),
49    empty: '░'.repeat(10 - filledCount),
50    text: `${percent}% · ${done}/${total}`,
51  }
52}
53
54export interface PaneRow {
55  label: string
56  state: 'done' | 'running' | 'pending' | 'failed' | 'skipped'
57}
58
59export function paneRows(view: PaneView): PaneRow[] {
60  const v = view as View
61  if (v.kind !== 'run') return []
62  return v.lines.map((line: ViewLine) => ({
63    label: `${line.id.replace(/^T0*/, 'T')} - ${line.title}${line.note ? ` · ${line.note}` : ''}`,
64    state: rowState(line.state),
65  }))
66}
67
68function rowState(state: ViewLine['state']): PaneRow['state'] {
69  switch (state) {
70    case 'done': return 'done'
71    case 'running':
72    case 'waiting': return 'running'
73    case 'ready':
74    case 'blocked': return 'pending'
75    case 'failed': return 'failed'
76    case 'skipped': return 'skipped'
77  }
78}
79
80// Kept as a compatibility adapter for the current renderer; the next pane slice consumes hudBar/paneRows directly.
81export interface PaneSummary {
82  full: string
83  partial: string
84  empty: string
85  tail: string
86}
87
88export function paneLines(view: PaneView, collapsed: boolean, columns: number): { header: string; summary?: PaneSummary; body: string[] } {
89  const v = view as View
90  const header = headerText(v, collapsed)
91  if (collapsed) return { header, body: [] }
92  if (v.kind === 'runs') {
93    const body = v.runs.length ? v.runs.map((r) => `${r.id} · ${r.status} · ${r.done}/${r.total} (${r.percent}%)`) : [tr('view.no_runs')]
94    return { header, body }
95  }
96  const width = Math.max(24, columns)
97  return { header, summary: { ...barCells(v.percent), tail: summaryTail(v) }, body: v.lines.map((l) => lineText(l, width)) }
98}
99
100/** Whether the view has something that changes without the run's files changing (locks, waits). */
101export function isLive(view: PaneView | null): boolean {
102  return !!view && view.kind === 'run' && view.lines.some((l) => l.state === 'running' || l.state === 'waiting' || l.state === 'blocked')
103}
104
hooks/protocol.ts 38 lines
1// What the model reads about jtasks in Claude Code: the tool's description and the protocol section of the
2// system prompt while a run is bound (the protocol text itself is the core's, shared with OpenCode).
3
4import { pauseProtocolLine, progressLine, protocolText, readRun, readTasks, runDir, tr, VERSION, type Host } from '../core/core.js'
5import type { BoundRun } from '../types'
6
7export const TOOL_NAME = 'jtasks'
8export const SECTION_ID = 'jtasks:protocol'
9
10// What the model reads is English in every language (plan section 21); the answers are in the person's language.
11export const TOOL_DESCRIPTION =
12  `jtasks ${VERSION}: a deterministic task list with dependencies and file ownership (jlock). ` +
13  '"command" is the jtasks CLI command line without the "jtasks" (e.g. "next", "start T04", "claim T04 src/x.ts", ' +
14  '"done T04 --evidence \\"bun test: 12 passed\\"", "status", "plan --add"); "json" carries the plan, the batch or ' +
15  'the task in JSON (plan, plan --add, plan --fix <id>). "help" lists the actions. Never edit tasks.json, the tickets ' +
16  'or git by hand: only through this tool.'
17
18export const TOOL_SCHEMA = {
19  type: 'object',
20  properties: {
21    command: { type: 'string', description: 'The jtasks command line, without the "jtasks": "next", "start T04", "status"…' },
22    json: { description: 'A plan, batch or task in JSON, for plan, plan --add and plan --fix.' },
23  },
24  required: ['command'],
25  additionalProperties: false,
26}
27
28const SPAWN = "the Agent tool; the prompt starts with --run and --task, and the model comes from the task's profile"
29
30/** The protocol section for a bound run, or undefined when its files are gone. */
31export async function protocolSection(host: Host, bound: BoundRun): Promise<string | undefined> {
32  const dir = runDir(bound.root, bound.run)
33  const [run, tasks] = await Promise.all([readRun(host, dir).catch(() => undefined), readTasks(host, dir).catch(() => undefined)])
34  if (!run || !tasks) return undefined
35  const summary = run.status === 'paused' ? `${tr('status.paused')} · ${progressLine(tasks.tasks)}` : progressLine(tasks.tasks)
36  return protocolText(run.id, summary, SPAWN, run.status === 'paused' && run.pause ? [pauseProtocolLine(run.pause)] : [])
37}
38
types/index.d.ts 59 lines
1// The jtasks mod's $.state contract.
2
3/** The run bound to this session (subagents share it). */
4export type BoundRun = {
5  run: string
6  /** The repository root of the run. */
7  root: string
8}
9
10/** The task a subagent works on (from its spawn's `--task`, or its own `jtasks start`). */
11export type AgentTask = {
12  run: string
13  task: string
14  /** The effort of the task's profile, set on every model request of the subagent's loop. */
15  effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'
16  /** Its loop died on a transient failure and the run was paused with its task kept (rev. 5.7): resume it, never open another. */
17  paused?: boolean
18}
19
20/** One checklist line of the pane (the core's ViewLine). */
21export type PaneLine = {
22  mark: string
23  id: string
24  title: string
25  detail: string
26  state: 'done' | 'running' | 'waiting' | 'ready' | 'blocked' | 'skipped' | 'failed'
27  /** A short note the row adds (rev. 5.6): what a task waits for after a yield, or its saved edits. */
28  note?: string
29}
30
31/** What the pane draws (the core's View): a run's checklist, or the repository's runs when none is bound. */
32export type PaneView =
33  | {
34      kind: 'run'
35      run: string
36      paused: boolean
37      done: number
38      total: number
39      skipped: number
40      failed: number
41      remaining: number
42      percent: number
43      lines: PaneLine[]
44      /** The keepalive gave up on the run as it stands: one line under the tasks. */
45      notice?: string
46    }
47  | { kind: 'runs'; runs: { id: string; status: string; done: number; total: number; percent: number }[] }
48
49declare module 'claude-code' {
50  interface PluginState {
51    'jtasks': {
52      run: BoundRun | null
53      collapsed: boolean
54      view: Shaped<PaneView | null>
55      agents: Record<string, AgentTask>
56    }
57  }
58}
59