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

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).
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).
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.
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.
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.
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)
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.
hooks/register.tsx 503 lines1// 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}
503hooks/mod-host.ts 203 lines1// 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}
203hooks/agents.ts 65 lines1// 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}
65hooks/guard.ts 24 lines1// 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}
24hooks/keepalive.ts 185 lines1// 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}
185hooks/transient.ts 88 lines1// 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}
88hooks/pane.tsx 104 lines1// 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}
104hooks/protocol.ts 38 lines1// 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}
38types/index.d.ts 59 lines1// 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