SLOPSHOPPER

inbox

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

newpanespinnerguardcommandtoast
★ 1v?GPL-3.0-or-laterupdated 2026-10-14macwilk/cc-extensions/inbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · inbox
│ ┃ Inbox ✕ › fix the failing auth test and add an audit log call │ ┃ Inbox · 1 need you · 1 open │ ┃ ⏺ Read(src/auth.ts) │ ┃ Needs youHide Needs you ⎿ Read 6 lines │ ┃ ! Failed: bun test now ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /inbox │ ┃ ⎿ inbox: Inbox opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ Hide the inbox

Draws

Pane · Inbox
Inbox · 1 need you · 1 open Needs youHide Needs you ! Failed: bun test now
README

inbox

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.

What the panel holds

The panel lists goals first, and then everything that belongs to no goal:

  • Goals: what you asked for. A goal that two or more items share is a block with its progress. The block holds the goal's asks and the questions, tasks, and findings about that work. Click the goal's name to minimize it, and click Done to list what is finished in it. An ask that shares a goal with nothing else is listed under Single goals.
  • Needs you: a turn that stopped on an error, checks that failed, questions, questions left behind in a question dialog, and your own tasks.
  • Findings: issues, opportunities, and claims that were not verified by running.
  • Running: commands, subagents, and workflows that Claude left running in the background.

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:

  • A question shows its answers as buttons, with a text box for an answer of your own.
  • A task shows its command, link, or drafted text, each with a button to copy it.
  • Any item can go into your prompt box as one quoted line with its id, so that you can add a note before you send it. Claude looks up the rest of the item by that id. Several items stack in one prompt.

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.

Check an item in the background

Two kinds of item have a button that looks into them beside the conversation:

  • Verify, on an unverified item, runs the claim and reports whether it passed, failed, or could not be settled.
  • What's going on?, on a running item, reports where the task stands.

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.

  • Passed: Close closes the item with what the check saw as its evidence. If you leave it, the item closes when the turn ends.
  • Failed: the item becomes an issue, named for the problem that the check found. Fix it puts the issue in your prompt box.
  • Unclear: the item stays as it was, with the reason.

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.

How items open and close

  • Without a model: the mod watches for a turn that ends on an error, a test or type check that fails and later passes, a question dialog that you leave, a background command, subagent, or workflow that ends, and a reload of the plugins or a restart that a task was waiting on.
  • On each prompt: a model reads your message for what it asks and what it answers.
  • On each reply: a model reads the exchange for what got done, what the reply skipped, and what it leaves open. Each close records its evidence. The model is given your message, each tool call with how it ended, what you chose in a question dialog, what Claude wrote during the turn, what its subagents did and reported, and the reply.
  • By Claude: one tool, 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:

  • Light reads with Haiku, which costs less and misses more.
  • Balanced, the default, reads the latest 80 tool calls of a turn and the end of what each one printed.
  • Thorough also reads which files Claude read and searched, what each edit changed, how every other tool ended, the task each subagent was given, and far more of long outputs, replies, and subagent reports. In a conversation of 137 turns, it gave the model about 1.6 times as much to read.
  • Exhaustive reads what Thorough reads, and checks every subagent's report against what that subagent ran, with one more model call for each subagent. It lists a report that hides a failure or claims something that was never run.

Where the inbox is kept

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.

Before you begin

Set "tui": "fullscreen" in your Claude Code settings. Claude Code reports clicks only in the fullscreen interface.

Install the mod

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.

Source 8 files
hooks/register.tsx 1676 lines
1// 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 lines
1// 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}
184
hooks/checks.ts 154 lines
1// 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}
154
hooks/ledger.ts 324 lines
1// 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}
324
hooks/signals.ts 87 lines
1// 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}
87
hooks/tool.ts 129 lines
1// 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}
129
hooks/workers.ts 666 lines
1// 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}
666
types/index.d.ts 160 lines
1/** 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