Holds each subagent's deliverable on disk across spawn, write, compaction, stop and TaskStop; agent model + context line under the prompt

hooks/register.tsx 166 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { AgentFlowAgent } from '../types'
5import {
6 COMPACT_NOTE, QUIET_MS, REISSUE_MS, REPORT_REJECTION, SPAWN_NOTE, WROTE_NOTHING_MIN,
7 agentEntries, isDurable, lastMarker, stopHold, trigger, wroteNothing,
8} from './text'
9
10const agents = atom({ plugin: 'agent-flow', key: 'agents' } as const, {})
11// The agent line redraws on each record write; a finish or kill writes none ⇒ a poll of the running set redraws on change.
12const LIST_EVERY_MS = 5_000
13let listKey: string | undefined
14
15function total(u: ModelUsage): number {
16 return u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens + u.output_tokens
17}
18
19function textOf(content: unknown): string {
20 if (typeof content === 'string') return content
21 if (!Array.isArray(content)) return ''
22 return content.map(b => (b?.type === 'text' && typeof b.text === 'string' ? b.text : '')).join(' ')
23}
24
25async function patch($: EngineInterface, id: string, change: (a: AgentFlowAgent) => AgentFlowAgent, isActivity = true): Promise<AgentFlowAgent> {
26 const now = await $.clock.now()
27 const all = await update($, agents, m => {
28 const a: AgentFlowAgent = m[id] ?? { targets: [], tools: 0, durable: 0, inTurn: false, activeAt: now }
29 return { ...m, [id]: change(isActivity ? { ...a, activeAt: now } : a) }
30 })
31 return all[id]!
32}
33
34/** Teammate compaction trigger = the agent line's denominator. */
35async function compactAt($: EngineInterface): Promise<number> {
36 const window = Number(await $.env.get('CLAUDE_CODE_MAX_CONTEXT_TOKENS')) || 200_000
37 const acw = Number(await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW')) || 0
38 return trigger(window, acw)
39}
40
41async function pollList($: EngineInterface): Promise<void> {
42 const key = (await $.agent.list()).map(x => `${x.id}:${x.status}`).join(' ')
43 if (key !== listKey) {
44 listKey = key
45 $.ui.invalidate('ui.render')
46 }
47}
48
49/** CC refused the write by basename alone ⇒ perform it, answering as Write does. */
50async function writeReport($: EngineInterface, path: string, content: string) {
51 const original = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : null
52 await $.process.run(['mkdir', '-p', path.slice(0, path.lastIndexOf('/')) || '/'])
53 await $.fs.write(path, content)
54 const type = original === null ? ('create' as const) : ('update' as const)
55 return { result: { type, filePath: path, content, structuredPatch: [], originalFile: original } }
56}
57
58/** A mid-turn teammate's first TaskStop is held: a stop cuts its in-flight call and rows. */
59async function holdStop($: EngineInterface, asked: string): Promise<string | undefined> {
60 const now = await $.clock.now()
61 const [found] = Object.entries(await read($, agents))
62 .filter(([id, a]) => a.name === asked || id === asked)
63 .sort(([, x], [, y]) => y.activeAt - x.activeAt)
64 if (!found) return undefined
65 const [id, a] = found
66 const isMidTurn = a.inTurn && now - a.activeAt < QUIET_MS
67 const isReissue = a.heldAt !== undefined && now - a.heldAt < REISSUE_MS
68 const held = isMidTurn && !isReissue ? now : undefined
69 await patch($, id, x => ({ ...x, heldAt: held }), false)
70 return held === undefined ? undefined : stopHold(asked, a.marker, a.targets)
71}
72
73export const register: Register = on => {
74 on('session.start', ($, e, next) => {
75 $.clock.every(LIST_EVERY_MS, () => void pollList($))
76 return next(e)
77 })
78
79 // The plugin status row truncates to one line ⇒ this PromptHint site, in that row's warning colour; entries wrap whole.
80 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
81 const entries = agentEntries(await $.agent.list(), await read($, agents), await compactAt($))
82 if (entries.length === 0) return next(e)
83 const { Box, Text } = $.ui.resolve(e)
84 return (
85 <Box flexDirection="column">
86 {await next(e)}
87 <Box flexWrap="wrap" columnGap={1}>
88 {entries.map((x, i) => <Text color="warning">{i < entries.length - 1 ? `${x} ·` : x}</Text>)}
89 </Box>
90 </Box>
91 )
92 })
93
94 on('agent.spawn', async ($, e, next) => {
95 const r = await next({ ...e, prompt: `${e.prompt}\n\n${SPAWN_NOTE}` })
96 if (r.agentId !== undefined) await patch($, r.agentId, a => ({ ...a, name: e.name ?? a.name, inTurn: true }))
97 return r
98 })
99
100 // Agent({ name }) spawns a teammate past agent.spawn ⇒ its brief = its first user row, seen before any other event of its loop.
101 // A loop tracked before this hook loaded (hot reload) gains its name on its next row, never the note.
102 on('session.append', async ($, e, next) => {
103 if (e.agentId === undefined || e.message.type !== 'user') return next(e)
104 const id = e.agentId
105 const marker = lastMarker(textOf(e.message.content))
106 const known = (await read($, agents))[id]
107 const teammate = known?.listed ? undefined : (await $.agent.list()).find(x => x.id === id && x.type === 'teammate')
108 if (!known?.listed || marker !== undefined) {
109 await patch($, id, a => ({ ...a, listed: true, name: teammate?.name ?? a.name, marker: marker ?? a.marker }))
110 }
111 if (known !== undefined || teammate === undefined) return next(e)
112 return next({ ...e, message: { ...e.message, content: [...e.message.content, { type: 'text', text: SPAWN_NOTE }] } })
113 })
114
115 on('turn.step', async function* ($, e, next) {
116 if (e.agentId === undefined) return yield* next(e)
117 const id = e.agentId
118 await patch($, id, a => ({ ...a, inTurn: true, model: a.model ?? e.model }))
119 const r = yield* next(e)
120 if (r.usage) {
121 const used = total(r.usage)
122 const model = r.usage.model || e.model
123 await patch($, id, a => ({ ...a, used, model }))
124 }
125 return r
126 })
127
128 on('turn.complete', async ($, e, next) => {
129 if (e.agentId !== undefined) await patch($, e.agentId, a => ({ ...a, inTurn: false }))
130 return next(e)
131 })
132
133 on('tool.call', async ($, e, next) => {
134 if (e.agentId === undefined) {
135 if (e.tool !== 'TaskStop') return next(e)
136 const hold = await holdStop($, (e.task_id ?? e.shell_id ?? '').replace(/@.*/s, ''))
137 return hold === undefined ? next(e) : { deny: hold }
138 }
139 const input = e as { file_path?: unknown; notebook_path?: unknown; command?: unknown }
140 const target = e.tool === 'NotebookEdit' ? input.notebook_path : e.tool === 'Write' || e.tool === 'Edit' ? input.file_path : undefined
141 const isWrite = isDurable(e.tool, input) ? 1 : 0
142 await patch($, e.agentId, a => ({
143 ...a,
144 tools: a.tools + 1,
145 durable: a.durable + isWrite,
146 targets: typeof target === 'string' ? [target, ...a.targets.filter(t => t !== target)] : a.targets,
147 }))
148 const r = await next(e)
149 if (e.tool === 'Write' && r.deny === undefined && r.isError === true && r.text?.includes(REPORT_REJECTION)) {
150 return writeReport($, e.file_path, e.content)
151 }
152 return r
153 })
154
155 on('classic.SubagentStop', async ($, e, next) => {
156 const r = await next(e)
157 if (e.stop_hook_active) return r
158 const a = (await read($, agents))[e.agent_id]
159 if (a === undefined || a.durable > 0 || a.tools < WROTE_NOTHING_MIN) return r
160 return { ...r, additionalContext: [...(r.additionalContext ?? []), wroteNothing(a.tools)] }
161 })
162
163 on('session.compact', (_$, e, next) =>
164 next({ ...e, instructions: e.instructions ? `${e.instructions}\n\n${COMPACT_NOTE}` : COMPACT_NOTE }))
165}
166hooks/text.ts 69 lines1import type { AgentInfo } from 'claude-code'
2
3import type { AgentFlowAgent } from '../types'
4
5// Every subagent system prompt ends with the Notes line "Do NOT Write report/summary/
6// findings/analysis .md files …", whose own exception reads "Files written as input to
7// another tool are fine". The brief files the deliverable under that exception.
8export const SPAWN_NOTE =
9 'When your task names an output file, that file is the deliverable and the input to the validator the brief names, so the Notes clause "Files written as input to another tool are fine" covers it. Write it early, keep it current, then point your final message at it with the headline. Mark each claim you could not confirm as unconfirmed, naming where you looked. Work held only in this conversation ends with the run.'
10
11// Auto-compaction can drop a brief's paths and marker; only the summary survives it.
12export const COMPACT_NOTE =
13 'The summary must carry verbatim: every path this run must write or update, the completion marker or "Met when" condition it was given, its write allowlist and git posture, the ids of any rows still unfilled, and the exact next action.'
14
15/** CC's Write rejects a subagent's /^(REPORT|SUMMARY|FINDINGS|ANALYSIS).*\.md$/i file with this error. */
16export const REPORT_REJECTION = 'Subagents should return findings as text'
17
18export const wroteNothing = (n: number) =>
19 `No Write, Edit or commit call appears across your ${n} tool calls, so the findings of this run may exist only in this conversation, which ends with it. If your task named an output file, write it now. If the correct outcome is that nothing needed writing, state that in your final message.`
20
21export function stopHold(name: string, marker: string | undefined, targets: readonly string[]): string {
22 return `${name} is mid-turn. Wait for its turn to end with ${marker ?? 'its completion marker'}: a stop now cuts the in-flight call and any row it is still changing. To stop it anyway (budget, supersession, takeover, cancellation), call TaskStop again, then re-read every file it wrote.${targets.length ? ` Its Write/Edit targets: ${targets.join(', ')}.` : ''}`
23}
24
25/** A short lookup answers in its message; a run this long without a write likely lost its findings. */
26export const WROTE_NOTHING_MIN = 15
27/** A teammate quiet this long is likely idle → its stop passes unheld (a silent long tool call is the blind spot). */
28export const QUIET_MS = 15 * 60_000
29/** A TaskStop re-issued within this window = the deliberate stop. */
30export const REISSUE_MS = 10 * 60_000
31
32const MARKER = /[A-Za-z0-9][A-Za-z0-9_-]*-DONE-[0-9]+/g
33
34/** Last completion marker in a text; leads write the name verbatim or uppercased. */
35export function lastMarker(text: string): string | undefined {
36 return text.match(MARKER)?.at(-1)
37}
38
39const DURABLE_BASH = /git commit|git add|>>|\btee\b/
40
41/** Durable call = Write/Edit/NotebookEdit or Bash commit/add/>>/tee; a plain > redirect passes uncounted. */
42export function isDurable(tool: string, input: { command?: unknown }): boolean {
43 if (tool === 'Write' || tool === 'Edit' || tool === 'NotebookEdit') return true
44 return tool === 'Bash' && typeof input.command === 'string' && DURABLE_BASH.test(input.command)
45}
46
47/** Gauge denominator = CC's compaction trigger: 33K below the ACW-clamped window. */
48export function trigger(window: number, acw: number): number {
49 return (acw > 0 ? Math.min(window, acw) : window) - 33_000
50}
51
52/** Statusline `h`: ≥1M ⇒ one-decimal M, `.0` dropped; else rounded K. An exact x.x5M tie rounds up where awk's rounds to even. */
53export function human(n: number): string {
54 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
55 return `${Math.floor(n / 1000 + 0.5)}K`
56}
57
58/** One entry per engine-listed running agent (idle teammates included), in list order: name, model, context against the trigger. */
59export function agentEntries(list: readonly AgentInfo[], agents: Readonly<Record<string, AgentFlowAgent>>, at: number): string[] {
60 return list
61 .filter(x => x.status === 'running')
62 .map(x => {
63 const a = agents[x.id]
64 const used = a?.used
65 const gauge = used === undefined ? `? ?/${human(at)}` : `${Math.round((used * 100) / at)}% ${human(used)}/${human(at)}`
66 return [x.name ?? a?.name ?? x.description, a?.model, gauge].filter(Boolean).join(' ')
67 })
68}
69types/index.d.ts 26 lines1/** One subagent loop as agent-flow tracks it, keyed by agentId. */
2export type AgentFlowAgent = {
3 name?: string
4 /** `$.agent.list()` consulted for a teammate name ⇒ once per loop: every tool result is a user row too. */
5 listed?: true
6 /** Last `<name>-DONE-<i>` token sent to it (brief or message). */
7 marker?: string
8 /** Write/Edit/NotebookEdit targets, newest first. */
9 targets: string[]
10 tools: number
11 durable: number
12 inTurn: boolean
13 activeAt: number
14 heldAt?: number
15 /** Last request's input + cache + output. */
16 used?: number
17 /** Last request's model: the one the API reports answering, else the one the step named. */
18 model?: string
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'agent-flow': { agents: Record<string, AgentFlowAgent> }
24 }
25}
26