SLOPSHOPPER

agent-flow

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

newspinnerguardprocesstimeragents
v1.0.0CC0-1.0updated 2026-10-08eturkes/agents/claude/mods/agent-flow
A shopper browsing a rack in a slop shop
README

agents

Source 3 files
hooks/register.tsx 166 lines
1import { 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}
166
hooks/text.ts 69 lines
1import 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}
69
types/index.d.ts 26 lines
1/** 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