A side panel that keeps what a conversation leaves open: questions, your tasks, findings and unfinished asks

The inbox mod keeps a list of what a Claude Code conversation leaves open: questions that wait for your answer, tasks only you can do, findings, and asks that are not finished. A small handle at the right end of the status row shows how many items are open, in the color of the first section that waits on you. Click the handle to open the list in a side panel.
https://github.com/user-attachments/assets/dddadf4f-ad5e-4a3b-854c-acfc6834fd69
The idea comes from petekp/inbox.
The panel lists goals first, and then everything that belongs to no goal:
Each kind of item has its own mark and color, and every section folds when you click its name.
Click an item to open it in place. The item shows why it matters, where it came from, and what closing it takes, followed by what you can do with it:
Nothing is sent for you. A click puts text in your prompt box, and the item stays open until a reply settles it. If you delete the text from the prompt box, the item goes back to open.
Done and Drop close an item, and Later sets it aside until you ask Claude to commit, push, or wrap up. A closed item stays in the list for four more prompts, with the reason that it closed, and you can reopen it. A finished goal stays under Closed as one row that unfolds to its items.
Two kinds of item have a button that looks into them beside the conversation:
An agent of the mod does the looking. The agent reads files and runs commands, and it is told to change nothing. Its report goes to the item, with the time, and never to the conversation, so Claude takes no turn over it. While a look is running, Stop drops it, and Add to prompt stops it and hands the item to the conversation.
Close closes the item with what the check saw as its evidence. If you leave it, the item closes when the turn ends.Fix it puts the issue in your prompt box.Dismiss throws a result away, and Details lists the commands that the agent ran. After three minutes, you can ask again.
An open item stays open until it is closed or you set it aside. If you tell Claude to keep something for later, as in "put a pin in those", the inbox lists it under Later without waiting for a click. Items that you set aside together stay under the name of the work they belong to.
inbox, lets Claude close what you asked for as it finishes each thing, list the open items, and record a finding. The reply's read checks each close that Claude made and reopens one that the exchange does not bear out. Ask Claude to "resolve the findings in the inbox" to work through them.If you cancel a prompt before Claude writes anything, Claude Code puts the prompt back in the prompt box, and the inbox goes back to how it stood before that prompt. If you rewind the conversation, the inbox keeps everything, and an item from a prompt that you rewound past is marked ↶.
The mod uses the sonnet model alias of your Claude Code session for both passes, so each exchange adds two short model calls to your usage. To change how much of each turn those calls read, run /config and set Depth:
Each conversation has its own inbox. The mod writes every change, and every model call with its input and output, to a log for each conversation, with a small file of marks beside it:
~/.claude/inbox/SESSION_ID.jsonl
~/.claude/inbox/SESSION_ID.marks
When you resume a conversation, the inbox comes back from its log. When you fork a conversation with claude --resume SESSION_ID --fork-session, the fork starts with the inbox as it stood where you forked. The marks are how a fork finds the conversation that it came from.
Set "tui": "fullscreen" in your Claude Code settings. Claude Code reports clicks only in the fullscreen interface.
claude plugin marketplace add macwilk/cc-extensions
claude plugin install inbox@cc-extensions
In a Claude Code session that is already open, run /reload-plugins.
To receive new versions, run /plugin, go to the Marketplaces tab, select cc-extensions, and then select Enable auto-update. Claude Code leaves auto-update off for this marketplace.
To show or hide the panel without the mouse, run /inbox.
hooks/register.tsx 1676 lines1// The inbox: a side panel that keeps what a conversation leaves open, a small handle that opens it,
2// one tool for Claude, and the signals that open and close items without anyone saying so. Every
3// change is an event. Events are applied to the ledger held in the session's state and written to
4// the conversation's log, from which the inbox comes back when the conversation is resumed.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, ThemeKey } from 'claude-code'
7
8import type { Check, Event, Item, Kind, Ledger, Pending, Running, View } from '../types'
9import { SECTION_NAME, clip, draftOf, linesOf, nameOf, sayOf, sentOf, without, wrapped, type Decoration } from './capture'
10import { AGENT_SPEC, AGENT_TYPE, ASK_AGAIN_AFTER, CHECKS_AT_ONCE, HAND_BACK, RECEIVED, STOPPED, canCheck, checkAsk, checkBack, checkDismissed, checkLost, ranLine, sayOfCheck, stepsOf, withRan } from './checks'
11import { EMPTY, ageOf, apply, carriedTo, forkPoint, layoutOf, marksOf, needsYou, opened, progressOf, rebased, replay, rewoundTo, sectionsOf, type GoalBlock, type Section } from './ledger'
12import { checkOf, failedCheck, leftQuestions } from './signals'
13import { TOOL_DESCRIPTION, TOOL_NAME, TOOL_SCHEMA, answerTool } from './tool'
14import { AGENT_OPENS_PER_PROMPT, AGENT_RULES, BY_REPLY, PROMPT_RULES, REPLY_RULES, agentAsk, agentEvents, agentPart, closedByClaude, didLines, forkAsk, lastReplyOf, newest, promptAsk, promptEvents, readAgent, readAt, readFork, readPrompt, readReply, replyAsk, replyEvents, saidOf, todoOf, toolLine, toolLines, useOf, type Beside, type Message, type Subagent } from './workers'
15
16const PANE = 'inbox'
17/** Raised whenever the fields of View change, so a value an earlier build stored is told apart from this build's. */
18const SHAPE = 2
19const OFF: View = { shape: SHAPE, isPanelOpen: false, folded: ['later', 'closed'], open: [], detailed: [], added: {}, typed: {}, picks: {}, shownGoals: [], agents: [], tick: 0 }
20const viewAtom = atom({ plugin: 'inbox', key: 'view' } as const, OFF)
21const ledgerAtom = atom({ plugin: 'inbox', key: 'ledger' } as const, EMPTY)
22// The audits that the ends of turns asked for, each until it has finished. They are kept in the
23// session's state because a reload of the mod cancels the timers they wait on, and the audits with
24// them. A build before this one kept one audit there, and not a list.
25const pendingAtom = atom({ plugin: 'inbox', key: 'pending' } as const, [] as Pending[] | Pending | null)
26const listOf = (left: Pending[] | Pending | null) => (Array.isArray(left) ? left : left ? [left] : [])
27const runningAtom = atom({ plugin: 'inbox', key: 'running' } as const, null as Running | null)
28
29type Color = ThemeKey
30/** Each section its own accent, so its bar and count are told apart at a glance. */
31const ACCENT: Record<Section['key'] | 'later' | 'closed', Color> = { needs: 'warning', findings: 'suggestion', goals: 'success', running: 'planMode', later: 'merged', closed: 'inactive' }
32/** Each kind of item has one mark and one color wherever it is listed, so that no heading has to say what a row is. */
33const LOOK: Record<Kind, { mark: string; color: Color }> = {
34 stopped: { mark: '!', color: 'error' },
35 check: { mark: '!', color: 'error' },
36 question: { mark: '?', color: 'warning' },
37 task: { mark: '→', color: 'warning' },
38 issue: { mark: '!', color: 'error' },
39 opportunity: { mark: '+', color: 'suggestion' },
40 unverified: { mark: '~', color: 'suggestion' },
41 ask: { mark: '○', color: 'success' },
42 running: { mark: '◌', color: 'planMode' },
43}
44/** How a check of a finding came out, as a row and a card show it. */
45const VERDICT: Record<NonNullable<Check['verdict']>, { head: string; tag: string; color: Color; mark: string }> = {
46 passed: { head: '✓ Passed at', tag: 'passed', color: 'success', mark: '✓' },
47 failed: { head: '✗ Failed at', tag: 'failed', color: 'error', mark: '!' },
48 unclear: { head: '? Unclear at', tag: 'unclear', color: 'warning', mark: '~' },
49}
50/** The words of a look in the background: a check of a finding, and where a running task stands. */
51const CHECK_WORDS = {
52 verify: { ask: 'Verify', again: 'Verify again', back: '✓ Checked at' },
53 status: { ask: "What's going on?", again: 'Ask again', back: '✓ Where it stands as of' },
54}
55const wordsOf = (item: Item) => (item.kind === 'running' ? CHECK_WORDS.status : CHECK_WORDS.verify)
56/** The time of day as the person reads it off a clock. */
57const clockOf = (at: number) => {
58 const time = new Date(at)
59
60 return `${time.getHours()}:${String(time.getMinutes()).padStart(2, '0')}`
61}
62const SHORT: Record<Section['key'], string> = { needs: 'Needs', findings: 'Finds', goals: 'Goals', running: 'Runs' }
63/** What Claude can be asked to do with an item of each kind: a button, and the line it adds to the prompt. */
64const INTENTS: Record<Kind, { label: string; say: string }[]> = {
65 stopped: [],
66 check: [{ label: 'Fix it', say: 'Fix this failing check, run it again, and show me how it ended.' }],
67 question: [{ label: 'Explain it', say: 'Explain this question and what each answer leads to before I pick.' }],
68 running: [{ label: 'Stop it', say: 'Stop this now.' }],
69 task: [{ label: 'Walk me through it', say: 'Walk me through this step by step.' }],
70 issue: [
71 { label: 'Fix it now', say: 'Fix this now, then tell me what you changed.' },
72 { label: 'Explain it', say: 'Explain this in plain words before changing anything.' },
73 ],
74 opportunity: [
75 { label: 'Build it', say: 'Build this.' },
76 { label: 'Weigh it up', say: 'Tell me what this would cost and what it would buy before building anything.' },
77 ],
78 unverified: [],
79 ask: [{ label: 'Do it now', say: 'Do this now.' }],
80}
81const BY_YOU = { done: 'You marked it done in the panel.', dropped: 'You dropped it in the panel.' }
82const LATER_REASON = 'You set it aside. It comes back when you ask to commit, push or wrap up.'
83/** Told to the audit of a turn that ended without a reply, under whatever Claude had written by then. */
84const CUT_SHORT = '[The turn was stopped here, before Claude finished.]'
85/** Under this many columns the panel has no room for an item's title, and draws its narrow shape. */
86const MIN_COLUMNS = 34
87/** Past this many characters a part of the log is full, and the next events start a new part. The engine writes no file over 4 MiB. */
88const PART_SIZE = 2_000_000
89
90/**
91 * What a press handler has no other way to learn: the terminal as last drawn, whether any item may
92 * be waiting in the prompt box, so that typing with none waiting costs nothing, and how many items
93 * Claude has recorded, and the audits of its subagents have opened, since the person's last prompt.
94 */
95const seen = { terminal: 0, isFullscreen: true, bodyColumns: 0, placement: '', hasStaged: true, recorded: 0, agentOpened: 0, cwd: '' }
96/** The conversation's log as this session holds it: where its parts are, the part being written and its text, and the writes still to finish. */
97const log = { base: '', part: 0, text: '', marks: '', queue: Promise.resolve() }
98/** What a turn answers: a message of the person's or a notice of the system's, with the asks the message opened. */
99/**
100 * A prompt on its way to a reply. `before` is how things stood when the person sent it, kept so that
101 * a prompt they cancel and get back in the prompt box leaves nothing behind.
102 */
103type Sent = { text: string; isNotice: boolean; asked: string[]; answered: string[]; before?: { ledger: Ledger; turns: number; added: View['added'] }; isTakenBack?: boolean }
104/**
105 * The model passes, run one after another, since a reply's audit needs the asks its prompt's pass
106 * found. A person may type ahead while Claude works, so each reply is audited against what it was
107 * a reply to: `waiting` holds what was submitted and has not reached Claude yet, and `turn` what
108 * the running turn answers. `turnId` is the engine's id of that turn, which each close Claude makes
109 * in it keeps. `did` holds the running turn's tool calls, each kept as it is made: a
110 * compaction in the middle of a turn takes the earlier ones out of the conversation. `touched`
111 * holds, for each audit not yet finished, the items the person changed in the panel since its turn
112 * ended. `agents` holds the subagents Claude started, each until a read has been told how it ended:
113 * `told` is how many of its tool calls a read has been given, and `report` what it handed back.
114 */
115const passes = {
116 queue: Promise.resolve(),
117 waiting: [] as Sent[],
118 turn: null as Sent[] | null,
119 turnId: undefined as string | undefined,
120 did: [] as string[],
121 audits: new Set<string>(),
122 touched: new Map<string, string[]>(),
123 depth: readAt(undefined),
124 agents: new Map<string, Subagent & { told: number; report?: string; calls: string[] }>(),
125}
126const isClosed = (item: Item) => item.status === 'done' || item.status === 'dropped'
127
128/**
129 * Keeps the running turn in the session's state, which a reload of the mod leaves as it is, while
130 * the mod's own memory starts over. What a prompt took with it for a take-back is not kept.
131 */
132function keepTurn($: EngineInterface) {
133 const told = ({ text, isNotice, asked, answered }: Sent) => ({ text, isNotice, asked, answered })
134 const running: Running = { ...(passes.turnId === undefined ? {} : { id: passes.turnId }), heard: passes.turn?.map(told) ?? null, waiting: passes.waiting.map(told) }
135
136 return update($, runningAtom, () => running)
137}
138
139/** Takes the running turn up from the session's state, when this load of the mod knows of none. */
140async function takeUpTurn($: EngineInterface) {
141 const running = await read($, runningAtom)
142 if (!running || passes.turn !== null || passes.turnId !== undefined || passes.waiting.length > 0) return
143 const sent = (told: Pending['turn'][number]): Sent => ({ ...told, answered: told.answered ?? [] })
144 passes.turn = running.heard?.map(sent) ?? null
145 passes.turnId = running.id
146 passes.waiting = running.waiting.map(sent)
147}
148
149/**
150 * The stored view as this build understands it. A session keeps its state across a reload, so the
151 * value may come from an earlier build with other fields: that one starts over, keeping only
152 * whether the panel is open.
153 */
154function current(stored: View | undefined): View {
155 return stored?.shape === SHAPE ? { ...OFF, ...stored } : { ...OFF, isPanelOpen: stored?.isPanelOpen === true }
156}
157
158async function viewOf($: EngineInterface) {
159 return current(await read($, viewAtom))
160}
161
162function change($: EngineInterface, next: (was: View) => View) {
163 return update($, viewAtom, was => next(current(was)))
164}
165
166/**
167 * The stored ledger, or an empty one when what is stored is not this build's: a session keeps its
168 * state across a reload, and another build, or another mod of the same name, may have left its own.
169 */
170function sound(stored: Ledger | undefined): Ledger {
171 return stored && Array.isArray(stored.items) && Array.isArray(stored.goals) && Array.isArray(stored.dropped) && typeof stored.turn === 'number' ? stored : EMPTY
172}
173
174async function ledgerOf($: EngineInterface) {
175 return sound(await read($, ledgerAtom))
176}
177
178function partPath(part: number) {
179 return `${log.base}${part === 0 ? '' : `.${part}`}.jsonl`
180}
181
182/**
183 * Writes events to the conversation's log, after the ones before them. The engine writes whole
184 * files, so the text of the part being written is held here and written out again with each batch.
185 */
186function logged($: EngineInterface, events: Event[], turn: number) {
187 if (events.length === 0 || log.base === '') return
188 log.queue = log.queue
189 .then(async () => {
190 const at = await $.clock.now()
191 if (log.text.length > PART_SIZE) Object.assign(log, { part: log.part + 1, text: '' })
192 log.text += events.map(event => `${JSON.stringify({ at, turn, ...event })}\n`).join('')
193 await $.fs.write(partPath(log.part), log.text)
194 })
195 .catch((error: unknown) => {
196 $.ui.log(`inbox: the log was not written: ${String(error).slice(0, 120)}`)
197 })
198}
199
200/** Applies the events `make` gives for the ledger as it stands, and logs them. */
201async function record($: EngineInterface, make: (now: Ledger) => Event[]) {
202 let events: Event[] = []
203 await update($, ledgerAtom, was => {
204 events = make(sound(was))
205
206 return events.reduce(apply, sound(was))
207 })
208 logged($, events, (await ledgerOf($)).turn)
209
210 return events
211}
212
213/** Logs something that changes no item: a tool call, a model call, a signal. */
214async function note($: EngineInterface, what: string, detail: unknown) {
215 logged($, [{ type: 'note', what, detail }], (await ledgerOf($)).turn)
216}
217
218/** The events of a log's lines. A line cut short by a crash is skipped: the events around it still apply. */
219function eventsOf(text: string) {
220 return text
221 .split('\n')
222 .filter(Boolean)
223 .flatMap(line => {
224 try {
225 return [JSON.parse(line) as Event]
226 } catch {
227 return []
228 }
229 })
230}
231
232/**
233 * Leaves a mark for the turn that just ended, beside the log: the last tool call the conversation
234 * holds, and the count of prompts. A fork of this conversation starts under a new id with the same
235 * history, and Claude Code names no parent, so the fork finds its way back by a call both hold.
236 */
237async function mark($: EngineInterface) {
238 if (log.base === '') return
239 // A conversation that has called no tool yet is marked all the same, with a dash for the call: the counts are what a rewind is read from.
240 const last = (await $.session.messages()).flatMap(row => row.toolUses.map(use => use.tool_use_id)).at(-1) ?? '-'
241 log.marks += `${last} ${(await ledgerOf($)).turn} ${await $.session.turns()}\n`
242 await $.fs.write(`${log.base}.marks`, log.marks)
243}
244
245/**
246 * Notices a rewind: Claude Code then counts fewer turns than the newest marks were written at. The
247 * items opened after the point rewound to are kept and flagged, and the marks of the turns that are
248 * gone are taken out, so that neither a fork nor a later rewind is placed by them.
249 */
250async function followRewind($: EngineInterface) {
251 const back = rewoundTo(marksOf(log.marks), await $.session.turns())
252 if (back === null || log.base === '') return
253 log.marks = back.kept.map(one => `${one.call} ${one.turn} ${one.turns}\n`).join('')
254 await $.fs.write(`${log.base}.marks`, log.marks)
255 await record($, () => [{ type: 'rewind', turn: back.turn }])
256}
257
258/**
259 * For a conversation that has a history and no log, which a fork is: the events of the conversation
260 * it was forked from, up to where the fork was made. That is the newest mark of any log whose tool
261 * call this conversation also holds.
262 */
263async function forkedFrom($: EngineInterface, folder: string) {
264 const calls = (await $.session.messages()).flatMap(row => row.toolUses.map(use => use.tool_use_id))
265 if (calls.length === 0 || !(await $.fs.exists(folder))) return null
266 const turns = await $.session.turns()
267 let found: { name: string; marks: string; turn: number; at: number; isExact: boolean } | null = null
268 for (const entry of await $.fs.list(folder)) {
269 if (entry.kind !== 'file' || !entry.name.endsWith('.marks')) continue
270 const marks = await $.fs.read(`${folder}/${entry.name}`)
271 const point = forkPoint(marksOf(marks), calls, turns)
272 // A log that marks this very count of turns beats one that only shares a tool call. Between two alike, the one sharing the later call.
273 const isBetter = point !== null && (found === null || (point.isExact && !found.isExact) || (point.isExact === found.isExact && point.at > found.at))
274 if (point !== null && isBetter) found = { name: entry.name.slice(0, -'.marks'.length), marks, turn: point.turn, at: point.at, isExact: point.isExact }
275 }
276 if (found === null) return null
277 const { name, turn } = found
278 const events: Event[] = []
279 for (let part = 0; await $.fs.exists(`${folder}/${name}${part === 0 ? '' : `.${part}`}.jsonl`); part += 1) {
280 events.push(...eventsOf(await $.fs.read(`${folder}/${name}${part === 0 ? '' : `.${part}`}.jsonl`)))
281 }
282 const marks = marksOf(found.marks)
283 .filter(one => one.turn <= turn)
284 .map(one => `${one.call} ${one.turn} ${one.turns}\n`)
285 .join('')
286
287 return { from: name, turn, events: carriedTo(events, turn), marks }
288}
289
290/**
291 * Brings the inbox back from the conversation's log. A session that starts with nothing in its
292 * state and events in its log is a conversation started again, which closes what waited on a
293 * restart. A fork of a conversation has no log yet, and starts from the inbox it was forked with.
294 */
295async function restore($: EngineInterface) {
296 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '.'
297 const folder = `${home}/.claude/inbox`
298 log.base = `${folder}/${await $.session.id()}`
299 const events: Event[] = []
300 let part = 0
301 while (await $.fs.exists(partPath(part))) {
302 log.text = await $.fs.read(partPath(part))
303 events.push(...eventsOf(log.text))
304 part += 1
305 }
306 log.part = Math.max(0, part - 1)
307 log.marks = (await $.fs.exists(`${log.base}.marks`)) ? await $.fs.read(`${log.base}.marks`) : ''
308 const fork = part === 0 ? await forkedFrom($, folder) : null
309 if (fork) {
310 events.push(...fork.events)
311 Object.assign(log, { marks: fork.marks, text: fork.events.map(event => `${JSON.stringify(event)}\n`).join('') })
312 await $.fs.write(`${log.base}.marks`, log.marks)
313 await $.fs.write(partPath(0), log.text)
314 void note($, 'carried', { from: fork.from, turn: fork.turn })
315 }
316 const held = await ledgerOf($)
317 await update($, ledgerAtom, () => replay(events))
318 // State that outlived the start means the plugins were reloaded in a running session. A restart reloads them too.
319 const isRestart = held.turn === 0 && held.items.length === 0
320 // Started again, the conversation is counted afresh, and lower than before where it was compacted:
321 // the marks are moved to that count, or the next prompt would read the drop as a rewind.
322 const marks = marksOf(log.marks)
323 const moved = isRestart ? rebased(marks, (await ledgerOf($)).turn, await $.session.turns()) : marks
324 if (moved !== marks) {
325 log.marks = moved.map(one => `${one.call} ${one.turn} ${one.turns}\n`).join('')
326 await $.fs.write(`${log.base}.marks`, log.marks)
327 }
328 await record($, now =>
329 now.items
330 .filter(item => item.status === 'open' && (item.closesOn === 'reload' || (isRestart && item.closesOn === 'restart')))
331 .map(item => ({ type: 'close', id: item.id, how: 'done', by: 'engine', reason: isRestart ? 'Claude Code was started again.' : 'The plugins were reloaded.' })),
332 )
333}
334
335/**
336 * Keeps the terminal's width as a drawing hook reports it. Beside a docked panel a hook is told the
337 * transcript's width, so the panel's own is added back.
338 */
339function noteViewport(viewport: { columns: number; isFullscreen?: boolean } | undefined, now: View) {
340 if (!viewport) return
341 seen.terminal = viewport.columns + (now.isPanelOpen && seen.placement === 'dock' ? seen.bodyColumns + 2 : 0)
342 seen.isFullscreen = viewport.isFullscreen ?? seen.isFullscreen
343}
344
345/** True where the engine docks a panel at the right of the transcript, false where it lands above the prompt. */
346function docks() {
347 return seen.isFullscreen && seen.terminal >= 110
348}
349
350async function openPanel($: EngineInterface) {
351 await change($, now => ({ ...now, isPanelOpen: true }))
352 await $.ui.open(docks() ? { id: PANE, title: 'Inbox' } : { id: PANE, title: 'Inbox', rows: 18 })
353}
354
355function togglePanel($: EngineInterface) {
356 return async () => {
357 // Opening the panel is when a rewind made since the last prompt is first looked at.
358 if (!(await viewOf($)).isPanelOpen) return followRewind($).then(() => openPanel($))
359 await $.ui.close({ id: PANE })
360 // The close hook below hears a person's close, not this one of the mod's own.
361 await change($, now => ({ ...now, isPanelOpen: false }))
362 }
363}
364
365/** The handle: an arrow toward where the panel lands and the number of open items, on the color of what waits on the person. */
366function handleOf($: EngineInterface, ui: ReturnType<EngineInterface['ui']['resolve']>, now: View, book: Ledger, arrow: string) {
367 const { Button, Text } = ui
368 const sections = sectionsOf(book)
369 // The same rows the panel counts under its title: open ones, less those of a goal whose asks are all done.
370 const openIn = (section: Section) => section.groups.filter(group => group.goal === undefined || !progressOf(book, group.goal).isComplete).flatMap(group => group.rows.filter(row => row.status === 'open'))
371 const total = sections.reduce((sum, section) => sum + openIn(section).length, 0)
372 // The handle wears the color of the first section, in the panel's order, that still waits on
373 // something. An item whose capture was sent waits on Claude's reply and no longer on the person,
374 // so it stops coloring the handle while it still counts as open.
375 const waiting = sections.find(section => openIn(section).some(row => row.sent === undefined))
376 const color = waiting ? ACCENT[waiting.key] : undefined
377
378 return (
379 <Button key="handle" label={now.isPanelOpen ? 'Hide the inbox' : 'Show the inbox'} plain onPress={togglePanel($)}>
380 <Text backgroundColor={color ?? 'selectionBg'} {...(color ? { color: 'inverseText' } : {})} bold>
381 {' '}
382 {arrow} {total}{' '}
383 </Text>
384 </Button>
385 )
386}
387
388/**
389 * Opens an item's card in place, or shuts it. Other cards stay as they are, so nothing above the
390 * clicked row changes height and the row stays under the pointer. The mod scrolls the panel in one
391 * case only: part of the opened card is off screen, and then by the least that shows it. When the
392 * list gets shorter than where the panel is scrolled to, the engine brings it back to the list's end.
393 */
394function toggleOpen($: EngineInterface, id: string) {
395 return async () => {
396 const isOpening = !(await viewOf($)).open.includes(id)
397 await change($, now => ({ ...now, open: isOpening ? [...now.open, id] : now.open.filter(one => one !== id) }))
398 // The card is drawn after this handler returns, so it is asked for a moment later.
399 if (isOpening) {
400 $.clock.after(150, () => $.ui.scroll({ in: PANE, to: { key: `item-${id}` }, block: 'nearest' }))
401 }
402 }
403}
404
405function toggleGoal($: EngineInterface, key: string) {
406 return () => change($, now => ({ ...now, shownGoals: now.shownGoals.includes(key) ? now.shownGoals.filter(one => one !== key) : [...now.shownGoals, key] }))
407}
408
409function toggleFold($: EngineInterface, key: string) {
410 return () => change($, now => ({ ...now, folded: now.folded.includes(key) ? now.folded.filter(one => one !== key) : [...now.folded, key] }))
411}
412
413function toggleDetails($: EngineInterface, id: string) {
414 return () => change($, now => ({ ...now, detailed: now.detailed.includes(id) ? now.detailed.filter(one => one !== id) : [...now.detailed, id] }))
415}
416
417/** Draws the panel again after a while, when all that will have changed is the time: a result is then old enough to ask about again. */
418function redrawLater($: EngineInterface, after: number) {
419 $.clock.after(after, () => {
420 void change($, now => ({ ...now, tick: now.tick + 1 }))
421 })
422}
423
424/**
425 * Ends a look at an item with what its agent said: what it handed back, or the answer its turn
426 * ended on. An agent that ended with nothing to say leaves `lost`, the reason, in its place. A look
427 * that was stopped, or that has ended already, is left as it is.
428 */
429async function endCheck($: EngineInterface, id: string, agent: string, message: string, lost = 'The check ended without a report.') {
430 const at = await $.clock.now()
431 const events = await record($, now => {
432 const item = now.items.find(one => one.id === id && one.check?.state === 'running' && one.check.agent === agent)
433
434 return item ? (message.trim() === '' ? checkLost(item, at, lost) : checkBack(item, message, at)) : []
435 })
436 await ours($, agent)
437 if (events.length > 0) redrawLater($, ASK_AGAIN_AFTER + 1000)
438}
439
440/** Remembers an agent as the mod's, so that what it still calls or hands back after its look is over is never taken for the conversation's. */
441function ours($: EngineInterface, agent: string) {
442 return change($, now => (now.agents.includes(agent) ? now : { ...now, agents: [...now.agents, agent].slice(-40) }))
443}
444
445/**
446 * Starts a look at an item in the background: an agent of the mod's own type, which reads and runs
447 * and changes nothing. What it hands back is taken by a hook of the mod's, so none of it enters the
448 * conversation and Claude takes no turn over it. It is called from the hook on a press and never
449 * from a button's own handler, for the reason given at that hook.
450 */
451async function startCheck($: EngineInterface, id: string) {
452 const book = await ledgerOf($)
453 const item = book.items.find(one => one.id === id)
454 if (!item || !canCheck(item) || item.check?.state === 'running') return
455 if (book.items.filter(one => one.check?.state === 'running').length >= CHECKS_AT_ONCE) {
456 $.ui.toast(`${CHECKS_AT_ONCE} checks are running. Try again when one is back.`)
457
458 return
459 }
460 try {
461 await $.agent.register(AGENT_SPEC)
462 const started = await $.agent.spawn({ prompt: checkAsk(item), description: clip(`Inbox: ${item.title}`, 60), subagentType: AGENT_TYPE })
463 const agent = started.deny === undefined ? started.agentId : undefined
464 if (agent === undefined) throw new Error(started.deny ?? `no agent started: ${JSON.stringify(started)}`)
465 const at = await $.clock.now()
466 await ours($, agent)
467 // The item stays as it was, open or shut: a look asked for from a row goes on behind the row,
468 // whose tag says it is running. Opening its card there would push the rest of the list away.
469 await change($, now => ({ ...now, detailed: now.detailed.filter(one => one !== id) }))
470 await record($, () => [{ type: 'patch', id, patch: { check: { state: 'running', at, agent, ...(item.check?.was ? { was: item.check.was } : {}) } } }])
471 void note($, 'check', { id, agent, model: started.model })
472 } catch (error) {
473 $.ui.toast('The check could not start.')
474 $.ui.log(`inbox: a check did not start: ${String(error).slice(0, 160)}`)
475 }
476}
477
478/** The handler of a button whose press a hook takes: the work is the hook's. */
479const ignore = () => undefined
480
481/**
482 * Stops a look: the item is as it was before, and whatever the agent still does or says is
483 * dropped. `thenAdd` hands the item to the conversation in its place, through the prompt box.
484 */
485function stopCheck($: EngineInterface, id: string, thenAdd = false) {
486 return async () => {
487 const agent = (await ledgerOf($)).items.find(one => one.id === id)?.check?.agent
488 if (agent !== undefined) await ours($, agent)
489 await record($, now => now.items.filter(one => one.id === id && one.check?.state === 'running').flatMap(checkDismissed))
490 if (thenAdd) await addAll($, [{ id }])
491 }
492}
493
494/** Throws a result away: the item is what it was before the look. */
495function dismissCheck($: EngineInterface, id: string) {
496 return async () => {
497 await record($, now => now.items.filter(one => one.id === id && one.check?.state === 'back').flatMap(checkDismissed))
498 await change($, now => ({ ...now, detailed: now.detailed.filter(one => one !== id) }))
499 }
500}
501
502/** Closes an item on a check that passed, with what the check saw as the evidence. */
503function closeChecked($: EngineInterface, id: string) {
504 return async () => {
505 await record($, now => passedOf(now, 'you').filter(event => event.id === id))
506 await change($, was => ({ ...was, open: was.open.filter(one => one !== id) }))
507 }
508}
509
510/** A close for every open item whose check passed: the person's click on one, or the end of a turn for all that are left. */
511function passedOf(ledger: Ledger, by: 'you' | 'engine') {
512 return ledger.items
513 .filter(item => item.status === 'open' && item.check?.state === 'back' && item.check.verdict === 'passed')
514 .map(item => ({ type: 'close' as const, id: item.id, how: 'done' as const, by, reason: `A check in the background passed: ${item.check?.text ?? ''}` }))
515}
516
517/**
518 * Adds items to the prompt box together, each with what is said about it: the answer picked for
519 * it, or what Claude is asked to do with it. An item already in the box takes the place of its
520 * earlier capture, so a second answer replaces the first.
521 */
522async function addAll($: EngineInterface, entries: { id: string; say?: string }[]) {
523 const [now, book] = await Promise.all([viewOf($), ledgerOf($)])
524 const room = Math.max(40, Math.min(96, seen.terminal - 10))
525 let draft: { text: string; decorations: Decoration[] } = { text: (await $.prompt.read()).text, decorations: [] }
526 const marks: Record<string, string> = {}
527 for (const { id, say } of entries) {
528 const item = book.items.find(one => one.id === id)
529 if (!item) continue
530 const { mark, ...next } = draftOf(without(draft.text, now.added[id]), item, room, say)
531 draft = next
532 marks[id] = mark
533 }
534 if (Object.keys(marks).length === 0) return
535 await $.prompt.fill({ ...draft, mode: 'replace' })
536 seen.hasStaged = true
537 await change($, was => ({ ...was, added: { ...was.added, ...marks }, open: was.open.filter(one => !(one in marks)) }))
538 if (seen.placement !== 'dock') await $.ui.close({ id: PANE })
539}
540
541function addToPrompt($: EngineInterface, id: string, say?: string) {
542 return () => addAll($, [{ id, say }])
543}
544
545/**
546 * Follows the prompt box: an item whose capture was deleted from it is open again, as if it had
547 * never been added.
548 */
549async function followPrompt($: EngineInterface, text: string) {
550 const waiting = Object.entries((await viewOf($)).added)
551 const gone = waiting.filter(([, mark]) => !text.includes(mark)).map(([id]) => id)
552 seen.hasStaged = waiting.length > gone.length
553 if (gone.length > 0) await change($, was => ({ ...was, added: Object.fromEntries(Object.entries(was.added).filter(([id]) => !gone.includes(id))) }))
554}
555
556/** Keeps what the person types into an item's text box, so its Send button knows when there is something to send. */
557function setTyped($: EngineInterface, id: string) {
558 return (value: string) => {
559 void change($, now => ({ ...now, typed: { ...now.typed, [id]: value } }))
560 }
561}
562
563/** Ticks or unticks one answer of a question that takes several. */
564function togglePick($: EngineInterface, id: string, at: number) {
565 return () =>
566 change($, now => {
567 const picks = now.picks[id] ?? []
568
569 return { ...now, picks: { ...now.picks, [id]: picks.includes(at) ? picks.filter(one => one !== at) : [...picks, at].sort() } }
570 })
571}
572
573/** Adds an item to the prompt box with what its card holds to send. Nothing ticked or typed, nothing added. */
574function sendSaid($: EngineInterface, item: Item, lead: string) {
575 return async () => {
576 const now = await viewOf($)
577 const say = sayOf(item, now.picks[item.id] ?? [], now.typed[item.id] ?? '', lead)
578 if (say !== '') await addAll($, [{ id: item.id, say }])
579 }
580}
581
582/** Adds every one of these open questions to the prompt box, each with its recommended answer. */
583function takeRecommended($: EngineInterface, questions: Item[]) {
584 return async () => {
585 const now = await viewOf($)
586 const rows = questions.filter(row => row.status === 'open' && !now.added[row.id] && row.options)
587 await addAll(
588 $,
589 rows.map(row => ({ id: row.id, say: `Answer: ${(row.recommended ?? [0]).map(at => row.options?.[at]).join(', ')}` })),
590 )
591 }
592}
593
594/** Adds every open ask of a goal to the prompt box, and asks Claude to work through them. */
595function doTheRest($: EngineInterface, block: GoalBlock) {
596 return () => {
597 const rows = block.open.filter(row => row.kind === 'ask')
598
599 return addAll(
600 $,
601 rows.map((row, at) => ({ id: row.id, say: at === rows.length - 1 ? `Do the rest of this goal: ${block.goal.title}.` : undefined })),
602 )
603 }
604}
605
606/**
607 * Closes an item as done or dropped, sets it aside for later, or brings it back, as the person's
608 * own doing. A closed item keeps its row under Closed for a few prompts, and one set aside keeps
609 * it under Later.
610 */
611function setStatus($: EngineInterface, id: string, how: 'done' | 'dropped' | 'later' | 'open') {
612 return async () => {
613 await record($, () => [how === 'open' ? { type: 'reopen', id } : how === 'later' ? { type: 'later', id, reason: LATER_REASON } : { type: 'close', id, how, by: 'you', reason: BY_YOU[how] }])
614 for (const job of passes.audits) passes.touched.set(job, [...(passes.touched.get(job) ?? []), id])
615 await change($, was => ({
616 ...was,
617 added: Object.fromEntries(Object.entries(was.added).filter(([one]) => one !== id)),
618 open: was.open.filter(one => one !== id),
619 }))
620 }
621}
622
623function copyText($: EngineInterface, text: string, what: string) {
624 return async () => {
625 const { isCopied } = await $.ui.copy({ text })
626 $.ui.toast(isCopied ? `${what} copied` : 'Could not copy')
627 }
628}
629
630/** A check that ran: a failure opens an item unless one is open for the same command, and a pass closes the ones that are. */
631async function followCheck($: EngineInterface, command: string, hasFailed: boolean, output: string) {
632 const at = await $.clock.now()
633 await record($, now => {
634 const open = now.items.filter(item => item.kind === 'check' && item.key === command && item.status === 'open')
635 if (hasFailed) return open.length > 0 ? [] : [opened(now, at, 'engine', failedCheck(command, output))]
636
637 return open.map(item => ({ type: 'close', id: item.id, how: 'done', by: 'engine', reason: 'The same check ran again and passed.' }))
638 })
639}
640
641/**
642 * What Claude left running in the background becomes an item: a command, a subagent or a workflow.
643 * `task` is what its end is known by, which the notice of that end names.
644 */
645async function followTask($: EngineInterface, task: string, title: string, why: string) {
646 const at = await $.clock.now()
647 await record($, now => [
648 opened(now, at, 'engine', {
649 kind: 'running',
650 title: clip(title, 80),
651 from: 'Claude started it in the background.',
652 why,
653 close: 'It ends by itself, or you ask Claude to stop it.',
654 key: task,
655 selfCloses: 'When the task ends.',
656 }),
657 ])
658}
659
660/**
661 * What Claude's subagents did since a read was last told: for each, its tool calls since then and,
662 * once it has ended, its report. A report that came back as the notice this turn answers is in the
663 * read already and is not told twice. A subagent that has ended is told of once and then forgotten.
664 */
665async function subagentsDid($: EngineInterface, turn: Sent[]) {
666 const notice = turn.map(sent => sent.text).join('\n')
667 const parts: string[] = []
668 for (const [id, agent] of passes.agents) {
669 const { lines, said } = await workOf($, id, agent.calls, agent.report)
670 if (lines.length > agent.told || agent.isDone) parts.push(agentPart(agent, lines.slice(agent.told), agent.isDone && !notice.includes(id) ? (agent.report ?? '') : '', said))
671 agent.told = lines.length
672 if (agent.isDone) passes.agents.delete(id)
673 }
674
675 return parts
676}
677
678/**
679 * A subagent's work: its tool calls as lines, and what it wrote before its report. The calls are
680 * in two places, as the main conversation's are: the subagent's own conversation and `calls`, the
681 * list this mod kept as they were made. The engine hands over no conversation for a workflow's
682 * agent, so the longer of the two is the whole, and what such an agent wrote along the way is not known.
683 */
684async function workOf($: EngineInterface, id: string, calls: string[], report = '') {
685 const rows = await $.session.messages({ agentId: id }).catch(() => [])
686 const messages = Array.isArray(rows) ? (rows as Message[]) : []
687 // A call still under way has no outcome yet: it and the calls after it are told once it has one.
688 const uses = messages.flatMap(message => message.toolUses ?? [])
689 const under = uses.findIndex(use => use.text === undefined && use.result === undefined)
690 const held = (under < 0 ? uses : uses.slice(0, under)).flatMap(use => toolLine(use, seen.cwd) ?? [])
691 const said = messages.flatMap(message => (message.role === 'assistant' && (message.text ?? '').trim() !== '' ? [(message.text ?? '').trim()] : []))
692
693 return { lines: held.length >= calls.length ? held : calls, said: said.at(-1) === report.trim() ? said.slice(0, -1) : said }
694}
695
696/**
697 * Audits one subagent that has ended: its report against its own tool calls. It opens only what is
698 * wrong or was never run, never an ask, and all such audits together open no more than a few items
699 * between two prompts of the person's, however many subagents a workflow starts.
700 */
701async function agentPass($: EngineInterface, id: string, agent: Subagent, calls: string[], report: string) {
702 const { lines, said } = await workOf($, id, calls, report)
703 if (lines.length === 0 && report.trim() === '') return
704 const reply = await ask($, 'agent audit', { model: passes.depth.model, system: AGENT_RULES, prompt: agentAsk(await ledgerOf($), agent, lines, said, report), maxTokens: 1500, effort: 'medium' })
705 const found = reply === null ? [] : readAgent(reply)
706 if (found.length === 0) return
707 const at = await $.clock.now()
708 const events = await record($, now => agentEvents(now, found, at, AGENT_OPENS_PER_PROMPT - seen.agentOpened))
709 seen.agentOpened += events.length
710}
711
712/** A question dialog that ended: the questions it was left with become items, each with its own answers. */
713async function followDialog($: EngineInterface, asked: Parameters<typeof leftQuestions>[0], reply: string, isRefused: boolean, dialog: string) {
714 const at = await $.clock.now()
715 const time = new Date(at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
716 const left = leftQuestions(asked, reply, isRefused, dialog, time)
717 await record($, now => {
718 const events: Event[] = []
719 left.reduce((book, fields) => {
720 const event = opened(book, at, 'engine', fields)
721 events.push(event)
722
723 return apply(book, event)
724 }, now)
725
726 return events
727 })
728}
729
730/** The end of a turn of the main conversation: one that died on an error opens an item, and the next that ends with a reply closes it. */
731async function followTurn($: EngineInterface, reason: string) {
732 const at = await $.clock.now()
733 await record($, now => {
734 const open = now.items.filter(item => item.kind === 'stopped' && item.status === 'open')
735 if (reason === 'answer') return open.map(item => ({ type: 'close', id: item.id, how: 'done', by: 'engine', reason: 'The next turn ended with a reply.' }))
736 if (reason !== 'error' || open.length > 0) return []
737
738 return [
739 opened(now, at, 'engine', {
740 kind: 'stopped',
741 title: 'The last turn stopped on an error',
742 from: 'Claude Code itself: the turn ended on an API error after its retries.',
743 why: 'The work halted outside the conversation. Nothing more happens until a prompt goes through.',
744 close: 'Send any message to pick the work back up. If it stops again, check your sign-in and usage with /status.',
745 selfCloses: 'When the next turn ends with a reply.',
746 }),
747 ]
748 })
749}
750
751/**
752 * Starts a model pass after the ones before it, on a timer of its own: a call made inside a hook
753 * is cut when that hook's work is over, and a pass outlives the hook that asked for it.
754 */
755function queued($: EngineInterface, pass: () => Promise<void>) {
756 $.clock.after(1, () => {
757 passes.queue = passes.queue.then(pass).catch((error: unknown) => {
758 $.ui.log(`inbox: a pass did not finish: ${String(error).slice(0, 120)}`)
759 })
760 })
761}
762
763/** One call of a model, logged whole: what it was asked, what it said, what it cost and how long it took. */
764async function ask($: EngineInterface, pass: string, request: { model: string; system: string; prompt: string; maxTokens: number; effort: 'low' | 'medium' }) {
765 const started = await $.clock.now()
766 const reply = await $.model.complete({ ...request, timeoutMs: 90_000 })
767 void note($, pass, {
768 model: request.model,
769 prompt: request.prompt,
770 reply: reply.isAnswered ? reply.text : null,
771 ...(reply.isAnswered ? {} : { failed: reply.reason }),
772 usage: reply.usage,
773 ms: (await $.clock.now()) - started,
774 })
775
776 return reply.isAnswered ? reply.text : null
777}
778
779/** Reads the person's message for what it asks and what it answers. It runs while Claude works on the same message. */
780async function promptPass($: EngineInterface, message: string, sent: Sent, lastReply: string) {
781 if (message.trim() === '') return
782 const reply = await ask($, 'prompt pass', { model: passes.depth.model, system: PROMPT_RULES, prompt: promptAsk(await ledgerOf($), message, lastReply), maxTokens: 3000, effort: 'low' })
783 const said = reply === null ? null : readPrompt(await ledgerOf($), reply)
784 // The read of a prompt can come back after the person has taken the prompt back.
785 if (!said || sent.isTakenBack) return
786 const at = await $.clock.now()
787 await record($, now => {
788 const made = promptEvents(now, said, at)
789 sent.asked = made.asked
790
791 return made.events
792 })
793 // Added to the list the turn's audit job already holds, so that a read that ends after the turn does still counts.
794 sent.answered.push(...said.answers.map(answer => answer.id))
795 await keepTurn($)
796}
797
798/**
799 * Audits the exchange that just ended: what got done, what the reply skipped, and what it leaves
800 * open. An item the audit cannot judge from one exchange is put to a fork of the main conversation,
801 * which reads all of it from the prompt cache while that is still warm.
802 */
803async function replyPass($: EngineInterface, answer: string, turn: Pending['turn'], did: string[], beside: Beside) {
804 const book = await ledgerOf($)
805 if (answer.trim() === '') return
806 const asked = turn.flatMap(sent => sent.asked)
807 const message = turn.map(sent => sent.text)
808 const isNotice = turn.length > 0 && turn.every(sent => sent.isNotice)
809 const request = { model: passes.depth.model, system: REPLY_RULES, prompt: replyAsk(book, asked, message, isNotice, did, answer, beside), maxTokens: 3000, effort: 'medium' as const }
810 // Asked once more when the call fails: an audit that never answers leaves what Claude closed in the turn unread for good.
811 const reply = (await ask($, 'reply audit', request)) ?? (await ask($, 'reply audit', request))
812 const said = reply === null ? null : readReply(book, reply, beside.turnId)
813 if (!said) return
814 const at = await $.clock.now()
815 // Read when the audit is back: the read of a message that reached the turn late may have ended meanwhile.
816 const heard = turn.every(sent => sent.answered !== undefined) ? turn.flatMap(sent => sent.answered ?? []) : undefined
817 await record($, now => replyEvents(now, said, at, { book, heard, isCut: beside.isCut, touched: passes.touched.get(beside.turnId ?? '') }))
818 // A turn that was cut short is read for what Claude closed in it, and for nothing else.
819 if (beside.isCut) return
820 for (const doubt of said.unsure) {
821 const item = book.items.find(one => one.id === doubt.id)
822 if (!item) continue
823 const forked = await $.model.fork({ prompt: forkAsk(item, doubt.question) })
824 const found = forked.isAnswered ? readFork(forked.text) : null
825 void note($, 'fork', { id: item.id, question: doubt.question, reply: forked.isAnswered ? forked.text : null, ...(forked.isAnswered ? {} : { failed: forked.reason }), usage: 'usage' in forked ? forked.usage : null })
826 if (found?.isDone) await record($, now => (now.items.some(one => one.id === item.id && one.status === 'open') ? [{ type: 'close', id: item.id, how: 'done', by: 'audit', reason: `${BY_REPLY}${found.reason}` }] : []))
827 }
828}
829
830/** Starts the audit of a reply. It stays in the session's state until it has run, whether or not the model answered. */
831function audit($: EngineInterface, job: Pending) {
832 passes.audits.add(job.id)
833 queued($, async () => {
834 try {
835 // An audit that a build before this one left in the state has no list of its own.
836 await replyPass($, job.answer, job.turn, job.did ?? didLines((await $.session.messages()) as Message[], undefined, seen.cwd), { said: job.said, agents: job.agents, todo: job.todo, turnId: job.id, isCut: job.isCut })
837 } finally {
838 passes.audits.delete(job.id)
839 passes.touched.delete(job.id)
840 await update($, pendingAtom, left => listOf(left).filter(one => one.id !== job.id))
841 }
842 })
843}
844
845export const register: Register = (on, options) => {
846 passes.depth = readAt(options.depth)
847
848 on('session.start', async ($, e, next) => {
849 seen.cwd = e.cwd
850 await restore($)
851 await takeUpTurn($)
852 // Each audit still in the state was cut off by a reload of the mod, unless this load of it is the one running it.
853 for (const left of listOf(await read($, pendingAtom))) if (!passes.audits.has(left.id)) audit($, left)
854 await $.command.register({ name: 'inbox', description: 'Show or hide the inbox panel' })
855 await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA, isDeferred: false })
856 // A new session's prompt box is empty, whatever the last one held.
857 await change($, now => ({ ...now, added: {} }))
858 // A look whose agent is gone can never come back: Claude Code was closed while it ran. A reload
859 // of the mod alone leaves the agent running, and its look with it.
860 // Where the agents cannot be listed, every look is left running: ending a live one is worse than leaving a dead one.
861 const listed = await $.agent.list().catch(() => null)
862 const alive = new Set((listed ?? []).map(agent => agent.id))
863 for (const item of listed ? (await ledgerOf($)).items : []) {
864 const agent = item.check?.state === 'running' ? item.check.agent : undefined
865 if (agent !== undefined && !alive.has(agent)) await endCheck($, item.id, agent, '', 'Claude Code was closed while the check was running.')
866 }
867
868 return next(e)
869 })
870
871 on('command.run', { command: 'inbox' }, async ($, e) => {
872 seen.terminal = e.presentation.columns
873 seen.isFullscreen = e.presentation.isFullscreen
874 const wasOpen = (await viewOf($)).isPanelOpen
875 await togglePanel($)()
876
877 return { text: wasOpen ? 'Inbox hidden.' : 'Inbox opened.' }
878 })
879
880 // Sending a prompt closes nothing. An item whose capture went out with it is marked as sent, with
881 // what was said under it, and stays open until the reply is read.
882 on('prompt.submit', async ($, e, next) => {
883 // A background task's notice of its end names the task, which closes the item that followed it.
884 if (e.origin.kind === 'task-notification') {
885 await record($, now => now.items.filter(item => item.kind === 'running' && item.status === 'open' && item.key !== undefined && e.text.includes(item.key)).map(item => ({ type: 'close', id: item.id, how: 'done', by: 'engine', reason: 'The task ended.' })))
886 }
887 if (e.origin.kind !== 'composer') {
888 // A notice that starts a turn of its own is what that turn's reply answers.
889 if (e.turnId === undefined) {
890 passes.waiting.push({ text: e.text, isNotice: true, asked: [], answered: [] })
891 await keepTurn($)
892 }
893
894 return next(e)
895 }
896 await followRewind($)
897 const added = (await viewOf($)).added
898 const before = { ledger: await ledgerOf($), turns: await $.session.turns(), added }
899 const { sent, rest } = sentOf(e.text, added)
900 await record($, () => [{ type: 'turn' }, ...sent.map(one => ({ type: 'patch' as const, id: one.id, patch: { sent: one.said || 'Sent with no note.' }, drop: ['isUnsettled' as const] }))])
901 await change($, now => ({ ...now, added: {} }))
902 Object.assign(seen, { hasStaged: false, recorded: 0, agentOpened: 0 })
903 const submitted: Sent = { text: e.text, isNotice: false, asked: [], answered: sent.map(one => one.id), before }
904 passes.waiting.push(submitted)
905 await keepTurn($)
906 // Read now, before Claude starts on this message: what the message answers is the reply before it.
907 const lastReply = lastReplyOf((await $.session.messages()) as Message[])
908 queued($, () => promptPass($, rest, submitted, lastReply))
909
910 return next(e)
911 })
912
913 // Each edit of the prompt box is checked against the items waiting in it, without holding the key up.
914 on('prompt.edit', async ($, e, next) => {
915 const box = await next(e)
916 if (seen.hasStaged && box.text !== e.text) void followPrompt($, box.text)
917
918 return box
919 })
920
921 // What an agent of the mod's hands back is taken here and goes no further. Left alone it would
922 // land in the conversation as a message, and Claude would take a turn to answer it.
923 // The tool an agent hands its report back with is the engine's own and not among the typed ones,
924 // so one hook sees every tool call of the mod's agents and tells that tool apart by name.
925 on('tool.call', async ($, e, next) => {
926 const agent = (e as { agentId?: string }).agentId
927 if (agent === undefined) {
928 // A call of Claude's own: kept for the reply's read, with how it ended. The inbox tool is
929 // answered by a hook of this mod, whose answer has no `text` and is its `result`.
930 const answer = await next(e)
931 const said = answer as { text?: unknown; deny?: unknown; result?: unknown }
932 const line = toolLine(useOf(e as unknown as Record<string, unknown>, String(said.text ?? said.deny ?? (typeof said.result === 'string' ? said.result : ''))), seen.cwd)
933 if (line !== null) passes.did.push(line)
934
935 return answer
936 }
937 const item = (await ledgerOf($)).items.find(one => one.check?.state === 'running' && one.check.agent === agent)
938 // An agent is the mod's while a look of its is running, and stays so once the look has ended or was stopped.
939 if (!item && !(await viewOf($)).agents.includes(agent)) {
940 // A call of a subagent of Claude's: kept for the reply's read, as Claude's own are. The engine
941 // hands over no conversation for a workflow's agent, so this list is all there is of its calls.
942 const answer = await next(e)
943 const said = answer as { text?: unknown; deny?: unknown }
944 const line = toolLine(useOf(e as unknown as Record<string, unknown>, String(said.text ?? said.deny ?? '')), seen.cwd)
945 if (line !== null) passes.agents.get(agent)?.calls.push(line)
946
947 return answer
948 }
949 if ((e.tool as string) === HAND_BACK) {
950 if (item) await endCheck($, item.id, agent, String((e as { message?: unknown }).message ?? ''))
951 const told = item ? RECEIVED : STOPPED
952
953 return { result: { success: true, message: told }, text: told } as unknown as Awaited<ReturnType<typeof next>>
954 }
955 // Every other tool: an agent whose look was stopped is refused, so that it ends, and what a
956 // running one does is kept for the person who asks how the answer was found.
957 if (!item) return { deny: STOPPED }
958 const answer = await next(e)
959 const line = ranLine(e.tool, e as unknown as Record<string, unknown>, String((answer as { text?: unknown }).text ?? ''))
960 void record($, now => now.items.filter(one => one.id === item.id && one.check?.state === 'running').map(one => ({ type: 'patch' as const, id: one.id, patch: { check: withRan(one.check as Check, line) } })))
961
962 return answer
963 })
964
965 // The checker is the mod's to start and never Claude's. Kept out of what Claude is offered, it
966 // adds no agent to Claude's list and no notice of a new agent type to the conversation.
967 on('agent.offer', { agent: AGENT_TYPE }, async () => ({ isOffered: false }))
968
969 // A look is started here, from a hook on the press, and not from the button's own handler. An
970 // agent started from a handler is the mod's own doing with no hook above it, and the engine then
971 // runs none of the mod's hooks on what that agent does: its report would pass the hook above and
972 // land in the conversation. Started inside this hook, what the agent does reaches that hook.
973 on('ui.press', { plugin: 'inbox' }, async ($, e, next) => {
974 const id = /^(?:check|look|again)-(i\d+)$/.exec(e.element)?.[1]
975 if (e.requestId !== PANE || id === undefined) return next(e)
976 await startCheck($, id)
977
978 return { element: e.element }
979 })
980
981 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
982 const answer = await next(e)
983 // A command an agent of the mod's runs to check something is not the conversation's: its failing test is no item.
984 const agent = (e as { agentId?: string }).agentId
985 if (agent !== undefined && (await viewOf($)).agents.includes(agent)) return answer
986 const check = checkOf(e.command)
987 const said = answer as { isError?: boolean; text?: string; deny?: string; result?: { interrupted?: boolean; backgroundTaskId?: string } }
988 const task = said.result?.backgroundTaskId
989 if (task) void followTask($, task, e.description || e.command, `It runs: ${clip(e.command, 300)}`)
990 else if (check && said.deny === undefined && !said.result?.interrupted) void followCheck($, check, said.isError === true, said.text ?? '')
991
992 return answer
993 })
994
995 // A subagent of Claude's is kept by its id, so that a read can be told what it did, and one left
996 // running in the background is listed as running until it ends. The mod's own checker is not Claude's.
997 on('agent.spawn', async ($, e, next) => {
998 const started = await next(e)
999 if (started.agentId === undefined || e.subagentType === AGENT_TYPE) return started
1000 const name = e.description || e.subagentType
1001 passes.agents.set(started.agentId, { name, type: e.subagentType, isBackground: e.background, isInWorkflow: e.workflow !== undefined, isDone: false, task: e.prompt, told: 0, calls: [] })
1002 // A workflow's agents are listed as one task, the workflow, and a teammate idles and never ends.
1003 if (e.background && e.workflow === undefined && e.parentAgentId === undefined && e.isTeammate !== true) void followTask($, started.agentId, name, `A subagent that was told: ${clip(e.prompt, 300)}`)
1004
1005 return started
1006 })
1007
1008 // A workflow runs in the background from the start, and the notice of its end names its task.
1009 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
1010 const answer = await next(e)
1011 const run = (answer as { result?: { taskId?: string; workflowName?: string; summary?: string; error?: string } }).result
1012 if ((e as { agentId?: string }).agentId === undefined && run?.taskId && !run.error) void followTask($, run.taskId, `Workflow: ${run.workflowName ?? 'a script'}`, run.summary || 'A script of Claude\'s that hands the work to several subagents.')
1013
1014 return answer
1015 })
1016
1017 // A background task stopped by hand sends no notice of its end, so the stop closes the item that followed it.
1018 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
1019 const answer = await next(e)
1020 const task = e.task_id ?? e.shell_id
1021 if (task !== undefined) {
1022 void record($, now => now.items.filter(item => item.kind === 'running' && item.status === 'open' && item.key === task).map(item => ({ type: 'close', id: item.id, how: 'done', by: 'engine', reason: 'The task was stopped.' })))
1023 }
1024
1025 return answer
1026 })
1027
1028 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
1029 const answer = await next(e)
1030 const said = answer as { isError?: boolean; text?: string; deny?: string }
1031 void followDialog($, e.questions, said.text ?? said.deny ?? '', said.isError === true || said.deny !== undefined, e.tool_use_id ?? 'dialog')
1032
1033 return answer
1034 })
1035
1036 on('tool.call', { tool: 'mcp__inbox__inbox' }, async ($, e) => {
1037 const at = await $.clock.now()
1038 let text = ''
1039 const events = await record($, now => {
1040 const answer = answerTool(now, e as Parameters<typeof answerTool>[1], at, seen.recorded, passes.turnId === undefined ? undefined : { id: passes.turnId, heard: passes.turn?.length ?? 0, unheard: passes.waiting.flatMap(sent => sent.answered) })
1041 text = answer.text
1042
1043 return answer.events
1044 })
1045 if (events.some(event => event.type === 'open')) seen.recorded += 1
1046 void note($, 'tool', { call: e, text })
1047
1048 return { result: text }
1049 })
1050
1051 // A turn answers what was submitted up to the message it starts with. What the person typed after
1052 // that, while Claude was still working, waits: for the running turn to take it in, or for a turn of its own.
1053 on('turn.start', async ($, e, next) => {
1054 // An agent's turn is not the conversation's, and answers nothing the person sent.
1055 if ((e as { agentId?: string }).agentId !== undefined) return next(e)
1056 passes.did = []
1057 passes.turnId = e.turnId
1058 const at = passes.waiting.findIndex(sent => sent.text === e.text)
1059 passes.turn = passes.waiting.splice(0, at >= 0 ? at + 1 : e.text === '' ? 0 : passes.waiting.length)
1060 await keepTurn($)
1061
1062 return next(e)
1063 })
1064
1065 // A message typed while Claude works is handed to it inside the running turn, when that turn is
1066 // still calling tools. The turn then answers that message too, and no turn starts for it. One row
1067 // hands over one message, and they are handed over in the order they were typed: the row is the
1068 // first waiting message whose words it holds, so of two alike only one leaves.
1069 on('session.append', { door: 'delivery' }, async ($, e, next) => {
1070 const turn = passes.turn
1071 if (e.agentId !== undefined || e.message.name !== 'queued_command' || !turn) return next(e)
1072 const told = e.message.content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('\n')
1073 const at = passes.waiting.findIndex(sent => !sent.isNotice && sent.text !== '' && told.includes(sent.text))
1074 if (at >= 0) {
1075 turn.push(...passes.waiting.splice(at, 1))
1076 await keepTurn($)
1077 }
1078
1079 return next(e)
1080 })
1081
1082 on('turn.complete', async ($, e, next) => {
1083 if (e.agentId !== undefined) {
1084 // The end of an agent's turn is the end of its look, when it has one still running: an agent
1085 // that never handed anything back said what it found in its last words.
1086 const agent = e.agentId
1087 const item = (await ledgerOf($)).items.find(one => one.check?.state === 'running' && one.check.agent === agent)
1088 if (item) await endCheck($, item.id, agent, e.answer)
1089 // A subagent of Claude's that ended: its last words are its report, and the item that listed it as running closes.
1090 const helper = passes.agents.get(agent)
1091 if (helper) {
1092 // Audited once, at the end of its first run: a subagent sent a further message runs again.
1093 if (passes.depth.isAuditingAgents && !helper.isDone) queued($, () => agentPass($, agent, { ...helper }, [...helper.calls], e.answer))
1094 Object.assign(helper, { isDone: true, report: e.answer })
1095 await record($, now => now.items.filter(one => one.kind === 'running' && one.status === 'open' && one.key === agent).map(one => ({ type: 'close', id: one.id, how: 'done', by: 'engine', reason: 'The subagent ended.' })))
1096 }
1097
1098 return next(e)
1099 }
1100 const turn = passes.turn ?? passes.waiting.splice(0)
1101 passes.turn = null
1102 passes.turnId = undefined
1103 await keepTurn($)
1104 // A prompt cancelled before Claude wrote anything goes back in the prompt box, and Claude Code
1105 // takes it out of the conversation: its count of turns is what it was when the prompt was sent.
1106 // Then the inbox is put back too. A turn stopped later stays in the conversation, and so does
1107 // what the inbox made of it.
1108 const [only] = turn
1109 const taken = turn.length === 1 && e.isAborted ? only?.before : undefined
1110 if (only !== undefined && taken !== undefined && (await $.session.turns()) === taken.turns) {
1111 only.isTakenBack = true
1112 await record($, () => [{ type: 'retract', to: taken.ledger }])
1113 await change($, now => ({ ...now, added: taken.added }))
1114 seen.hasStaged = Object.keys(taken.added).length > 0
1115
1116 return next(e)
1117 }
1118 void followTurn($, e.reason)
1119 // A check that passed and that the person did not close is closed with the turn.
1120 await record($, now => passedOf(now, 'engine'))
1121 // A turn that ended without a reply settles nothing, so it is audited only when Claude closed
1122 // something in it on its own word: that close has had no other read.
1123 const isCut = e.reason !== 'answer'
1124 if (!isCut || closedByClaude(await ledgerOf($), e.turnId).length > 0) {
1125 // The turn's tool calls are in two places, and either can have lost some: the conversation to
1126 // a compaction, the mod's own list to a reload of the mod. Neither gains any, so the longer is
1127 // the whole. Read here and not when the audit runs, by when a new prompt may have been sent.
1128 const messages = (await $.session.messages()) as Message[]
1129 const told = toolLines(messages, seen.cwd)
1130 const did = newest(passes.did.length >= told.length ? passes.did : told)
1131 const answer = isCut ? `${e.answer}\n\n${CUT_SHORT}`.trim() : e.answer
1132 const job = { id: e.turnId, answer, turn: turn.map(({ text, isNotice, asked, answered }) => ({ text, isNotice, asked, answered })), did, said: saidOf(messages, e.answer), agents: await subagentsDid($, turn), todo: todoOf(messages), ...(isCut ? { isCut } : {}) }
1133 await update($, pendingAtom, left => [...listOf(left), job])
1134 audit($, job)
1135 }
1136 // Last, and never in the way of the audit: a mark that is not written only costs a fork its inbox.
1137 try {
1138 await mark($)
1139 } catch (error) {
1140 $.ui.log(`inbox: the turn's mark was not written: ${String(error).slice(0, 120)}`)
1141 }
1142
1143 return next(e)
1144 })
1145
1146 // Made for the mouse: a click presses, and the keyboard's focus ring lands only in a text box.
1147 on('ui.focus', { plugin: 'inbox' }, async ($, e, next) => (e.requestId === PANE && e.origin.kind === 'person' && !e.element?.startsWith('input-') ? {} : next(e)))
1148
1149 on('ui.close', async ($, e, next) => {
1150 if (e.id === PANE) await change($, now => ({ ...now, isPanelOpen: false }))
1151
1152 return next(e)
1153 })
1154
1155 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1156 noteViewport(e.viewport, await viewOf($))
1157
1158 return next(e)
1159 })
1160
1161 // The handle is the one way in and out: a small box that is always there, at the right end of the
1162 // status row, with the number of open items and an arrow toward where the panel lands: sideways
1163 // where it docks at the right, upward where it lands above the prompt.
1164 // The handle goes after whatever is drawn there already, the mode labels and what another mod adds.
1165 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1166 const [now, book, beneath] = await Promise.all([viewOf($), ledgerOf($), next(e)])
1167 noteViewport(e.viewport, now)
1168 const { Box, Text } = $.ui.resolve(e)
1169 const arrow = docks() ? (now.isPanelOpen ? '▸' : '◂') : now.isPanelOpen ? '▾' : '▴'
1170
1171 return (
1172 <Box>
1173 {beneath}
1174 {e.props.modes.length > 0 && <Text> </Text>}
1175 {handleOf($, $.ui.resolve(e), now, book, arrow)}
1176 </Box>
1177 )
1178 })
1179
1180 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1181 const ui = $.ui.resolve(e)
1182 const { Box, Button, Link, Text } = ui
1183 // A surface with no text box of its own gets the answer buttons alone.
1184 const Input = 'Input' in ui ? ui.Input : undefined
1185 const [now, book, clock] = await Promise.all([viewOf($), ledgerOf($), $.clock.now()])
1186 const isDock = e.props.placement === 'dock'
1187 const height = e.props.scroll.bodyRows
1188 Object.assign(seen, { bodyColumns: e.props.bodyColumns, placement: e.props.placement })
1189 const width = e.props.bodyColumns
1190 const sections = sectionsOf(book)
1191 const openIn = (section: Section) => section.groups.flatMap(group => group.rows.filter(row => row.status === 'open'))
1192 const layout = layoutOf(book)
1193 const opened = book.items.filter(item => item.status === 'open')
1194 const isFolded = (key: string) => now.folded.includes(key)
1195
1196 const pillOf = (text: string, color: Color) => (
1197 <Text backgroundColor={color} color="inverseText" bold>
1198 {' '}
1199 {text}{' '}
1200 </Text>hooks/capture.ts 184 lines1// How an item is written into the prompt box, and read back out of the prompt that was sent.
2// Nothing here touches the engine.
3import type { Item, Kind, Ledger } from '../types'
4import { progressOf, sectionsOf } from './ledger'
5
6/** What stands between two items in the prompt box. */
7export const RULE = '\n\n---\n\n'
8export const SECTION_NAME = { needs: 'Needs you', findings: 'Findings', goals: 'Goals', running: 'Running' }
9const LABEL: Record<Kind, string> = {
10 stopped: 'Stopped',
11 check: 'Failing check',
12 question: 'Question',
13 task: 'Your task',
14 issue: 'Issue',
15 opportunity: 'Opportunity',
16 unverified: 'Unverified',
17 ask: 'Open ask',
18 running: 'Running',
19}
20/** How an item's line starts in the prompt box. The id in it is what Claude fetches the item by. */
21const leadOf = (id: string) => `Inbox ${id}:`
22const TITLE_LEAD = /^> Inbox \w+: /
23
24export type Decoration = { start: number; end: number; bold?: boolean; dimColor?: boolean }
25
26export function labelOf(item: Item) {
27 return item.kind === 'ask' && item.isMissed ? 'Missed ask' : LABEL[item.kind]
28}
29
30/**
31 * What the panel calls an item of each kind, in a word. The labels above are what Claude and the
32 * models read, where an ask has to be told apart from the goal it belongs to. To the person, what
33 * they asked for is a goal whether it is one item or a thread of them.
34 */
35export function nameOf(item: Item) {
36 // A claim that a check ran and bore out is no longer one that nobody verified.
37 if (item.kind === 'unverified' && item.status === 'done' && item.check?.verdict === 'passed') return 'Verified'
38
39 return item.kind === 'ask' ? (item.isMissed ? 'Missed goal' : 'Goal') : item.kind === 'task' ? 'Task' : LABEL[item.kind]
40}
41
42export function clip(text: string, room: number) {
43 return text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`
44}
45
46export function wrapped(line: string, room: number) {
47 const lines = ['']
48 for (const word of line.split(' ')) {
49 const last = lines[lines.length - 1] ?? ''
50 if (last !== '' && last.length + 1 + word.length > room) lines.push(word)
51 else lines[lines.length - 1] = last === '' ? word : `${last} ${word}`
52 }
53
54 return lines.map(one => clip(one, room))
55}
56
57/** At most `most` lines of `text` wrapped to `room`, the last cut with an ellipsis when more follow. */
58export function linesOf(text: string, room: number, most: number) {
59 const lines = wrapped(text, room)
60
61 return lines.length <= most ? lines : [...lines.slice(0, most - 1), clip(`${lines[most - 1]} ${lines[most]}`, room)]
62}
63
64/** Where an item sits in the panel: its section's name, then its group's with a goal's progress. */
65export function placeOf(ledger: Ledger, item: Item) {
66 for (const section of sectionsOf(ledger)) {
67 const group = section.groups.find(one => one.rows.some(row => row.id === item.id))
68 if (!group) continue
69 const progress = group.goal ? progressOf(ledger, group.goal) : null
70
71 return [SECTION_NAME[section.key], group.heading && `${group.heading}${progress ? ` (${progress.done} of ${progress.of} done)` : ''}`].filter(Boolean).join(' / ')
72 }
73
74 return ''
75}
76
77/**
78 * One item as the lines put in the prompt box: its id and its title, quoted. Everything else the
79 * inbox knows about it stays out of the box, where the person would have to read past it: Claude
80 * fetches it by the id, with the tool's `get`.
81 */
82export function captureOf(item: Item, room: number) {
83 return wrapped(`${leadOf(item.id)} ${item.title}`, room).map((line, at) => `> ${at === 0 ? '' : ' '}${line}`)
84}
85
86/** An item in full, as the tool's `get` hands it to Claude: what its line in the prompt box leaves out. */
87export function fullOf(ledger: Ledger, item: Item) {
88 const doneSoFar = item.goal === undefined ? [] : ledger.items.filter(one => one.goal === item.goal && one.status === 'done').map(one => one.title)
89
90 return {
91 id: item.id,
92 kind: labelOf(item),
93 title: item.title,
94 status: item.status,
95 where: placeOf(ledger, item),
96 from: item.from,
97 why: item.why,
98 toClose: item.close,
99 ...(item.options ? { answers: item.options } : {}),
100 ...(item.command ? { command: item.command } : {}),
101 ...(item.link ? { link: item.link } : {}),
102 ...(item.file ? { file: item.file } : {}),
103 ...(item.check?.text ? { check: { verdict: item.check.verdict ?? 'running', found: item.check.text } } : {}),
104 ...(doneSoFar.length > 0 ? { doneSoFar } : {}),
105 }
106}
107
108/** What the prompt box holds, less the item that carries `mark`: an item added again takes the place of its earlier capture. */
109export function without(held: string, mark: string | undefined) {
110 return mark === undefined
111 ? held
112 : held
113 .split(RULE)
114 .filter(block => !block.includes(mark))
115 .join(RULE)
116}
117
118/**
119 * An item as a quoted capture after what the prompt box already holds, then `say`: the answer picked
120 * for it or what Claude is asked to do with it. A `say` that ends in a colon is left for the person
121 * to finish, with the cursor right after it. `mark` is how the capture starts, id included, by which
122 * the item is later found in the box.
123 */
124export function draftOf(held: string, item: Item, room: number, say?: string) {
125 const capture = captureOf(item, room)
126 const before = held.trim() === '' ? '' : `${held.replace(/\n+$/, '')}${RULE}`
127 const after = say === undefined ? '' : say.endsWith(': ') ? say : `${say}\n\n`
128 const text = `${before}${capture.join('\n')}\n\n${after}`
129 const titles = new Set(capture)
130 const decorations: Decoration[] = []
131 let at = 0
132 for (const line of text.split('\n')) {
133 if (titles.has(line) || TITLE_LEAD.test(line)) decorations.push({ start: at, end: at + line.length, bold: true })
134 else if (line.startsWith('>') || line === '---') decorations.push({ start: at, end: at + line.length, dimColor: true })
135 at += line.length + 1
136 }
137
138 return { text, decorations, mark: `> ${leadOf(item.id)}` }
139}
140
141/**
142 * What a card's Send adds under the capture, empty while nothing is ticked or typed. On a question,
143 * text typed beside ticked answers goes on a line of its own, so that a comma in it is not read as
144 * two answers. Elsewhere the typed value follows `lead`.
145 */
146export function sayOf(item: Item, picks: number[], typed: string, lead: string) {
147 const own = typed.trim()
148 if (!item.options) return own === '' ? '' : `${lead}${own}`
149 const ticked = picks
150 .map(at => item.options?.[at] ?? '')
151 .filter(Boolean)
152 .join(', ')
153 if (ticked === '') return own === '' ? '' : `Answer: ${own}`
154
155 return own === '' ? `Answer: ${ticked}` : `Answer: ${ticked}\nCustom: ${own}`
156}
157
158/**
159 * A sent prompt taken apart: for each item whose capture went out with it, what the person said
160 * under the capture, and `rest`, the text that belongs to no capture. `added` holds each waiting
161 * item's mark.
162 */
163export function sentOf(text: string, added: Record<string, string>) {
164 const blocks = text.split(RULE)
165 const sent = Object.entries(added).flatMap(([id, mark]) => {
166 const block = blocks.find(one => one.includes(mark))
167 if (block === undefined) return []
168 const said = block
169 .split('\n')
170 .filter(line => !line.startsWith('>'))
171 .join('\n')
172 .trim()
173
174 return [{ id, said }]
175 })
176 const marks = Object.values(added)
177 const rest = blocks
178 .filter(block => !marks.some(mark => block.includes(mark)))
179 .join('\n\n')
180 .trim()
181
182 return { sent, rest }
183}
184hooks/checks.ts 154 lines1// A look at an item in the background: what the agent that takes it is, what it is asked, and how
2// what it hands back is read. Nothing here touches the engine.
3import type { Check, Event, Item } from '../types'
4
5/** The agent type the mod registers, as the engine names it: the plugin's name, then the type's. */
6export const AGENT = 'checker'
7export const AGENT_TYPE = `inbox:${AGENT}`
8/** How long after a result came back asking again is offered: sooner, the answer would be the same. */
9export const ASK_AGAIN_AFTER = 3 * 60_000
10export const CHECKS_AT_ONCE = 3
11/** How many of the agent's commands a check keeps to show. */
12const RAN_KEPT = 12
13
14/**
15 * The agent looks and reports, and changes nothing: fixing what a check finds is work for the
16 * conversation, where the person sees it and can stop it.
17 */
18export const AGENT_SPEC = {
19 name: AGENT,
20 description: 'Looks into one thing for the inbox by reading and running, and reports what it saw. Started by the inbox, not by Claude.',
21 prompt: [
22 'You look into one thing and report what you saw. You work beside a conversation between a user and Claude, and neither of them sees you work.',
23 'You may read files and run commands that change nothing. Never edit, write, move or delete a file, never commit or push, never install anything, and never stop or restart a process you did not start. If settling it would take any of those, say so in your report instead.',
24 'Run the least that settles it, and do not ask anyone anything. When you have your answer, hand back your report once, in the form you were asked for, and end your turn.',
25 ].join(' '),
26 tools: ['Read', 'Grep', 'Glob', 'Bash'],
27}
28
29/** The engine's tool that an agent hands its report back with. */
30export const HAND_BACK = 'SubagentHandback'
31/** What the engine's hand-back tool is told once the mod has the report, so that the agent stops there. */
32export const RECEIVED = 'Delivered. Your work is finished: end your turn now with one word and do not call this tool again.'
33export const STOPPED = 'This look was stopped by the user. End your turn now with one word and call no more tools.'
34/** What closes an issue that a failed check made. What the claim before it was waiting on is not what settles the problem found. */
35export const ISSUE_CLOSE = 'Fix the problem that the check found, and show by running that it is gone.'
36
37/**
38 * How a report is to be written: it is read in a narrow side panel, which draws it as Markdown, by
39 * a person who wants the answer and not how it was reached.
40 */
41const FOR_THE_PANEL =
42 'It is read in a narrow side panel, so each bullet is one short fact on a line of its own that starts with "- ". Put every command, path, file name, id and value in backticks. Leave out process ids, temporary paths and how you went about it, unless the user needs them to act.'
43
44/** True for an item a look in the background can be taken at: a finding that was never run, or something still running. */
45export function canCheck(item: Item) {
46 return item.status === 'open' && (item.kind === 'unverified' || item.kind === 'running' || item.check?.was !== undefined)
47}
48
49/** What the agent is asked: to check a finding by running it, or to say where a running task stands. */
50export function checkAsk(item: Item) {
51 if (item.kind === 'running') {
52 return [
53 'Find out where this background task stands right now. Do not stop it, restart it or wait for it to end.',
54 `The task: ${item.title}`,
55 item.why,
56 ...(item.key ? [`Its task id: ${item.key}`] : []),
57 'Look at what it has written so far: its output, the files it writes, and whether its process is alive.',
58 'Report in this form and nothing else:',
59 `REPORT: where it stands, in one short sentence. Then at most four bullets: how far along it is, whether anything has gone wrong, and when it looks like ending. ${FOR_THE_PANEL}`,
60 ].join('\n')
61 }
62
63 return [
64 'Check whether this holds by running it. Reading the code alone does not settle it.',
65 `What was never run: ${item.check?.was?.title ?? item.title}`,
66 `What is known: ${item.why}`,
67 `What would settle it: ${item.check?.was?.close ?? item.close}`,
68 ...(item.file ? [`The file it is about: ${item.file}`] : []),
69 'Report in this form and nothing else:',
70 'VERDICT: passed, failed or unclear',
71 'TITLE: only when it failed, the problem you found, as one plain sentence of at most 14 words, with no backticks',
72 `REPORT: what you found, in one short sentence. Then at most four bullets: what you ran and what you saw. ${FOR_THE_PANEL}`,
73 '"passed" means you ran it and it holds. "failed" means you ran it and it does not hold. "unclear" means you could not run it, or what you saw does not settle it: then the report says why.',
74 ].join('\n')
75}
76
77/** One field of a report: the text after its name, up to the next field. */
78function fieldOf(message: string, name: string) {
79 return new RegExp(`^${name}:[ \\t]*([\\s\\S]*?)(?=^(?:VERDICT|TITLE|REPORT):|$(?![\\s\\S]))`, 'm').exec(message)?.[1]?.trim() ?? ''
80}
81
82/**
83 * What the agent handed back, as the events it causes. A report that does not follow the form is
84 * one that settled nothing, with its words kept. A check that failed makes an issue of the item,
85 * under the problem the agent named and closed by that problem's fix, and remembers what the item was.
86 */
87export function checkBack(item: Item, message: string, at: number): Event[] {
88 const text = fieldOf(message, 'REPORT') || message.trim()
89 const kept = { state: 'back' as const, at, text, ...tracesOf(item.check) }
90 if (item.kind === 'running') return [{ type: 'patch', id: item.id, patch: { check: kept } }]
91 // What the item was before an earlier look made an issue of it: a second look starts from that again.
92 const was = item.check?.was ?? { kind: item.kind, title: item.title, close: item.close }
93 const said = fieldOf(message, 'VERDICT').toLowerCase()
94 const verdict = said.startsWith('passed') ? 'passed' : said.startsWith('failed') ? 'failed' : 'unclear'
95 if (verdict !== 'failed') return [{ type: 'patch', id: item.id, patch: { ...was, check: { ...kept, verdict } } }]
96 // A title is drawn as plain text, where a backtick would show as one.
97 const title = (fieldOf(message, 'TITLE').split('\n')[0] ?? '').replaceAll('`', '')
98
99 return [{ type: 'patch', id: item.id, patch: { kind: 'issue', title: title || was.title, close: ISSUE_CLOSE, check: { ...kept, verdict, was } } }]
100}
101
102/**
103 * A look whose agent ended with no report, as one that settled nothing. A look is never ended
104 * for taking long: the agent of one that timed out came back a minute later, and its report was
105 * thrown away. It runs until its agent ends or the person stops it.
106 */
107export function checkLost(item: Item, at: number, why: string): Event[] {
108 const lost: Check = { state: 'back', at, text: why, ...(item.kind === 'running' ? {} : { verdict: 'unclear' as const }), ...tracesOf(item.check) }
109
110 return [{ type: 'patch', id: item.id, patch: { ...(item.check?.was ?? {}), check: lost } }]
111}
112
113/** Throwing a result away: the item is what it was before the look, an issue made by a failed check included. */
114export function checkDismissed(item: Item): Event[] {
115 return [{ type: 'patch', id: item.id, patch: item.check?.was ?? {}, drop: ['check'] }]
116}
117
118/** One command of the agent's, kept with how it ended, for the person who wants to see how the answer was found. */
119export function ranLine(tool: string, input: Record<string, unknown>, output: string) {
120 const last = output
121 .split('\n')
122 .map(line => line.trim())
123 .filter(Boolean)
124 .at(-1)
125 if (tool === 'Bash') return `$ ${String(input.command ?? '').split('\n').join(' ').slice(0, 160)}${last ? ` => ${last.slice(0, 120)}` : ''}`
126
127 return `${tool} ${String(input.file_path ?? input.pattern ?? input.path ?? '').slice(0, 160)}`
128}
129
130/**
131 * The check with one more step: counted, and its command put on the list, the oldest dropped once
132 * the list is full. The count is kept apart from the list, which would stop at its own length.
133 */
134export function withRan(check: Check, line: string): Check {
135 return { ...check, steps: stepsOf(check) + 1, ran: [...(check.ran ?? []), line].slice(-RAN_KEPT) }
136}
137
138/** How many steps a check has taken. */
139export function stepsOf(check: Check) {
140 return check.steps ?? check.ran?.length ?? 0
141}
142
143/** What a look that has ended keeps of how it was taken. */
144function tracesOf(check: Check | undefined) {
145 return check?.ran ? { steps: stepsOf(check), ran: check.ran } : {}
146}
147
148/** What goes with an item into the prompt box when a look found something for the conversation to act on. */
149export function sayOfCheck(check: Check) {
150 if (check.verdict === 'failed') return `A check in the background found this: ${check.text ?? ''} Fix it, then show me that it holds.`
151
152 return `A check in the background could not settle this: ${check.text ?? ''}`
153}
154hooks/ledger.ts 324 lines1// The inbox's items and how they change. Nothing here touches the engine. Every change is an event,
2// a conversation's ledger is its events applied in order, and the log of those events is what
3// brings the inbox back when the conversation is resumed.
4import type { Actor, Event, Goal, Item, Ledger } from '../types'
5
6/** How many prompts a closed item stays listed for before it leaves the panel. Its log lines stay. */
7export const KEPT_TURNS = 4
8
9export const EMPTY: Ledger = { items: [], goals: [], nextId: 1, turn: 0, dropped: [] }
10
11export type Group = { key: string; heading?: string; goal?: Goal; isBatch?: true; rows: Item[] }
12export type Section = { key: 'needs' | 'findings' | 'goals' | 'running'; groups: Group[] }
13type Fields = Pick<Item, 'kind' | 'title' | 'from' | 'why' | 'close'> & Partial<Omit<Item, 'id' | 'turn' | 'at' | 'by' | 'status'>>
14
15const isClosed = (item: Item) => item.status === 'done' || item.status === 'dropped'
16
17/** Words that say nothing about what a title names. */
18const FILLER = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'are', 'was', 'not', 'but', 'its', 'still', 'only', 'now'])
19
20/** A title as its words of weight, so that two spellings of one thing compare alike. */
21export function wordsOf(title: string) {
22 return title
23 .toLowerCase()
24 .replace(/[^a-z0-9]+/g, ' ')
25 .split(' ')
26 .filter(word => word.length > 2 && !FILLER.has(word))
27}
28
29/** True when two titles name the same thing: at least half of the words of the two are shared. */
30export function isAlike(one: string, other: string) {
31 const words = new Set(wordsOf(one))
32 const others = new Set(wordsOf(other))
33 const shared = [...words].filter(word => others.has(word)).length
34
35 return shared > 0 && shared / (words.size + others.size - shared) >= 0.5
36}
37
38/** True when `title` names something the person already dropped. */
39export function isDropped(ledger: Ledger, title: string) {
40 return ledger.dropped.some(dropped => isAlike(dropped, title))
41}
42
43/** True when `title` names something the inbox already holds open or set aside. */
44export function isListed(ledger: Ledger, title: string) {
45 return ledger.items.some(item => (item.status === 'open' || item.status === 'later') && isAlike(item.title, title))
46}
47
48/** A new item as an event, numbered after the ledger's last and stamped with its count of prompts. */
49export function opened(ledger: Ledger, at: number, by: Actor, fields: Fields): Extract<Event, { type: 'open' }> {
50 return { type: 'open', item: { ...fields, id: `i${ledger.nextId}`, turn: ledger.turn, at, by, status: 'open' } }
51}
52
53export function goalOpened(ledger: Ledger, title: string): Extract<Event, { type: 'goal' }> {
54 return { type: 'goal', goal: { id: `g${ledger.nextId}`, title, turn: ledger.turn } }
55}
56
57/**
58 * How far along a goal is, counted in its asks: the questions and findings that belong to it are
59 * not steps of it. It is complete once it has asks and every one of them is done or dropped.
60 */
61export function progressOf(ledger: Ledger, goal: Goal) {
62 const asks = ledger.items.filter(item => item.goal === goal.id && item.kind === 'ask')
63
64 return {
65 done: asks.filter(item => item.status === 'done').length,
66 of: asks.filter(item => item.status !== 'dropped').length,
67 isComplete: asks.length > 0 && asks.every(isClosed),
68 }
69}
70
71/**
72 * The ledger after one more prompt. Closed items that have been listed for KEPT_TURNS prompts leave
73 * it. An ask stays with its goal while the goal has work open, so that the goal's progress still
74 * counts it, and a finished goal leaves with its asks. Nothing open leaves by its age: an open
75 * item stays until someone closes it or the person sets it aside.
76 */
77function aged(ledger: Ledger): Ledger {
78 const turn = ledger.turn + 1
79 const isStale = (item: Item) => isClosed(item) && (item.closedTurn ?? 0) + KEPT_TURNS <= turn
80 const gone = new Set(
81 ledger.goals
82 .filter(goal => progressOf(ledger, goal).isComplete && ledger.items.filter(item => item.goal === goal.id).every(isStale))
83 .map(goal => goal.id),
84 )
85 const kept = ledger.items.filter(item => (item.goal === undefined ? !isStale(item) : !gone.has(item.goal)))
86
87 return {
88 ...ledger,
89 turn,
90 goals: ledger.goals.filter(goal => !gone.has(goal.id)),
91 items: kept,
92 }
93}
94
95function changed(ledger: Ledger, id: string, change: (item: Item) => Item): Ledger {
96 return { ...ledger, items: ledger.items.map(item => (item.id === id ? change(item) : item)) }
97}
98
99const numberOf = (id: string) => Number(id.slice(1)) || 0
100
101export function apply(ledger: Ledger, event: Event): Ledger {
102 switch (event.type) {
103 case 'turn':
104 return aged(ledger)
105 case 'goal':
106 return { ...ledger, goals: [...ledger.goals, event.goal], nextId: Math.max(ledger.nextId, numberOf(event.goal.id) + 1) }
107 case 'open': {
108 const dropped = event.isRenewed ? ledger.dropped.filter(title => !isAlike(title, event.item.title)) : ledger.dropped
109
110 return { ...ledger, items: [...ledger.items, event.item], nextId: Math.max(ledger.nextId, numberOf(event.item.id) + 1), dropped }
111 }
112 case 'close': {
113 const item = ledger.items.find(one => one.id === event.id)
114 const dropped = item && event.how === 'dropped' && event.by === 'you' ? [...ledger.dropped, item.title] : ledger.dropped
115 const { sent: _sent, isUnsettled: _isUnsettled, isMissed: _isMissed, closedIn: _closedIn, closedHeard: _closedHeard, ...rest } = item ?? ({} as Item)
116 const within = { ...(event.in === undefined ? {} : { closedIn: event.in }), ...(event.heard === undefined ? {} : { closedHeard: event.heard }) }
117 const made = { status: event.how, closedBy: event.by, reason: event.reason, closedTurn: ledger.turn, ...within }
118
119 return item ? { ...changed(ledger, event.id, () => ({ ...rest, ...made })), dropped } : ledger
120 }
121 case 'later':
122 return changed(ledger, event.id, item => ({ ...item, status: 'later', closedBy: 'you', reason: event.reason, closedTurn: ledger.turn }))
123 case 'reopen': {
124 const title = ledger.items.find(item => item.id === event.id)?.title
125
126 // An item brought back is as fresh as a new one: its age counts from now.
127 return {
128 ...changed(ledger, event.id, ({ closedBy: _closedBy, reason: _reason, closedTurn: _closedTurn, closedIn: _closedIn, closedHeard: _closedHeard, ...item }) => ({ ...item, status: 'open' })),
129 dropped: ledger.dropped.filter(dropped => dropped !== title),
130 }
131 }
132 case 'patch':
133 return changed(ledger, event.id, item => {
134 const gone = new Set<string>(event.drop ?? [])
135
136 return Object.fromEntries(Object.entries({ ...item, ...event.patch, id: item.id }).filter(([key]) => !gone.has(key))) as Item
137 })
138 // The person cancelled a prompt and Claude Code took it out of the conversation: the inbox is as
139 // it was before that prompt. Ids are not handed out twice, so a log line names one item only.
140 case 'retract':
141 return { ...event.to, nextId: ledger.nextId }
142 // The person rewound the conversation to the end of this prompt. The inbox keeps everything, and
143 // what was opened after that point says so: the exchange it came from is gone.
144 case 'rewind':
145 return { ...ledger, items: ledger.items.map(item => (item.turn > event.turn && item.status !== 'done' && item.status !== 'dropped' ? { ...item, isRewound: true } : item)) }
146 case 'note':
147 return ledger
148 }
149}
150
151export function replay(events: Event[]) {
152 return events.reduce(apply, EMPTY)
153}
154
155/**
156 * The events of a log up to the end of its prompt number `turn`: all that came before the prompt
157 * after it. A fork of a conversation carries the inbox as it stood where the fork was made, and
158 * nothing the first conversation did afterwards.
159 */
160export function carriedTo(events: Event[], turn: number) {
161 const kept: Event[] = []
162 let now = EMPTY
163 for (const event of events) {
164 if (event.type === 'turn' && now.turn === turn) break
165 now = apply(now, event)
166 kept.push(event)
167 }
168
169 return kept
170}
171
172/**
173 * The marks a conversation's log left, one for each finished turn: the last tool call the
174 * conversation held then, the inbox's count of prompts, and Claude Code's own count of turns.
175 */
176export function marksOf(text: string) {
177 return text
178 .split('\n')
179 .map(line => line.trim().split(' '))
180 .flatMap(([call, turn, turns]) => (call && turn !== undefined && turn !== '' && Number.isInteger(Number(turn)) ? [{ call, turn: Number(turn), turns: Number(turns ?? -1) }] : []))
181}
182
183/**
184 * What a rewind left behind. Claude Code counts fewer turns after one than the newest marks were
185 * written at: those marks are of turns that are no longer in the conversation. Returns the last
186 * prompt of the inbox that is still in it and the marks that still hold, or null when no mark is
187 * ahead of the count, which is every case but a rewind (a compaction raises the count).
188 */
189export function rewoundTo(marks: { call: string; turn: number; turns: number }[], turns: number) {
190 const kept = marks.filter(mark => mark.turns <= turns)
191 if (kept.length === marks.length) return null
192
193 return { turn: Math.max(0, ...kept.map(mark => mark.turn)), kept }
194}
195
196/**
197 * The marks of a conversation that was started again, moved to the count of turns Claude Code gives
198 * it now. A compaction raises that count, and a conversation that is quit and resumed is counted
199 * afresh from what it still holds, so the count drops with nothing rewound. The newest mark is put
200 * at the count as it stands, less the prompts sent since that mark, and the others keep their
201 * distance from it. `prompts` is the inbox's own count of prompts. Marks the count is not behind stay.
202 */
203export function rebased(marks: { call: string; turn: number; turns: number }[], prompts: number, turns: number) {
204 const last = marks.at(-1)
205 const drop = last ? last.turns - (turns - Math.max(0, prompts - last.turn)) : 0
206
207 return drop > 0 ? marks.map(mark => ({ ...mark, turns: mark.turns - drop })) : marks
208}
209
210/**
211 * Where a fork was made, among the marks of one log: the mark for the same count of turns as the
212 * fork holds, when one of its tool calls is there too. Two turns can end on the same tool call, so
213 * without that count the newest mark for the newest call the fork holds is taken.
214 */
215export function forkPoint(marks: { call: string; turn: number; turns: number }[], calls: string[], turns: number) {
216 const held = marks.map(mark => ({ ...mark, at: calls.lastIndexOf(mark.call) })).filter(mark => mark.at >= 0)
217 const exact = held.filter(mark => mark.turns === turns).at(-1)
218 const newest = held.reduce<(typeof held)[number] | undefined>((best, mark) => (best === undefined || mark.at >= best.at ? mark : best), undefined)
219
220 return exact ? { ...exact, isExact: true } : newest ? { ...newest, isExact: false } : null
221}
222
223/** How long ago `at` was, in the one unit that fits: `now`, `5m`, `2h`, `3d`. */
224export function ageOf(at: number, now: number) {
225 const minutes = Math.floor((now - at) / 60_000)
226 if (minutes < 1) return 'now'
227 if (minutes < 60) return `${minutes}m`
228
229 return minutes < 60 * 24 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / (60 * 24))}d`
230}
231
232/**
233 * The ledger's items as the panel lays them out: what waits on the person, findings, goals with
234 * their asks, and what is running. Each group holds its items of every status, the panel choosing
235 * which to show where.
236 */
237export function sectionsOf(ledger: Ledger): Section[] {
238 const of = (key: string, heading: string, isIn: (item: Item) => boolean): Group => ({ key, heading, rows: ledger.items.filter(isIn) })
239 // A goal is drawn as one once two asks share it. Until then its one ask is listed like any other.
240 const shared = ledger.goals.filter(goal => ledger.items.filter(item => item.goal === goal.id).length > 1)
241
242 return [
243 {
244 key: 'needs',
245 groups: [
246 of('stopped', 'Stopped', item => item.kind === 'stopped'),
247 of('checks', 'Failing checks', item => item.kind === 'check'),
248 of('questions', 'Questions', item => item.kind === 'question' && item.dialog === undefined),
249 { ...of('dialog', 'Left in a dialog', item => item.kind === 'question' && item.dialog !== undefined), isBatch: true },
250 of('tasks', 'Your tasks', item => item.kind === 'task'),
251 ],
252 },
253 {
254 key: 'findings',
255 groups: [
256 of('issues', 'Issues', item => item.kind === 'issue'),
257 of('opportunities', 'Opportunities', item => item.kind === 'opportunity'),
258 of('unverified', 'Unverified', item => item.kind === 'unverified'),
259 ],
260 },
261 {
262 key: 'goals',
263 groups: [
264 ...shared.map(goal => ({ key: goal.id, heading: goal.title, goal, rows: ledger.items.filter(item => item.kind === 'ask' && item.goal === goal.id) })),
265 of('asks', 'Asks', item => item.kind === 'ask' && !shared.some(goal => goal.id === item.goal)),
266 ],
267 },
268 { key: 'running', groups: [{ key: 'running', rows: ledger.items.filter(item => item.kind === 'running') }] },
269 ]
270}
271
272const NEEDS: Item['kind'][] = ['stopped', 'check', 'question', 'task']
273const FINDINGS: Item['kind'][] = ['issue', 'opportunity', 'unverified']
274/** True for an item that waits on the person. */
275export const needsYou = (item: Item) => NEEDS.includes(item.kind)
276/** Inside a goal, what waits on the person comes first, then findings, then the goal's own asks. */
277const rankOf = (item: Item) => (NEEDS.includes(item.kind) ? 0 : FINDINGS.includes(item.kind) ? 1 : item.kind === 'ask' ? 2 : 3)
278
279/** A goal as the panel draws it: what is open in it, what is closed, and how much of it waits on the person. */
280export type GoalBlock = { goal: Goal; open: Item[]; done: Item[]; waiting: number }
281export type Layout = {
282 goals: GoalBlock[]
283 /** Open asks that share a goal with nothing else: each is a goal of one item. */
284 single: Item[]
285 oneOffs: { key: 'needs' | 'findings' | 'running'; rows: Item[] }[]
286 later: { held: { goal: Goal; rows: Item[] }[]; rows: Item[] }
287 closed: { goals: GoalBlock[]; rows: Item[] }
288}
289
290/**
291 * The ledger's items as the panel lays them out: goals first, each with everything that belongs
292 * to it, then what belongs to no goal by what it is. A goal is a block of its own once two items
293 * share it. Until then its one item is listed like any other of its kind. A goal with nothing
294 * open or set aside left in it is under Closed, and what was set aside keeps its goal's name.
295 */
296export function layoutOf(ledger: Ledger): Layout {
297 const shared = ledger.goals.filter(goal => ledger.items.filter(item => item.goal === goal.id).length > 1)
298 const blocks = shared.map(goal => {
299 const own = ledger.items.filter(item => item.goal === goal.id)
300 const open = own.filter(item => item.status === 'open').sort((one, other) => rankOf(one) - rankOf(other))
301
302 return { goal, open, done: own.filter(isClosed), waiting: open.filter(needsYou).length, later: own.filter(item => item.status === 'later') }
303 })
304 const loose = ledger.items.filter(item => !shared.some(goal => goal.id === item.goal))
305 const open = loose.filter(item => item.status === 'open')
306 const block = ({ goal, open: rows, done, waiting }: (typeof blocks)[number]): GoalBlock => ({ goal, open: rows, done, waiting })
307
308 return {
309 goals: blocks.filter(one => one.open.length > 0).map(block),
310 single: open.filter(item => item.kind === 'ask'),
311 oneOffs: [
312 { key: 'needs', rows: open.filter(needsYou) },
313 { key: 'findings', rows: open.filter(item => FINDINGS.includes(item.kind)) },
314 { key: 'running', rows: open.filter(item => item.kind === 'running') },
315 ],
316 later: { held: blocks.filter(one => one.later.length > 0).map(one => ({ goal: one.goal, rows: one.later })), rows: loose.filter(item => item.status === 'later') },
317 closed: {
318 goals: blocks.filter(one => one.open.length === 0 && one.later.length === 0 && one.done.length > 0).map(block),
319 // What is closed in a goal whose other items are all set aside has no block to sit in.
320 rows: [...loose.filter(isClosed), ...blocks.filter(one => one.open.length === 0 && one.later.length > 0).flatMap(one => one.done)],
321 },
322 }
323}
324hooks/signals.ts 87 lines1// What the mod learns without asking a model: a check that failed or passed, and a question dialog
2// that was left with questions unanswered. Nothing here touches the engine.
3import type { Item } from '../types'
4
5type Fields = Pick<Item, 'kind' | 'title' | 'from' | 'why' | 'close'> & Partial<Item>
6type Asked = { question: string; header?: string; multiSelect?: boolean; options?: { label: string; description?: string }[] }
7
8/** Commands whose exit status says whether the work holds: tests, type checks, linters, builds, validators. */
9const CHECKS = [
10 /^(npm|pnpm|yarn|bun) (run )?(test|lint|typecheck|type-check|check|build)\b/,
11 /^(npx|pnpm dlx|bunx) (-p \S+ )?(tsc|vitest|jest|eslint|biome|prettier --check|playwright test)\b/,
12 /^(tsc|vitest|jest|eslint|biome|ruff|mypy|pyright|pytest|flake8)\b/,
13 /^(uv run |poetry run |python3? -m )(pytest|mypy|ruff|unittest)\b/,
14 /^(go|cargo) (test|vet|build|check|clippy)\b/,
15 /^make (test|check|lint|build)\b/,
16 /^(terraform|tofu) (validate|plan)\b/,
17 /^claude plugin (validate|test)\b/,
18]
19
20/**
21 * The command as a check's name, or null when it is not one. A command joined to another by a pipe,
22 * `&&`, `||` or `;` is not one either: its exit status is then not the check's own. Moving to a
23 * folder first is the one exception, since the status is still the check's.
24 */
25export function checkOf(command: string) {
26 const line = command.trim().replace(/\s+/g, ' ')
27 const inPlace = line.replace(/^(cd \S+ ?(&&|;) ?)+/, '')
28 if (/[|;]|&&/.test(inPlace.replace(/2>&1/g, ''))) return null
29 const run = inPlace.replace(/^(cd \S+ )?/, '').replace(/^([A-Z_]+=\S+ )+/, '')
30
31 return CHECKS.some(check => check.test(run)) ? line : null
32}
33
34/** A failed check as a new item: what failed, the end of what it printed, and how it closes. */
35export function failedCheck(command: string, output: string): Fields {
36 const lines = output
37 .split('\n')
38 .map(line => line.trim())
39 .filter(Boolean)
40 const name = command.replace(/^(npx|pnpm dlx|bunx) (-p \S+ )?/, '').slice(0, 60)
41
42 return {
43 kind: 'check',
44 title: `Failed: ${name}`,
45 from: `The command itself: ${command} ended with an error.`,
46 why: lines.slice(-4).join(' ').slice(0, 400) || 'It printed nothing.',
47 close: 'A fix, and a run of the same check that passes.',
48 command,
49 key: command,
50 selfCloses: 'When the same check next runs and passes.',
51 }
52}
53
54/**
55 * The questions of a dialog the person left, each as a new item with its own answers. `reply` is
56 * what the dialog handed back to Claude. Where it names the questions, the ones marked as having
57 * no answer are the ones left; a dialog refused with no such list left all of them. A dialog that
58 * was answered gives none.
59 */
60export function leftQuestions(asked: Asked[], reply: string, isRefused: boolean, dialog: string, time: string): Fields[] {
61 if (!isRefused) return []
62 const isListed = reply.includes('Questions asked:')
63 const left = asked.filter(one => {
64 const at = reply.indexOf(`"${one.question}"`)
65
66 return !isListed || (at >= 0 && reply.slice(at + one.question.length + 2, at + one.question.length + 40).includes('(No answer provided)'))
67 })
68
69 return left.map(one => {
70 const options = (one.options ?? []).map(option => option.label.replace(/\s*\(Recommended\)\s*$/i, ''))
71 const recommended = (one.options ?? []).flatMap((option, at) => (/\(Recommended\)\s*$/i.test(option.label) ? [at] : []))
72 const described = (one.options ?? []).filter(option => option.description).map(option => `${option.label.replace(/\s*\(Recommended\)\s*$/i, '')}: ${option.description}`)
73
74 return {
75 kind: 'question' as const,
76 title: one.question,
77 from: `A question dialog of Claude's at ${time} with ${asked.length} ${asked.length === 1 ? 'question' : 'questions'}, which was left before this one was answered.`,
78 why: described.join(' ') || 'Claude asked, and the work may be waiting on the answer.',
79 close: one.multiSelect ? 'Tick every answer that applies.' : 'Pick one of the answers.',
80 dialog,
81 ...(options.length > 0 ? { options } : {}),
82 ...(one.multiSelect ? { isMulti: true as const } : {}),
83 ...(recommended.length > 0 ? { recommended } : {}),
84 }
85 })
86}
87hooks/tool.ts 129 lines1// The one tool Claude has on the inbox: get the items the user sent, list what is open, close an item, record a finding.
2// Nothing here touches the engine: a call is answered with the events it causes and the text Claude reads.
3import type { Event, Item, Ledger } from '../types'
4import { fullOf, labelOf } from './capture'
5import { isDropped, isListed, opened, wordsOf } from './ledger'
6
7/** How many items Claude may record between two prompts of the person, so that one reply cannot fill the panel. */
8export const RECORDS_PER_TURN = 3
9
10export const TOOL_NAME = 'inbox'
11export const TOOL_DESCRIPTION = [
12 "The user's inbox: a side panel that lists what this conversation has left open. It holds questions for the user, tasks only the user can do, findings, and what the user asked for that is not finished. The inbox reads every message and every reply itself, so never call this tool to find out what to do.",
13 '`get`: the user can send you an item from the panel. It arrives in their message as a quoted line with the item\'s id, as in "> Inbox i12: ...", and what they wrote under the quote is their answer to it or what they want done with it. The quote holds only the title, so before anything else call `get` with every id quoted in the message. It returns each item in full: where it came from, why it matters and what closing it takes. Act on that, and never on the title alone.',
14 '`close`: when you finish something the user asked for in this turn, close it then and not at the end, so that the user sees it settle while you work. Name it with `about`, a few of the user\'s own words for it, or with `id` when you have one. `reason` says what now shows it done, in one sentence the user can check. Close only what is fully done and shown: never what is partly done, planned or not yet run. `how: dropped` is for what no longer applies. Always send a close in the same message as your next tool call, never as a call on its own: when no other tool call is left to make, leave the close out, since the inbox closes what your reply shows done.',
15 '`list` returns the open items with their ids. Use it only when the user refers to the inbox or to its items, as in "resolve the findings in our inbox": list them, work through the ones meant, and close each one as it is resolved.',
16 '`record` adds a finding the user would want to see later and that this turn does not resolve: something wrong or risky you noticed (`issue`), something that could be better (`opportunity`), or a claim you could not check by running (`unverified`). Record sparingly. Do not record what you fix in this turn, what your reply already asks the user, or anything already listed. When the user tells you to keep something for later ("put a pin in that"), record nothing: the inbox sets it aside itself.',
17].join(' ')
18export const TOOL_SCHEMA = {
19 type: 'object',
20 properties: {
21 action: { type: 'string', enum: ['get', 'list', 'close', 'record'] },
22 ids: { type: 'array', items: { type: 'string' }, description: "get: the ids quoted in the user's message." },
23 id: { type: 'string', description: 'close: the id of the item, as `list` or an earlier close gave it.' },
24 about: { type: 'string', description: 'close: a few words naming what the user asked for, when you have no id.' },
25 how: { type: 'string', enum: ['done', 'dropped'], description: 'close: `done` when it was resolved, `dropped` when it no longer applies. Default `done`.' },
26 reason: { type: 'string', description: 'close: what happened, in one sentence the user can check.' },
27 kind: { type: 'string', enum: ['issue', 'opportunity', 'unverified'], description: 'record: what the finding is.' },
28 title: { type: 'string', description: 'record: the finding as one plain sentence the user gets at a glance with the conversation gone from their mind: whole words, no compressed headline, up to about 16 words. The details go in `why`.' },
29 why: { type: 'string', description: 'record: why it matters, with the names and paths that make it checkable.' },
30 close: { type: 'string', description: 'record: what resolving it would take.' },
31 file: { type: 'string', description: 'record: the absolute path of the file it is about, if one.' },
32 },
33 required: ['action'],
34}
35
36type Call = { action?: unknown; ids?: unknown; id?: unknown; about?: unknown; how?: unknown; reason?: unknown; kind?: unknown; title?: unknown; why?: unknown; close?: unknown; file?: unknown }
37
38const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
39
40/**
41 * The open item a few words point at: the one whose title and done-criterion hold the most of
42 * them, when that is at least half and more than any other item holds. Words that fit two items
43 * alike point at neither, since closing the wrong one is worse than asking for the id.
44 */
45function meant(ledger: Ledger, about: string) {
46 const wanted = [...new Set(wordsOf(about))]
47 const ranked = ledger.items
48 .filter(item => item.status === 'open')
49 .map(item => ({ item, near: wanted.filter(word => wordsOf(`${item.title} ${item.close}`).includes(word)).length / Math.max(1, wanted.length) }))
50 .sort((one, other) => other.near - one.near)
51 const [best, next] = ranked
52
53 return best && best.near >= 0.5 && (next === undefined || best.near > next.near) ? best.item : undefined
54}
55
56/** What the user asked for that is still open, with ids, for Claude to close by id from here on. */
57function stillAsked(ledger: Ledger, but = '') {
58 const asks = ledger.items.filter(item => item.status === 'open' && item.kind === 'ask' && item.id !== but)
59
60 return asks.length === 0 ? '' : ` Still open from the user: ${asks.map(item => `${item.id} "${item.title}" (done when: ${item.close})`).join('; ')}.`
61}
62
63function listed(item: Item) {
64 const answers = item.options ? ` Answers: ${item.options.join(' | ')}.` : ''
65
66 return `${item.id} [${labelOf(item)}] ${item.title}\n Why: ${item.why}\n To close: ${item.close}${answers}`
67}
68
69/**
70 * What a call of the tool causes and what Claude reads back. `recorded` is how many items Claude has
71 * recorded since the person's last prompt. `turn` is the engine's turn the call is made in and how
72 * many of its messages have reached Claude, both of which a close keeps, and `unheard` the items the
73 * person answered in a message that has not reached Claude yet.
74 */
75export function answerTool(ledger: Ledger, call: Call, at: number, recorded: number, turn?: { id: string; heard: number; unheard?: string[] }): { events: Event[]; text: string } {
76 if (call.action === 'get') {
77 const ids = (Array.isArray(call.ids) ? call.ids : [call.id]).map(text).filter(Boolean)
78 if (ids.length === 0) return { events: [], text: 'A get needs `ids`: the ids quoted in the user\'s message, as in "> Inbox i12: ...".' }
79 const found = ids.flatMap(id => ledger.items.find(one => one.id === id) ?? [])
80 const unknown = ids.filter(id => !found.some(item => item.id === id))
81
82 return { events: [], text: [JSON.stringify(found.map(item => fullOf(ledger, item)), null, 1), ...(unknown.length > 0 ? [`No item has the id ${unknown.join(', ')}.`] : [])].join('\n\n') }
83 }
84 if (call.action === 'list') {
85 const open = ledger.items.filter(item => item.status === 'open')
86 const later = ledger.items.filter(item => item.status === 'later').length
87 const aside = later > 0 ? `\n\n${later} more ${later === 1 ? 'is' : 'are'} set aside and not listed.` : ''
88
89 return { events: [], text: open.length === 0 ? `The inbox has no open items.${aside}` : `${open.map(listed).join('\n')}${aside}` }
90 }
91 if (call.action === 'close') {
92 const id = text(call.id)
93 const item = id === '' ? meant(ledger, text(call.about)) : ledger.items.find(one => one.id === id)
94 if (!item && id !== '') return { events: [], text: `No item has the id "${id}".${stillAsked(ledger) || ' Call list for the ids.'}` }
95 if (!item) return { events: [], text: `No open item clearly matches "${text(call.about)}", so nothing was closed.${stillAsked(ledger) || ' Nothing the user asked for is open.'}` }
96 if (item.status !== 'open') return { events: [], text: `${item.id} is not open: it is ${item.status === 'later' ? 'set aside' : item.status}.` }
97 if (text(call.reason) === '') return { events: [], text: 'A close needs a reason: what happened, in one sentence the user can check.' }
98 // Closing it would take the person's newer word off the item before anyone has read it.
99 if (turn?.unheard?.includes(item.id)) return { events: [], text: `${item.id} was not closed: the user has just sent something about it that has not reached you yet. Leave it open, and act on their message when it arrives.` }
100
101 return {
102 events: [{ type: 'close', id: item.id, how: call.how === 'dropped' ? 'dropped' : 'done', by: 'claude', reason: `Claude closed it: ${text(call.reason)}`, ...(turn ? { in: turn.id, heard: turn.heard } : {}) }],
103 text: `Closed ${item.id}: ${item.title}.${stillAsked(ledger, item.id)}`,
104 }
105 }
106 if (call.action === 'record') {
107 const kind = call.kind === 'opportunity' || call.kind === 'unverified' ? call.kind : 'issue'
108 const title = text(call.title)
109 if (title === '' || text(call.why) === '') return { events: [], text: 'A record needs a title and a why.' }
110 if (isDropped(ledger, title)) return { events: [], text: 'Not recorded: the user dropped this before. Do not record it again.' }
111 // What the person asked for in this turn can be the record itself, and that ask is not a listing of it.
112 const others = { ...ledger, items: ledger.items.filter(item => item.kind !== 'ask' || item.turn !== ledger.turn) }
113 if (isListed(others, title)) return { events: [], text: 'Not recorded: the inbox already lists this.' }
114 if (recorded >= RECORDS_PER_TURN) return { events: [], text: `Not recorded: ${RECORDS_PER_TURN} items were recorded since the user's last message. Tell the user about it in your reply.` }
115 const event = opened(ledger, at, 'claude', {
116 kind,
117 title,
118 from: 'Claude recorded it while working.',
119 why: text(call.why),
120 close: text(call.close) || 'Say whether to act on it, or drop it.',
121 ...(text(call.file) ? { file: text(call.file) } : {}),
122 })
123
124 return { events: [event], text: `Recorded as ${event.item.id}.` }
125 }
126
127 return { events: [], text: 'The action is one of get, list, close or record.' }
128}
129hooks/workers.ts 666 lines1// What the model passes are asked, and how their answers are read. One pass reads each prompt of
2// the person's for what it asks and what it answers. The other reads each exchange for what got
3// done, what was skipped and what it leaves open. Nothing here touches the engine.
4import type { Event, Item, Ledger } from '../types'
5import { labelOf } from './capture'
6import { apply, goalOpened, isDropped, isListed, opened, progressOf } from './ledger'
7
8/**
9 * The model of both passes, and the lighter one a person can choose in its place. In a replay of 66
10 * real exchanges, the lighter model missed asks put as a question or a complaint, found half as many
11 * answers to open questions, and took longer.
12 */
13export const READ_MODEL = 'sonnet'
14export const LIGHT_MODEL = 'haiku'
15/** The most new items one reply may open, and the most entries of Claude's todo list one read is given. */
16export const OPENS_PER_REPLY = 3
17export const TODOS_PER_READ = 20
18
19/**
20 * How much of a turn the passes are given, which is what a read costs. Counts are of tool calls,
21 * lines and subagents, and the rest are characters.
22 */
23export type Depth = {
24 model: string
25 /** The most tool calls of a turn, and of how each ended: its last lines after its first, and the characters of that. */
26 calls: number
27 tail: number
28 ending: number
29 /** Of an exchange: the reply, the message it answers, and what Claude wrote before the reply. */
30 reply: number
31 message: number
32 said: number
33 /** Of a prompt as it is read for what it asks: the prompt, and the reply it answers. */
34 prompt: number
35 lastReply: number
36 /** The most subagents, and of each: its tool calls, its report, the task it was given, and what it wrote along the way. */
37 agents: number
38 agentCalls: number
39 report: number
40 task: number
41 agentSaid: number
42 /** The most items one reply may put to a fork of the conversation, which reads all of it. */
43 forks: number
44 /** Whether a read is also told which files were read and searched, what each edit changed, and how every other tool ended. */
45 isFull: boolean
46 /** Whether every subagent's report is audited against its own tool calls, with a model call of its own. */
47 isAuditingAgents: boolean
48}
49const BALANCED: Depth = { model: READ_MODEL, calls: 80, tail: 3, ending: 500, reply: 9000, message: 4000, said: 6000, prompt: 6000, lastReply: 5000, agents: 12, agentCalls: 10, report: 2000, task: 0, agentSaid: 0, forks: 2, isFull: false, isAuditingAgents: false }
50const THOROUGH: Depth = { model: READ_MODEL, calls: 400, tail: 12, ending: 2000, reply: 40000, message: 20000, said: 30000, prompt: 30000, lastReply: 20000, agents: 60, agentCalls: 60, report: 12000, task: 2000, agentSaid: 4000, forks: 6, isFull: true, isAuditingAgents: false }
51/**
52 * The depths a person can choose. `Light` reads what `Balanced` reads, with the lighter model, and
53 * `Exhaustive` reads what `Thorough` reads and audits each subagent as the main conversation is audited.
54 */
55export const DEPTHS: Record<string, Depth> = {
56 Light: { ...BALANCED, model: LIGHT_MODEL },
57 Balanced: BALANCED,
58 Thorough: THOROUGH,
59 Exhaustive: { ...THOROUGH, isAuditingAgents: true },
60}
61/** The most items the audit of one subagent may open, and the most that all of them may open between two prompts of the person's. */
62export const OPENS_PER_AGENT = 2
63export const AGENT_OPENS_PER_PROMPT = 6
64/** The depth the passes read at: set from the person's setting when the mod loads. */
65let depth = BALANCED
66
67export function readAt(name: unknown) {
68 depth = (typeof name === 'string' ? DEPTHS[name] : undefined) ?? BALANCED
69
70 return depth
71}
72
73type Use = { tool: string; input?: Record<string, unknown>; text?: string; result?: unknown }
74export type Message = { role: string; text?: string; toolUses?: Use[]; toolResults?: unknown[] }
75/** A subagent as a read is told of it: what Claude called it, its type, how it ran, and the task Claude gave it. */
76export type Subagent = { name: string; type: string; isBackground: boolean; isInWorkflow: boolean; isDone: boolean; task?: string }
77/** What a read is given of a turn besides its tool calls and its reply. */
78export type Beside = { said?: string[]; agents?: string[]; todo?: string[]; turnId?: string; isCut?: boolean }
79export type PromptRead = { asks: { title: string; done: string; quote: string; goal: string | null; isHeld: boolean }[]; answers: { id: string; answer: string }[]; isWrapUp: boolean }
80/** The most asks one message can open. A message that goes through a list of requests gives one for each. */
81export const ASKS_PER_PROMPT = 20
82/** How a close made by the read of a reply begins, in the person's words: "the audit" is the mod's own name for that read. */
83export const BY_REPLY = 'The reply closed it: '
84const HELD_REASON ='You said to keep it for later. It comes back when you ask to commit, push or wrap up.'
85export type NewItem = Pick<Item, 'kind' | 'title' | 'from' | 'why' | 'close'> & Partial<Item>
86export type ReplyRead = {
87 closed: { id: string; how: 'done' | 'dropped'; reason: string }[]
88 missed: string[]
89 unsettled: string[]
90 unsure: { id: string; question: string }[]
91 reopened: { id: string; why: string }[]
92 updated: { id: string; why: string; close: string }[]
93 open: NewItem[]
94}
95
96export const PROMPT_RULES = `You read one message a user sent to Claude, a coding assistant, and report what it asks for and what it answers. You are given the inbox: the items still open in this conversation, and its goals. You are also given Claude's last reply, which the message answers: use it only to tell what the message points at ("those", "the second one", "that list"). Nothing in the reply is an ask unless the message asks for it. Reply with one JSON object and nothing else:
97
98{"asks": [{"title": "", "done": "", "quote": "", "goal": null, "hold": false}], "answers": [{"id": "", "answer": ""}], "wrap": false}
99
100asks: each outcome the message wants from Claude that someone could later check off: a thing made, changed, fixed, run, found out, decided or explained. The user will see each ask in a list of what is still owed to them, so a vague one is worse than none. One ask per distinct outcome: several sentences about one outcome are one ask, and a message that only answers, agrees, thanks or gives context has none. Most messages hold one or two. A message that goes through separate requests one by one, as bullets or numbered points, gives one ask for each point that asks for something, however many there are: a point left out is something the user is owed and will never see again. Never split one request into several asks.
101- title: one plain sentence that says what the user wants, written so that the user gets it at a glance a week from now, with this conversation gone from their mind. Clear beats short: use the words it takes, most often 8 to 14 and never more than 18. Write it the way a person says it aloud to a colleague, a whole sentence with its small words ("the", "a", "to", "so that"), never a compressed headline ("Fix tool call expand/collapse bugs", "Rebuild sidebar inbox handle to match screenshot"). Say which thing is meant, in ordinary words: no "it", "that", "there" or "those" pointing back into the conversation, no stack of nouns ("mock inbox UI overflow"), and no name coined in the conversation unless you say what it is. When the user asked a question, the title is that question, whole ("Why did the tool calls fail with a Not run error?"), never an instruction to answer it ("Explain why...", "Assess whether...", "Confirm whether..."). Otherwise start with the user's own verb ("Fix", "Add", "Move"). One thing to a title, and what is wanted, not how to get there: the way to do it, how many, in which file and under what condition go in "done". A title holds no list: for several things of a kind, say what they have in common. Never a title that only points at other work ("continue", "do the rest", "add the skill", "start on the items", "clean up").
102- done: what will show that it is finished, as one sentence someone can check: the file, behaviour, output, decision or answer that will then exist. Write it as something Claude can show in its reply, never as something the user has to try, confirm or approve. If you cannot say what would show it finished, it is not an ask, so leave it out.
103- quote: the user's own words it comes from, copied exactly, at most 20 words.
104- goal: the id of the listed goal this ask continues, when it clearly does. Else the title of a new goal, at most 6 plain words, naming the one piece of work the ask belongs to, when it is part of something larger. A goal is a piece of work that gets finished ("Shiki colors in diffs", "A license on the repo"), never a theme that every later ask would fit ("A better inbox", "Good UX"). Else null, for a one-off: an ask that stands alone has no goal, and a goal is never one ask said again in fewer words. Asks from one message that serve one outcome share one goal. Prefer a listed goal to a new one.
105- hold: true when the user says to keep it for later and not to do it now ("put a pin in that", "that can sit in the backlog", "not now"). It is then listed as set aside, and the reply is not expected to do it. When what the user holds is a list from Claude's last reply ("put a pin in those for now", said of a section of the reply), copy the list: every entry of it, sub-entries too, becomes an ask of its own, with a title and a "done" taken from the reply, hold true, and one shared goal named for the list ("Before commit and push"). Never fold such a list into one ask about the list.
106Not asks: how the reply should read (its length, format, tone, or what it should ask back), limits on the work ("do not run the tests", "nothing else"), and a step given only as the way to carry out another ask.
107Not asks either: words the user quotes from an earlier message, a document or a tool's output, which are what they are talking about and not what they ask now; a go-ahead for work Claude proposed or already has listed ("yes", "go on", "start on those"), which adds no outcome of its own; and an open-ended direction with no end to check ("polish it", "keep iterating", "make it better").
108Do not list as an ask anything already open in the inbox: an ask repeated from an earlier message, or a reference to an open item.
109
110answers: each open item this message clearly answers, decides, or reports as done or not wanted, whether or not it names the item. A question is answered by a message that states the choice in passing, and a task by a message that shows the user did it. id is the item's id, answer is what the user said about it, in their words. Leave out anything you are not sure the message settles.
111
112wrap: true when the message asks to commit, push, open a pull request, or wrap up the work.`
113
114/** How the audit is told that Claude closed an item as not wanted any more, and not as done. */
115const AS_DROPPED = '[dropped as no longer wanted]'
116
117export const REPLY_RULES = `You audit one exchange between a user and Claude, a coding assistant: the user's message, what Claude did with its tools, and Claude's reply. Some turns start from a notice of the system's instead, such as a background task ending: then the notice is what the reply answers, and what it reports is known, not a claim to verify. You keep the user's inbox, which lists what the conversation has left open. Reply with one JSON object and nothing else:
118
119{"closed": [{"id": "", "how": "done", "reason": ""}], "missed": [], "unsettled": [], "unsure": [{"id": "", "question": ""}], "reopened": [{"id": "", "why": ""}], "updated": [{"id": "", "why": "", "close": ""}], "open": [{"kind": "", "title": "", "from": "", "why": "", "close": "", "goal": null}]}
120
121What you are given of the turn: WHAT CLAUDE DID lists its tool calls, each with how it ended. A line there that starts "Question dialog" holds what the user chose in a dialog Claude put up: those choices are the user's own, as a message of theirs is, and can answer an open question. WHAT CLAUDE SAID WHILE WORKING, when present, is what Claude wrote to the user earlier in the same turn, before its reply: the user read it, so what it reports, asks or leaves open counts as the reply's own does. WHAT ITS SUBAGENTS DID, when present, lists the helpers Claude handed work to, each with its tool calls and, once it has ended, its report: that work is Claude's, so it shows what was run and changed, and a problem a report names is a problem whether or not the reply repeats it. CLAUDE'S OWN TODO LIST, when present, is its working list as the turn ended: an entry not finished there is a sign that the ask it belongs to is not done, unless the tool calls or the reply show that work done. Never open an item for a todo entry alone. When more than one message of the user's reached Claude in the turn, they are numbered in the order they arrived, and where a later one takes back or changes what an earlier one said, the later one stands.
122
123An inbox that only grows is useless, so closing what this exchange settled matters as much as opening what it leaves. Go through every open item, not only the marked ones.
124
125closed: each open item this exchange settled.
126- how "done": the work was carried out, the question was answered, or the user's message shows they did the task, and the tool results, the reply or the message show it. Saying it will be done, planning it, or mentioning it does not count. Part of it done does not count. An ask is done once Claude has carried it out and the exchange shows that: it does not wait for the user to try it, reload, look at it or approve it. When that is all that is left, close the ask as done, and put what the user has not yet seen under open or updated as an unverified item.
127- how "dropped": it no longer applies. The user said it is not wanted. The user or the reply chose another way, so the item is moot. A newer question or item in this reply replaces it. The reply declines an ask and says why, which the user has now read.
128- reason: the evidence in one sentence the user can check, with the commit, file, command or words that show it. For a declined ask, start it with "Claude declined:".
129
130missed: ids of asks marked [asked now] that the reply shows no sign of: it did not do them, did not start them, and said nothing about them. Not finished is not missed. An ask the reply did part of, is still working on, is waiting on something for, explains, or asks the user about stays open and is not listed here.
131
132unsettled: ids of items marked [sent] that the reply did not act on. A [sent] item the reply acted on goes under closed.
133
134unsure: an open item you cannot judge from this exchange because it turns on earlier parts of the conversation, with the one yes-or-no question whose answer would settle it. Use it rarely.
135
136reopened: Claude may close an item itself while it works, on its own word. Those are listed under CLOSED BY CLAUDE THIS TURN, each with what was to show it done and the reason Claude gave. List here each one that the tool results and the reply do not bear out: the work is not there, it failed, or it was only planned. Where the user's messages are numbered, a close says after which of them it was made: list it too when a message that came after it takes it back, by saying to keep the item open, by changing the answer the close rests on, or by asking for something else in its place. A message that came before the close takes nothing back. One marked ${AS_DROPPED} was closed by Claude as not wanted any more and not as done: list it when nothing the user wrote, and nothing the reply shows, makes it unwanted or moot. "why" says what is missing or what the user said, written for the user. Leave out every close the exchange supports, which is most of them.
137
138updated: each open item this exchange says more about without settling it. Give "why", the whole account of it as it now stands, written for the user as an item's "why" is (it replaces the old one, so keep what still holds), and for an item that is not an ask "close", what would settle it now. An ask keeps the "To close" it has, which is what the user asked for. An ask the reply worked on and did not finish belongs here, with what was found and what of the work itself is still missing. So does an open issue or unverified item that the reply repeats, narrows or adds to. Decide this before you open anything: whatever is about the same work as an open item goes here, whatever its kind, and is never opened as a second item. An ask the reply declines, or shows cannot be done as asked, is not updated: close it as dropped. Neither is an ask whose "To close" the exchange now meets: close it as done.
139
140open: what the reply newly leaves that the user would be worse off not seeing again: something they must answer, something they must do, or something that is wrong. At most ${OPENS_PER_REPLY}. Each has a kind:
141- "question": a decision the reply asks the user to make and that work waits on. Give "options", the answers the reply offered or the plain ones (2 to 4, at most 6 words each, each saying what choosing it does: "Yes, forks keep the inbox", never a bare "Yes"), "recommended", the places in options of the ones the reply recommended, and "multi": true when several can be chosen. A go-ahead ("Shall I commit?") is a question with options like "Yes, commit it" and "Not yet". One question holds one decision: two decisions are two questions, never one question whose answers are combinations of both. When it replaces an open question on the same matter, close that one as dropped.
142- "task": something only the user can do: run a command in their own shell, act on a web page, give a value only they have, or judge something by eye. Give what applies: "command", the exact command; "link", the page's URL; "draft", text written for them to send; "value", the name of the value to type, like "Ticket"; "look": true when their eyes must judge it. A reply that tells the user to reload plugins or to restart Claude Code leaves one task, with "closesOn": "reload" or "closesOn": "restart", whatever else it says to look at afterward.
143- "issue": a defect or a risk that remains after this reply and that someone could act on. A limit of the platform that the reply explains and works within is not an issue.
144- "opportunity": an improvement the reply raises and does not make, where the user showed interest.
145- "unverified": something the reply presents as working or fixed without having run it, where the user is likely to rely on it. At most one for a reply: when the reply lists several things it did not run, name them in one item. When an unverified item about the same work is already open, add to that one under updated and open none.
146Write every item for the user, who directs this work and will read the item several messages from now, when the details of this exchange have left their mind. They must be able to decide or act from the item alone. So say the thing itself, in plain words, and never describe the conversation ("the reply asks whether...", "work waits on the answer"). A name the reply coined ("the log marker", "item 4", "the carry-over") means nothing later: say what it is.
147For each:
148- "title": one plain sentence that the user gets at a glance a week from now, with this exchange gone from their mind. Clear beats short: use the words it takes, most often 8 to 14 and never more than 18. Write it the way you would say it aloud to the user, a whole sentence with its small words ("the", "a", "to", "so that"), never a compressed headline ("Click-shading fix relies on undocumented colorless background value"). Say which thing is meant, in ordinary words: no "it" or "that" pointing back into the conversation, no stack of nouns, no hyphenated coinage ("mid-turn closing"), and no name coined in the conversation unless you say what it is. A question is the question itself, asked of the user and ending in "?" ("Should the ten new themes switch to the dark base, so that their diff colors show?"). A task says what the user has to do and what for ("Restart Claude Code so that the new theme colors load"). An issue says what is wrong and where ("Old commits on GitHub still show your work email"). An unverified item says what was not tried ("The copy button was never clicked, so copying is untested"). An opportunity says what could be done. One thing to a title, and no list in it: when an item covers several things, the title says what they have in common or names the one that matters most, and "why" lists them. Counts, paths, causes and what Claude tried go in "why".
149- "from": where it comes from, written as Claude in the first person with a short quote, like: My reply after the force push: "GitHub still serves the old commits".
150- "why": three to five sentences. What this is about and how it came up. What is at stake: for a question, what each answer leads to and what it costs; for a task, what it unblocks and what happens if it waits; for a finding, what goes wrong and when. What Claude recommends, if the reply says. Give the exact names, paths and numbers.
151- "close": what the user has to do or say, concretely.
152- "goal": the id of the listed goal it belongs to (as in "g12"), when it is plainly about that piece of work: a question on how to do it, a task that unblocks it, a finding about what was built for it. Else null. Never a goal's title, and never a goal that is not listed.
153- "file": the absolute path it is about, if one.
154Do not open: what this exchange already resolved; what the reply says it will do next; a closing pleasantry or an open-ended offer of more help; anything an open item is already about, even in other words or as another kind, which goes under updated; anything under DROPPED BY THE USER; anything under SET ASIDE BY THE USER, which the user is keeping for later and does not want raised again, one by one or as a list.`
155
156const cut = (text: string, room: number) => (text.length <= room ? text : `${text.slice(0, Math.floor(room * 0.6))}\n[...]\n${text.slice(-Math.floor(room * 0.4))}`)
157const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
158const flat = (lines: string) => lines.split(/\s*\n\s*/).filter(Boolean).join(' ')
159const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
160
161/** The open items as lines a model reads, each with its id, and with what marks it for this exchange. */
162export function inboxLines(ledger: Ledger, asked: string[] = []) {
163 const open = ledger.items.filter(item => item.status === 'open')
164 const lines = open.map(item => {
165 const marks = [asked.includes(item.id) ? ' [asked now]' : '', item.sent ? ` [sent: ${item.sent.split('\n').join(' ')}]` : ''].join('')
166 const answers = item.options ? ` Answers: ${item.options.join(' | ')}.` : ''
167
168 return `${item.id} [${labelOf(item)}]${marks} ${item.title}. To close: ${item.close}${answers}`
169 })
170 const goals = ledger.goals.map(goal => {
171 const { done, of } = progressOf(ledger, goal)
172
173 return `${goal.id} ${goal.title} (${done} of ${of} asks done)`
174 })
175
176 return [`OPEN ITEMS\n${lines.join('\n') || 'none'}`, `GOALS\n${goals.join('\n') || 'none'}`].join('\n\n')
177}
178
179/** The last thing Claude wrote to the person, which their next message answers. */
180export function lastReplyOf(messages: Message[]) {
181 return text(messages.findLast(message => message.role === 'assistant' && text(message.text) !== '')?.text)
182}
183
184/** `lastReply` is what the message answers, given so that "those" and "the second one" can be told. */
185export function promptAsk(ledger: Ledger, message: string, lastReply = '') {
186 return `${inboxLines(ledger)}\n\nCLAUDE'S LAST REPLY\n${cut(lastReply, depth.lastReply) || 'None.'}\n\nMESSAGE\n${cut(message, depth.prompt)}`
187}
188
189/** How a tool call ended: its first line and its last few, since a run's verdict is at the end of what it prints. */
190function endOf(output: string) {
191 const lines = output
192 .split('\n')
193 .map(line => line.trim())
194 .filter(Boolean)
195 const kept = lines.length <= depth.tail + 1 ? lines : [lines[0] ?? '', '[...]', ...lines.slice(-depth.tail)]
196
197 return kept.join(' ').slice(0, depth.ending)
198}
199
200/** What an edit changed or a new file starts with, for a read that is told as much. */
201function changeOf(input: Record<string, unknown>) {
202 if (typeof input.old_string === 'string') return ` (replaced ${JSON.stringify(flat(text(input.old_string)).slice(0, 300))} with ${JSON.stringify(flat(text(input.new_string)).slice(0, 300))})`
203
204 return typeof input.content === 'string' ? ` (${input.content.split('\n').length} lines, starting ${JSON.stringify(flat(input.content).slice(0, 300))})` : ''
205}
206
207/** A file's path from the project's folder when the file is in it: what an item says of a file is read by the person, who knows it by that path. */
208function fromProject(path: string, cwd: string) {
209 return cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
210}
211
212/**
213 * One tool call as a line: what was run or changed, and how it ended. Reading and searching are
214 * left out, and so is the inbox tool but for a finding it was given to record, which can be the
215 * very thing the person asked for. A read at full depth is told more: which files were read and
216 * searched, what each edit changed, a plan whole, and how every other tool ended.
217 */
218export function toolLine(use: Use, cwd = '') {
219 const input = use.input ?? {}
220 const ended = endOf(text(use.text))
221 if (use.tool === 'Bash') return `Bash: ${text(input.command).split('\n').join(' ').slice(0, depth.isFull ? 2000 : 600)} => ${ended || 'no output'}`
222 if (use.tool === 'Edit' || use.tool === 'Write' || use.tool === 'NotebookEdit') return `${use.tool}: ${fromProject(text(input.file_path) || text(input.notebook_path), cwd)}${depth.isFull ? changeOf(input) : ''}`
223 if (depth.isFull && ['Read', 'Grep', 'Glob'].includes(use.tool)) return `${use.tool}: ${[text(input.pattern), fromProject(text(input.file_path) || text(input.path), cwd)].filter(Boolean).join(' in ')}`
224 if (depth.isFull && use.tool === 'ExitPlanMode') return `Plan: ${cut(text(input.plan), 4000)}`
225 // One started in the background answers with the engine's notes to Claude, which say nothing of the work.
226 if (use.tool === 'Agent' || use.tool === 'Task') return `Agent: ${text(input.description)} => ${ended.startsWith('Async agent launched') ? 'started in the background' : ended}`
227 // What the person chose in a dialog is theirs, and is told whole.
228 if (use.tool === 'AskUserQuestion') return `Question dialog: ${flat(text(use.text)).slice(0, 700)}`
229 if (use.tool === 'Workflow') return `Workflow: ${text(input.name) || /name:\s*['"]([^'"]+)['"]/.exec(text(input.script))?.[1] || text(input.scriptPath) || 'a script'} => ${ended.startsWith('Workflow launched') ? 'started in the background' : ended}`
230 if (use.tool.startsWith('mcp__inbox__')) return input.action === 'record' ? `Inbox record: ${text(input.title)} => ${ended}` : null
231 // A subagent's report is told as its report, and Claude's todo list as the list.
232 if (['Read', 'Grep', 'Glob', 'ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'SubagentHandback'].includes(use.tool)) return null
233
234 return depth.isFull ? `${use.tool}: ${JSON.stringify(input).slice(0, 400)} => ${ended || 'no output'}` : `${use.tool}: ${JSON.stringify(input).slice(0, 160)}`
235}
236
237/**
238 * What Claude wrote to the person in this turn before its reply, in order: each message of its own
239 * that has text, since the person's last message, but for the reply itself.
240 */
241export function saidOf(messages: Message[], reply: string) {
242 const from = messages.findLastIndex(message => message.role === 'user' && text(message.text) !== '' && list(message.toolResults).length === 0)
243 const said = messages
244 .slice(from + 1)
245 .filter(message => message.role === 'assistant' && text(message.text) !== '')
246 .map(message => text(message.text))
247
248 return said.at(-1) === reply.trim() ? said.slice(0, -1) : said
249}
250
251/**
252 * Claude's own todo list as the conversation last left it, one line an entry, and nothing when all
253 * of it is finished. One tool writes the list whole each time, and another keeps it entry by entry.
254 */
255export function todoOf(messages: Message[]) {
256 const uses = messages.flatMap(message => message.toolUses ?? [])
257 const entries = new Map<string, { title: string; status: string }>()
258 list(uses.findLast(use => use.tool === 'TodoWrite')?.input?.todos).forEach((todo, at) => {
259 const { content, status } = todo as { content?: unknown; status?: unknown }
260 entries.set(`w${at}`, { title: text(content), status: text(status) })
261 })
262 for (const use of uses) {
263 const input = use.input ?? {}
264 if (use.tool === 'TaskCreate') {
265 const id = text((use.result as { task?: { id?: unknown } } | undefined)?.task?.id)
266 if (id !== '') entries.set(id, { title: text(input.subject), status: 'pending' })
267 }
268 const entry = use.tool === 'TaskUpdate' ? entries.get(text(input.taskId)) : undefined
269 if (entry && input.status === 'deleted') entries.delete(text(input.taskId))
270 else if (entry) Object.assign(entry, { title: text(input.subject) || entry.title, status: text(input.status) || entry.status })
271 }
272 const kept = [...entries.values()].filter(entry => entry.title !== '')
273 if (kept.every(entry => entry.status === 'completed')) return []
274
275 return kept.slice(-TODOS_PER_READ).map(entry => `[${entry.status === 'completed' ? 'done' : entry.status === 'in_progress' ? 'in progress' : 'not started'}] ${entry.title}`)
276}
277
278/**
279 * A subagent as lines of a read: who it is, the latest of its tool calls, and its report once it has
280 * ended. A deeper read is also told the task it was given and what it wrote along the way.
281 */
282export function agentPart(agent: Subagent, lines: string[], report: string, said: string[] = []) {
283 const how = [agent.type, agent.isInWorkflow ? 'in a workflow' : '', agent.isBackground ? 'in the background' : '', agent.isDone ? 'ended' : 'still running'].filter(Boolean).join(', ')
284
285 return [
286 `"${agent.name}" (${how})`,
287 ...(depth.task > 0 && text(agent.task) !== '' ? [` Its task: ${cut(flat(text(agent.task)), depth.task)}`] : []),
288 ...newest(lines, depth.agentCalls).map(line => ` ${line}`),
289 ...(depth.agentSaid > 0 && said.length > 0 ? [` It wrote along the way: ${cut(said.map(flat).join(' / '), depth.agentSaid)}`] : []),
290 ...(report.trim() !== '' ? [` Its report: ${cut(report.trim(), depth.report)}`] : []),
291 ].join('\n')
292}
293
294export const AGENT_RULES = `You audit the work of one helper: a subagent that Claude, a coding assistant, handed a task to. You are given the task, the helper's tool calls with how each ended, what it wrote along the way, and its report. Claude relies on that report, and the user who directs the work never reads it. You also keep the user's inbox, which lists what the conversation has left open. Reply with one JSON object and nothing else:
295
296{"open": [{"kind": "", "title": "", "from": "", "why": "", "close": "", "file": ""}]}
297
298open: what the user would be worse off not knowing about this helper's work, at most ${OPENS_PER_AGENT}, and most often none. A helper that did its task and reported it truly leaves nothing. Each has a kind:
299- "unverified": the report presents something as working, fixed, passing or true, and the tool calls do not show that it was run or checked. Name what was claimed and what was not run. At most one: several such claims go in one item.
300- "issue": something the tool calls show that the report hides or gets wrong, and that someone could act on: a check that failed while the report says it passed or leaves it out, an error it worked around without saying so, a change it made that its task did not ask for and its report does not mention, or a part of its task that it skipped while its report reads as if all of it was done.
301Do not open: what the tool calls bear out; a problem the report states plainly, which Claude has read; a limit the helper explains and works within; anything an open item is already about, even in other words; a judgement of how well the report is written.
302Write every item for the user, who will read it several messages from now with none of this in mind, and who must be able to act from the item alone. Say the thing itself in plain words, and name the helper by what it was doing ("the subagent that ran the tests"), never by an id.
303- "title": one plain sentence that the user gets at a glance, most often 8 to 14 words and never more than 18: a whole sentence with its small words, never a compressed headline. An issue says what is wrong and where. An unverified item says what was not tried.
304- "from": where it comes from, written as Claude in the first person with a short quote, like: A subagent I started to run the tests reported: "all tests pass".
305- "why": three to five sentences. What this is about, what is at stake, and the exact names, paths, commands and numbers.
306- "close": what would settle it, concretely.
307- "file": the absolute path it is about, if one.`
308
309/** What the audit of one subagent is asked: the inbox, then the subagent's task, its tool calls, what it wrote and its report. */
310export function agentAsk(ledger: Ledger, agent: Subagent, lines: string[], said: string[], report: string) {
311 return [
312 inboxLines(ledger),
313 `THE HELPER\n"${agent.name}" (${[agent.type, agent.isInWorkflow ? 'in a workflow' : ''].filter(Boolean).join(', ')})`,
314 `THE TASK CLAUDE GAVE IT\n${cut(text(agent.task), depth.message) || 'Not kept.'}`,
315 `WHAT IT DID\n${newest(lines).join('\n') || 'It called no tools that change anything.'}`,
316 ...(said.length > 0 ? [`WHAT IT WROTE ALONG THE WAY\n${cut(said.join('\n\n'), depth.said)}`] : []),
317 `ITS REPORT\n${cut(report, depth.reply) || 'It gave none.'}`,
318 ].join('\n\n')
319}
320
321/** A tool call as a hook on it is handed it, with how it ended: the call's own fields, without the ones the engine adds to every call. */
322export function useOf(call: Record<string, unknown>, output: string): Use {
323 const input = Object.fromEntries(Object.entries(call).filter(([field]) => !['tool', 'tool_use_id', 'consent', 'agentId', 'requestMeta'].includes(field)))
324
325 return { tool: text(call.tool), input, text: output }
326}
327
328/** The latest of a turn's tool calls when there are many, under a line that says how many were left out. */
329export function newest(lines: string[], most = depth.calls) {
330 return lines.length <= most ? lines : [`[${lines.length - most} earlier tool calls left out]`, ...lines.slice(-most)]
331}
332
333/**
334 * What Claude did since the person's last message, as the conversation holds it: one line a tool
335 * call. A compaction in the middle of a turn leaves only the calls made after it.
336 */
337export function toolLines(messages: Message[], cwd = '') {
338 const from = messages.findLastIndex(message => message.role === 'user' && text(message.text) !== '' && list(message.toolResults).length === 0)
339
340 return messages
341 .slice(from + 1)
342 .flatMap(message => message.toolUses ?? [])
343 .flatMap(use => toolLine(use, cwd) ?? [])
344}
345
346export function didLines(messages: Message[], most = depth.calls, cwd = '') {
347 return newest(toolLines(messages, cwd), most)
348}
349
350/** `isNotice` is true for a turn that a notice of the system's started, such as a background task ending, and not a message of the user's. */
351/**
352 * The items Claude closed itself in the turn being audited, as done or as dropped: the audit is the
353 * check on those. A close keeps the id of the engine's turn it was made in. One that kept none, from
354 * a build before this one, is told by the count of prompts as it stands.
355 */
356export function closedByClaude(ledger: Ledger, turnId?: string) {
357 const isClosed = (item: Item) => item.status === 'done' || item.status === 'dropped'
358
359 return ledger.items.filter(item => isClosed(item) && item.closedBy === 'claude' && (item.closedIn === undefined ? item.closedTurn === ledger.turn : item.closedIn === turnId))
360}
361
362/**
363 * `message` is what the turn answers: one text, or each message in the order it reached Claude.
364 * Several are told by number, and a close of Claude's then says after which of them it was made:
365 * the person may take back in a later message what an earlier one said.
366 */
367export function replyAsk(ledger: Ledger, asked: string[], message: string | string[], isNotice: boolean, did: string[], reply: string, beside: Beside = {}) {
368 const messages = typeof message === 'string' ? [message] : message
369 const isSeveral = messages.length > 1
370 const told = isSeveral ? messages.map((one, at) => `[${at + 1}] ${one}`).join('\n\n') : (messages[0] ?? '')
371 const heading = isNotice ? 'THE NOTICE THAT STARTED THIS TURN (from the system, not the user)' : isSeveral ? "THE USER'S MESSAGES, numbered in the order they reached Claude" : "THE USER'S MESSAGE"
372 const after = (item: Item) => (isSeveral && item.closedHeard !== undefined ? ` Closed ${item.closedHeard < 1 ? 'before message 1' : `after message ${item.closedHeard}`} of ${messages.length}.` : '')
373 const closed = closedByClaude(ledger, beside.turnId).map(item => `${item.id} ${item.title}. To close: ${item.close} ${item.reason ?? ''}${item.status === 'dropped' ? ` ${AS_DROPPED}` : ''}${after(item)}`)
374 const agents = beside.agents ?? []
375 const shown = agents.length <= depth.agents ? agents : [`[${agents.length - depth.agents} earlier subagents left out]`, ...agents.slice(-depth.agents)]
376 const said = (beside.said ?? []).join('\n\n')
377
378 return [
379 inboxLines(ledger, asked),
380 `CLOSED BY CLAUDE THIS TURN\n${closed.join('\n') || 'none'}`,
381 `DROPPED BY THE USER\n${ledger.dropped.join('\n') || 'none'}`,
382 `SET ASIDE BY THE USER\n${
383 ledger.items
384 .filter(item => item.status === 'later')
385 .map(item => item.title)
386 .join('\n') || 'none'
387 }`,
388 `${heading}\n${cut(told, depth.message) || 'None: the turn went on without a new message.'}`,
389 `WHAT CLAUDE DID\n${did.join('\n') || 'It called no tools that change anything.'}`,
390 ...(shown.length > 0 ? [`WHAT ITS SUBAGENTS DID\n${shown.join('\n')}`] : []),
391 ...(beside.todo && beside.todo.length > 0 ? [`CLAUDE'S OWN TODO LIST\n${beside.todo.join('\n')}`] : []),
392 ...(said !== '' ? [`WHAT CLAUDE SAID WHILE WORKING\n${cut(said, depth.said)}`] : []),
393 `CLAUDE'S REPLY\n${cut(reply, depth.reply)}`,
394 ].join('\n\n')
395}
396
397export function forkAsk(item: Item, question: string) {
398 return `Do not continue the work. Answer one question about this conversation so far, with one JSON object and nothing else: {"done": true, "reason": ""}. "done" is true only if it was carried out or settled, and "reason" is the evidence in one sentence the user can check.\n\nThe item: ${item.title}\nClosing it takes: ${item.close}\nThe question: ${question}`
399}
400
401/** The JSON object in a model's reply, or null when it holds none: models wrap it in prose or a code fence at times. */
402export function jsonOf(reply: string): Record<string, unknown> | null {
403 const from = reply.indexOf('{')
404 const to = reply.lastIndexOf('}')
405 if (from < 0 || to <= from) return null
406 try {
407 const value: unknown = JSON.parse(reply.slice(from, to + 1))
408
409 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null
410 } catch {
411 return null
412 }
413}
414
415/** What the prompt pass said, kept to what the ledger can hold: asks with a title, answers to items that are open. */
416export function readPrompt(ledger: Ledger, reply: string): PromptRead | null {
417 const said = jsonOf(reply)
418 if (!said) return null
419 const open = new Set(ledger.items.filter(item => item.status === 'open').map(item => item.id))
420 const asks = list(said.asks).flatMap(one => {
421 const ask = one as Record<string, unknown>
422
423 // An ask that does not say what would show it finished could never be closed, so it is not listed.
424 return text(ask.title) === '' || text(ask.done) === '' ? [] : [{ title: text(ask.title), done: text(ask.done), quote: text(ask.quote), goal: text(ask.goal) || null, isHeld: ask.hold === true }]
425 })
426 const answers = list(said.answers).flatMap(one => {
427 const answer = one as Record<string, unknown>
428
429 return open.has(text(answer.id)) && text(answer.answer) !== '' ? [{ id: text(answer.id), answer: text(answer.answer) }] : []
430 })
431
432 return { asks: asks.slice(0, ASKS_PER_PROMPT), answers, isWrapUp: said.wrap === true }
433}
434
435const KINDS = ['question', 'task', 'issue', 'opportunity', 'unverified'] as const
436
437function newItem(raw: unknown): NewItem | null {
438 const one = raw as Record<string, unknown>
439 const kind = KINDS.find(known => known === one.kind)
440 if (!kind || text(one.title) === '') return null
441 const options = list(one.options).map(text).filter(Boolean).slice(0, 4)
442 const recommended = list(one.recommended).filter((at): at is number => typeof at === 'number' && at >= 0 && at < options.length)
443
444 return {
445 kind,
446 title: text(one.title),
447 from: text(one.from) || "Claude's reply.",
448 why: text(one.why),
449 close: text(one.close) || (kind === 'question' ? 'Pick one of the answers.' : 'Say whether to act on it, or drop it.'),
450 ...(/^g\d+$/.test(text(one.goal)) ? { goal: text(one.goal) } : {}),
451 ...(kind === 'question' && options.length > 1 ? { options, ...(one.multi === true ? { isMulti: true as const } : {}), ...(recommended.length > 0 ? { recommended } : {}) } : {}),
452 ...(text(one.command) ? { command: text(one.command) } : {}),
453 ...(/^https?:\/\//.test(text(one.link)) ? { link: text(one.link) } : {}),
454 ...(text(one.file).startsWith('/') ? { file: text(one.file) } : {}),
455 ...(text(one.draft) ? { draft: text(one.draft) } : {}),
456 ...(kind === 'task' && text(one.value) ? { ask: `${text(one.value).replace(/:\s*$/, '')}: ` } : {}),
457 ...(kind === 'task' && one.look === true ? { isLook: true as const } : {}),
458 ...(kind === 'task' && one.closesOn === 'restart' ? { closesOn: 'restart' as const, selfCloses: 'When Claude Code is started again.' } : {}),
459 ...(kind === 'task' && one.closesOn === 'reload' ? { closesOn: 'reload' as const, selfCloses: 'When the plugins are reloaded, or Claude Code is started again.' } : {}),
460 }
461}
462
463/** True for a close's reason that says what happened: models at times leave a stub where the evidence should be. */
464function isEvidence(reason: string) {
465 return reason.length >= 12 && !/^(placeholder|n\/a|none|tbd|done)\.?$/i.test(reason)
466}
467
468/** What the reply audit said, kept to what the ledger can hold: closes of items that are open, and new items within the cap. */
469export function readReply(ledger: Ledger, reply: string, turnId?: string): ReplyRead | null {
470 const said = jsonOf(reply)
471 if (!said) return null
472 const open = new Set(ledger.items.filter(item => item.status === 'open').map(item => item.id))
473 const ids = (value: unknown) => list(value).map(text).filter(id => open.has(id))
474 const closed = list(said.closed).flatMap(one => {
475 const close = one as Record<string, unknown>
476
477 return open.has(text(close.id)) && isEvidence(text(close.reason)) ? [{ id: text(close.id), how: close.how === 'dropped' ? ('dropped' as const) : ('done' as const), reason: text(close.reason) }] : []
478 })
479 const fresh = list(said.open).flatMap(one => newItem(one) ?? [])
480 // One reply leaves at most one thing unverified: its other caveats belong in that one item.
481 const kept = fresh.filter((item, at) => item.kind !== 'unverified' || fresh.findIndex(other => other.kind === 'unverified') === at)
482 const settled = new Set(closed.map(close => close.id))
483 const unsure = list(said.unsure).flatMap(one => {
484 const doubt = one as Record<string, unknown>
485
486 return open.has(text(doubt.id)) && !settled.has(text(doubt.id)) && text(doubt.question) !== '' ? [{ id: text(doubt.id), question: text(doubt.question) }] : []
487 })
488 const selfClosed = new Set(closedByClaude(ledger, turnId).map(item => item.id))
489 const reopened = list(said.reopened).flatMap(one => {
490 const back = one as Record<string, unknown>
491
492 return selfClosed.has(text(back.id)) && text(back.why) !== '' ? [{ id: text(back.id), why: text(back.why) }] : []
493 })
494 const updated = list(said.updated).flatMap(one => {
495 const more = one as Record<string, unknown>
496 const isSaying = text(more.why) !== '' || text(more.close) !== ''
497
498 return open.has(text(more.id)) && !settled.has(text(more.id)) && isSaying ? [{ id: text(more.id), why: text(more.why), close: text(more.close) }] : []
499 })
500
501 return {
502 closed,
503 missed: ids(said.missed).filter(id => !settled.has(id)),
504 unsettled: ids(said.unsettled).filter(id => !settled.has(id)),
505 unsure: unsure.slice(0, depth.forks),
506 reopened,
507 updated,
508 open: kept.slice(0, OPENS_PER_REPLY),
509 }
510}
511
512/** Events made one after another, each from the ledger as the ones before it left it. */
513function builder(ledger: Ledger) {
514 const made = { events: [] as Event[], now: ledger }
515
516 return {
517 made,
518 push: <E extends Event>(event: E) => {
519 made.events.push(event)
520 made.now = apply(made.now, event)
521
522 return event
523 },
524 }
525}
526
527/**
528 * What the prompt pass found, as events: each new ask under its goal, each answer marked on its
529 * item for the reply's audit to judge, and what was set aside brought back when the person wraps up.
530 * `asked` names the asks opened, which the reply's audit checks for ones the reply skipped.
531 */
532export function promptEvents(ledger: Ledger, said: PromptRead, at: number) {
533 const { made, push } = builder(ledger)
534 const asked: string[] = []
535 for (const ask of said.asks) {
536 if (isListed(made.now, ask.title)) continue
537 // What the person dropped and now asks for again is theirs to bring back: the ask opens, and the drop is lifted.
538 const isRenewed = isDropped(made.now, ask.title)
539 // A goal named by an id the ledger does not hold is a slip of the model's, not a new goal's title.
540 const wanted = ask.goal !== null && /^g\d+$/.test(ask.goal) && !made.now.goals.some(goal => goal.id === ask.goal) ? null : ask.goal
541 const named = made.now.goals.find(goal => goal.id === wanted || goal.title.toLowerCase() === wanted?.toLowerCase())
542 const goal = wanted === null ? undefined : (named?.id ?? push(goalOpened(made.now, wanted)).goal.id)
543 const event = push({
544 ...opened(made.now, at, 'audit', {
545 kind: 'ask',
546 title: ask.title,
547 from: ask.quote ? `You: "${ask.quote}"` : 'Your message.',
548 why: '',
549 close: ask.done,
550 ...(goal ? { goal } : {}),
551 }),
552 ...(isRenewed ? { isRenewed } : {}),
553 })
554 // What the person said to keep for later is set aside as it is listed, and the reply is not held to it.
555 if (ask.isHeld) push({ type: 'later', id: event.item.id, reason: HELD_REASON })
556 else asked.push(event.item.id)
557 }
558 for (const answer of said.answers) push({ type: 'patch', id: answer.id, patch: { sent: answer.answer }, drop: ['isUnsettled'] })
559 // Wrapping up brings back what the person set aside.
560 if (said.isWrapUp) for (const item of made.now.items.filter(one => one.status === 'later' && one.closedBy === 'you')) push({ type: 'reopen', id: item.id })
561
562 return { events: made.events, asked }
563}
564
565/**
566 * What an audit was asked about: the ledger it was given, and the items that the messages of its
567 * turn answered. `heard` is left out by a job that a build before this one kept. `isCut` is true for
568 * a turn that ended without a reply, which settles nothing: its audit can only reopen what Claude closed.
569 * `touched` holds the items the person changed in the panel after the turn ended.
570 */
571export type Since = { book: Ledger; heard?: string[]; isCut?: boolean; touched?: string[] }
572
573/**
574 * The items an audit leaves alone. It takes seconds and may run after the next message, so the
575 * ledger it writes to is not the one it read. An item that was closed, reopened or answered while it
576 * was out has moved on, and so has one the person changed in the panel while it waited to start. An
577 * answer that came in a message its turn never heard is for the audit of the turn that does hear it.
578 */
579function beyond(since: Since, now: Ledger) {
580 const read = new Map(since.book.items.map(item => [item.id, item]))
581 const isBeyond = (item: Item) => {
582 const was = read.get(item.id)
583 if (!was || was.status !== item.status || was.closedBy !== item.closedBy || was.sent !== item.sent) return true
584 if (since.touched?.includes(item.id)) return true
585
586 return since.heard !== undefined && item.sent !== undefined && !since.heard.includes(item.id)
587 }
588
589 return new Set(now.items.filter(isBeyond).map(item => item.id))
590}
591
592/**
593 * What the reply's audit found, as events: closes with their evidence, asks the reply skipped, what
594 * was sent and not acted on, and the new items the reply leaves, less any the person dropped before
595 * or the inbox already lists. With `since`, nothing is written to an item the audit must leave alone.
596 */
597export function replyEvents(ledger: Ledger, found: ReplyRead, at: number, since?: Since) {
598 const { made, push } = builder(ledger)
599 const off = since ? beyond(since, ledger) : new Set<string>()
600 const within = <T extends { id: string }>(ones: T[]) => ones.filter(one => !off.has(one.id))
601 if (since?.isCut) {
602 for (const back of within(found.reopened)) {
603 push({ type: 'reopen', id: back.id })
604 push({ type: 'patch', id: back.id, patch: { why: back.why } })
605 }
606
607 return made.events
608 }
609 const said = { ...found, closed: within(found.closed), reopened: within(found.reopened), updated: within(found.updated), missed: found.missed.filter(id => !off.has(id)), unsettled: found.unsettled.filter(id => !off.has(id)) }
610 for (const close of said.closed) push({ type: 'close', id: close.id, how: close.how, by: 'audit', reason: `${BY_REPLY}${close.reason}` })
611 // While something Claude started still runs in the background, the work is not over: an ask not yet done is not one that was skipped.
612 const isBusy = made.now.items.some(item => item.kind === 'running' && item.status === 'open')
613 for (const id of said.missed) if (!isBusy && made.now.items.some(item => item.id === id && item.kind === 'ask')) push({ type: 'patch', id, patch: { isMissed: true } })
614 for (const id of said.unsettled) push({ type: 'patch', id, patch: { isUnsettled: true }, drop: ['sent'] })
615 // What was sent has now been read. An item the audit neither closed nor called unsettled is open as before.
616 for (const item of made.now.items.filter(one => one.status === 'open' && one.sent !== undefined && !off.has(one.id))) push({ type: 'patch', id: item.id, patch: {}, drop: ['sent'] })
617 // A close Claude made on its own word, which the exchange does not bear out, is open again with what is missing.
618 for (const back of said.reopened) {
619 push({ type: 'reopen', id: back.id })
620 push({ type: 'patch', id: back.id, patch: { why: back.why } })
621 }
622 // An item a reply says more about takes the newer account of it.
623 for (const more of said.updated) {
624 const isAsk = made.now.items.some(item => item.id === more.id && item.kind === 'ask')
625 // What closes an ask is what the person asked for, in the words read off their message. No reply rewrites that.
626 push({ type: 'patch', id: more.id, patch: { ...(more.why === '' ? {} : { why: more.why }), ...(more.close === '' || isAsk ? {} : { close: more.close }) }, drop: ['isMissed'] })
627 }
628 for (const { goal: named, ...rest } of said.open) {
629 // A goal the ledger does not hold is a slip of the model's: the item is then one of no goal.
630 const fields = made.now.goals.some(goal => goal.id === named) ? { ...rest, goal: named } : rest
631 // A reload or a restart is one task however many replies ask for it: the open one takes the newest wording.
632 const same = fields.closesOn && made.now.items.find(item => item.status === 'open' && item.closesOn === fields.closesOn)
633 if (same) push({ type: 'patch', id: same.id, patch: { title: fields.title, from: fields.from, why: fields.why, close: fields.close } })
634 else if (!isDropped(made.now, fields.title) && !isListed(made.now, fields.title)) push(opened(made.now, at, 'audit', fields))
635 }
636
637 return made.events
638}
639
640/** What the fork said about one item: whether it is done, and the evidence. */
641/** What the audit of a subagent said, kept to what it may open: issues and untested claims, within its cap. */
642export function readAgent(reply: string): NewItem[] {
643 const fresh = list(jsonOf(reply)?.open)
644 .flatMap(one => newItem(one) ?? [])
645 .filter(item => item.kind === 'issue' || item.kind === 'unverified')
646
647 return fresh.filter((item, at) => item.kind !== 'unverified' || fresh.findIndex(other => other.kind === 'unverified') === at).slice(0, OPENS_PER_AGENT)
648}
649
650/**
651 * What the audit of a subagent found, as events: its new items, less any the person dropped before
652 * or the inbox already lists, and no more of them than `room`.
653 */
654export function agentEvents(ledger: Ledger, found: NewItem[], at: number, room: number) {
655 const { made, push } = builder(ledger)
656 for (const fields of found) if (made.events.length < room && !isDropped(made.now, fields.title) && !isListed(made.now, fields.title)) push(opened(made.now, at, 'audit', fields))
657
658 return made.events
659}
660
661export function readFork(reply: string) {
662 const said = jsonOf(reply)
663
664 return said && typeof said.done === 'boolean' ? { isDone: said.done, reason: text(said.reason) } : null
665}
666types/index.d.ts 160 lines1/** What an inbox item is, which decides the section it sits in. */
2export type Kind = 'stopped' | 'check' | 'question' | 'task' | 'issue' | 'opportunity' | 'unverified' | 'ask' | 'running'
3
4/** Who opened or closed an item: the person, the audit's model, Claude through the tool, or the mod's own signals. */
5export type Actor = 'you' | 'audit' | 'claude' | 'engine'
6
7/** One thing a conversation left open, with what is known about it and what closing it takes. */
8export type Item = {
9 id: string
10 kind: Kind
11 title: string
12 /** Where it came from, in words: whose message, and what it said. */
13 from: string
14 why: string
15 /** What closing it takes. */
16 close: string
17 /** The count of prompts sent when it opened, and the time. */
18 turn: number
19 at: number
20 by: Actor
21 status: 'open' | 'later' | 'done' | 'dropped'
22 /** Once closed or set aside: who did it, how it came about, and the count of prompts then. */
23 closedBy?: Actor
24 reason?: string
25 closedTurn?: number
26 /**
27 * The id of the engine's turn in which Claude closed it, which is the turn whose audit reviews the
28 * close, and how many of that turn's messages had reached Claude by then.
29 */
30 closedIn?: string
31 closedHeard?: number
32 /** The goal it belongs to: an ask, or a question, a task or a finding about that piece of work. */
33 goal?: string
34 /** A look at it in the background, running or come back. */
35 check?: Check
36 /** True for an ask that a reply neither did nor mentioned. */
37 isMissed?: true
38 /** What the person last sent about it from the panel, kept until the reply to that is read. */
39 sent?: string
40 /** True when it was sent and the reply did not settle it. */
41 isUnsettled?: true
42 /** True when the prompt it came from is no longer in the conversation: the person rewound past it. */
43 isRewound?: true
44 /** A question's answers, each one click. */
45 options?: string[]
46 /** True for a question that takes several answers. */
47 isMulti?: true
48 /** The answers Claude recommended, by their place in `options`. */
49 recommended?: number[]
50 /** The question dialog a question was left in, by its tool call. */
51 dialog?: string
52 /** A task's command, a page it is done on, a file it is about, and text drafted for the person to send. */
53 command?: string
54 link?: string
55 file?: string
56 draft?: string
57 /** A value only the person has: what to call it in the prompt, typed in the card. */
58 ask?: string
59 /** True for something only the person's eyes can judge. */
60 isLook?: true
61 /** What the mod watches for to close it without anyone saying so, in words. */
62 selfCloses?: string
63 /** The event that closes it by itself: a restart of Claude Code, or a reload of its plugins. */
64 closesOn?: 'restart' | 'reload'
65 /** What a failing check or a running task follows: the command, or the task's id. */
66 key?: string
67}
68
69/**
70 * A look at an item that an agent takes beside the conversation: a check of something that was
71 * never run, or where a running task stands. Nothing of it enters the conversation.
72 */
73export type Check = {
74 state: 'running' | 'back'
75 /** When it started, and once it is back, when it came back. */
76 at: number
77 /** The agent taking it, while it runs. */
78 agent?: string
79 /** How a check of a finding came out. A look at a running task has none. */
80 verdict?: 'passed' | 'failed' | 'unclear'
81 /** What was found, in words. */
82 text?: string
83 /** How many steps the agent has taken. `ran` keeps only the newest of them, so its length is not the count. */
84 steps?: number
85 /** The newest of the commands that were run to find it, each with how it ended. */
86 ran?: string[]
87 /** What the item was before a failed check made an issue of it, for when the person throws the result away. */
88 was?: { kind: Kind; title: string; close?: string }
89}
90
91/** A piece of work the person asked for, which its items sit under. */
92export type Goal = { id: string; title: string; turn: number }
93
94/** Everything the inbox holds for one conversation. It is rebuilt from the conversation's log of events. */
95export type Ledger = {
96 items: Item[]
97 goals: Goal[]
98 nextId: number
99 /** How many prompts the person has sent. */
100 turn: number
101 /** Titles of items the person dropped, so that the same thing is not opened again. */
102 dropped: string[]
103}
104
105/** One line of a conversation's inbox log. `note` lines change nothing: they record a model call, a tool call or a signal. */
106export type Event =
107 | { type: 'turn' }
108 | { type: 'goal'; goal: Goal }
109 | { type: 'open'; item: Item; isRenewed?: boolean }
110 | { type: 'close'; id: string; how: 'done' | 'dropped'; by: Actor; reason: string; in?: string; heard?: number }
111 | { type: 'later'; id: string; reason: string }
112 | { type: 'reopen'; id: string }
113 | { type: 'patch'; id: string; patch: Partial<Item>; drop?: (keyof Item)[] }
114 | { type: 'retract'; to: Ledger }
115 | { type: 'rewind'; turn: number }
116 | { type: 'note'; what: string; detail: unknown }
117
118/** What the panel shows and holds between presses. It lasts for the session and is not logged. */
119export type View = {
120 /** Which build's fields this value has. One stored by a build with other fields is not read. */
121 shape: number
122 isPanelOpen: boolean
123 /** The sections and goals that are minimized, by key. */
124 folded: string[]
125 /** The items whose cards are open. Opening one leaves the others as they are. */
126 open: string[]
127 /** The items whose check shows the commands it ran. */
128 detailed: string[]
129 /** Items whose capture waits in the prompt box, each with the line that shows it is still there. */
130 added: Record<string, string>
131 /** What the person has typed into an item's text box, and the answers ticked on it. */
132 typed: Record<string, string>
133 picks: Record<string, number[]>
134 /** The goals whose finished items are listed. */
135 shownGoals: string[]
136 /** The agents the mod started to look at items, whose reports must never reach the conversation. */
137 agents: string[]
138 /** Raised to draw the panel again when only the time has moved on. */
139 tick: number
140}
141
142/**
143 * The audit of a reply that the end of a turn asked for and that has not finished: the turn, its
144 * reply, the prompts it answered, and the tool calls made in it. With them, when the turn had any:
145 * what Claude wrote before the reply, what its subagents did, and its todo list as the turn ended.
146 */
147export type Pending = { id: string; answer: string; turn: { text: string; isNotice: boolean; asked: string[]; answered?: string[] }[]; did?: string[]; said?: string[]; agents?: string[]; todo?: string[]; isCut?: boolean }
148
149/**
150 * The turn that is running, kept where a reload of the mod leaves it: the engine's id of the turn,
151 * the messages that have reached Claude in it, and the ones the person sent that have not yet.
152 */
153export type Running = { id?: string; heard: Pending['turn'] | null; waiting: Pending['turn'] }
154
155declare module 'claude-code' {
156 interface PluginState {
157 inbox: { ledger: Ledger; view: View; pending: Pending[] | Pending | null; running: Running | null }
158 }
159}
160