An always-on pane with what this session is about: the task, your last prompt, what Claude is doing and last said, and the explanations it wrote, saved as…

A Claude Code mod that keeps a pane beside the session with what it's about, so you can switch worktrees and pick up where you left off.
Mods need Claude Code 2.1.287 or later.
/plugin marketplace add crockalet/claude-mods
/plugin install breadcrumbs@claude-mods
/whereami shows or hides the pane. It opens by itself at 144+ columns.
When you need to run tests by hand, Claude lists them in a second pane (/tests) with their steps. Start tester hands one test to a tester subagent that walks you through it, follows the logs while you go, and sends its final report to the main session, so the main agent stays the orchestrator.
Notes live outside your repos, one folder per session:
~/.agents/notes/<repo>/<worktree>/
index.md one line per session
<date>-<task>-<id>/
context.md the pane as a file
NN-<title>.md notes
state.json restores the pane on resume
~/.agents/notes/<repo>/_pinned/ pinned notes
Session folders are archived after 30 days, or as soon as their worktree is gone, and deleted 60 days after that. /whereami clean shows what would go and asks first. Both periods, the side-pass model, the testers' model (Sonnet unless a test asks for another) and when the pane opens are settings in /plugin.
claude --plugin-dir plugins/breadcrumbs # hot-reloads on save
claude plugin validate plugins/breadcrumbs
claude plugin test plugins/breadcrumbshooks/register.tsx 1786 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Ask, Crumbs, Entry, ManualTest, Note, Repo, TestRun, TestStatus, TestsView, View, Wait, Where } from '../types'
5import { contextMarkdown, repoState } from './markdown'
6import { normalize } from './state'
7import { ICON, STATUSES, TESTER, TESTER_PROMPT, TESTS_GUIDANCE, TESTS_PANE, TOOL, WAIT_LIMIT_MS, isLive, listOf, openWait, settleNeeds, str, testOf, unmet } from './tests'
8import type { Saved } from './markdown'
9import { richLines, type Line } from './rich'
10import { ago, asks, basename, cdTarget, clip, day, doneOf, head, narrowTables, parseObject, parseStatus, slug, stamp, strings, toolLabel } from './text'
11
12const PANE = 'breadcrumbs'
13const PAD = 1
14// Below this many columns per cell a wrapped table reads worse than the same rows as a list.
15const MIN_CELL = 10
16
17// The terminal's own Markdown draws flat; there the pane lays markdown out itself in the theme's colors.
18const drawRich = (el: ReturnType<$['ui']['resolve']>, lines: Line[], dim = false) => {
19 const { Box, Text, Link } = el
20 return (
21 <Box flexDirection="column">
22 {lines.map(line => (
23 <Text wrap="truncate-end">
24 {line.length === 0
25 ? ' '
26 : line.map(s => {
27 const text = (
28 <Text {...s.style} dimColor={dim || undefined}>
29 {s.text}
30 </Text>
31 )
32 return s.href ? <Link href={s.href}>{text}</Link> : text
33 })}
34 </Text>
35 ))}
36 </Box>
37 )
38}
39const SAVE_NOTE = 'mcp__breadcrumbs__save_note'
40const EDIT_NOTE = 'mcp__breadcrumbs__edit_note'
41
42const crumbs = atom({ plugin: 'breadcrumbs', key: 'crumbs' } as const, normalize({}))
43const where = atom({ plugin: 'breadcrumbs', key: 'where' } as const, null)
44const VIEW: View = {
45 openNote: null,
46 isShowingPrompts: false,
47 isShowingMore: false,
48 isShowingStatus: false,
49 openDone: null,
50 picked: {},
51 typed: {},
52 details: {},
53 expanded: null,
54}
55const view = atom({ plugin: 'breadcrumbs', key: 'view' } as const, VIEW)
56const pending = atom({ plugin: 'breadcrumbs', key: 'pending' } as const, null)
57const testRun = atom({ plugin: 'breadcrumbs', key: 'tests' } as const, { brief: '', tests: [], waits: [], unreported: [] })
58const testsView = atom({ plugin: 'breadcrumbs', key: 'testsView' } as const, { expanded: null, typed: {} })
59
60const NOTE_GUIDANCE = [
61 'The user keeps a "breadcrumbs" pane open beside this session.',
62 `When the user asks for an explanation, a summary, a walkthrough or a comparison, write it with the ${SAVE_NOTE} tool`,
63 '(a short title and the full markdown body) instead of only in your reply, then reply with one line saying it is saved in the breadcrumbs pane.',
64 `To change a note you saved, call ${EDIT_NOTE} with its id and only the text that changes; never save the same note again.`,
65 'Keep doing any task the same message asked for. Do not save notes for status updates or for answers of a sentence or two.',
66 'The pane reflows notes to its current width, which the person resizes: headings, paragraphs, lists and tables all rewrap (table cells wrap), so write for reading, not for a column count. Only code and diagram lines are cut where the pane ends, so keep those short.',
67].join(' ')
68
69type $ = EngineInterface
70
71// Session state outlives reloads, so it can predate fields added since; every access goes through normalize.
72const readCrumbs = async ($: $): Promise<Crumbs> => normalize(await read($, crumbs))
73
74const mutate = ($: $, fn: (c: Crumbs) => Crumbs) => update($, crumbs, c => fn(normalize(c)))
75
76const notesRoot = async ($: $): Promise<string> =>
77 `${(await $.env.get('HOME')) ?? '~'}/.agents/notes`
78
79const git = async ($: $, cwd: string, ...args: string[]): Promise<string | null> => {
80 try {
81 const run = await $.process.run(['git', ...args], { cwd, timeoutMs: 3000 })
82
83 return run.exitCode === 0 ? run.stdout : null
84 } catch {
85 return null
86 }
87}
88
89const locate = async ($: $): Promise<Where> => {
90 const cwd = await $.session.cwd()
91 const repo = await $.session.repo()
92 const worktree = (await git($, cwd, 'rev-parse', '--show-toplevel'))?.trim() || cwd
93
94 return {
95 repo: repo ? basename(repo.root) : basename(worktree),
96 branch: (await git($, cwd, 'branch', '--show-current'))?.trim() ?? '',
97 worktree,
98 }
99}
100
101const refreshRepos = async ($: $) => {
102 const c = await readCrumbs($)
103 const place = await read($, where)
104 const places = new Set([...c.touched, ...c.edited.map(f => f.slice(0, f.lastIndexOf('/')))])
105 if (place) places.add(place.worktree)
106 const roots = new Set<string>()
107 for (const dir of places) {
108 const root = (await git($, dir, 'rev-parse', '--show-toplevel'))?.trim()
109 if (root) roots.add(root)
110 }
111 const repos: Repo[] = []
112 for (const root of roots) {
113 const status = await git($, root, 'status', '--porcelain=v1', '-b')
114 if (status !== null) repos.push({ root, ...parseStatus(status) })
115 }
116 await mutate($, old => ({ ...old, repos }))
117}
118
119const worktreeDir = (root: string, where: Where) =>
120 `${root}/${where.repo}/${basename(where.worktree)}`
121
122const readSaved = async ($: $, dir: string): Promise<Saved | null> => {
123 try {
124 return JSON.parse(await $.fs.read(`${dir}/state.json`)) as Saved
125 } catch {
126 return null
127 }
128}
129
130const writeIndex = async ($: $, wtDir: string) => {
131 let entries
132 try {
133 entries = await $.fs.list(wtDir)
134 } catch {
135 return
136 }
137 const rows: { at: number; line: string }[] = []
138 for (const entry of entries) {
139 if (entry.kind !== 'dir') continue
140 const saved = await readSaved($, `${wtDir}/${entry.name}`)
141 if (!saved) continue
142 const task = clip(saved.tasks[0]?.title ?? 'No task yet', 70)
143 const notes = saved.notes.length === 1 ? '1 note' : `${saved.notes.length} notes`
144 const waiting = asks(saved.needsYou).length > 0 ? ' · needs you' : ''
145 rows.push({
146 at: saved.updatedAt,
147 line: `- ${task}${waiting} · ${notes} · ${stamp(saved.updatedAt)} → [${entry.name}/](${entry.name}/context.md)`,
148 })
149 }
150 if (rows.length === 0) return
151 rows.sort((a, b) => b.at - a.at)
152 await $.fs.write(`${wtDir}/index.md`, `# ${basename(wtDir)}\n\n${rows.map(r => r.line).join('\n')}\n`)
153}
154
155const persist = async ($: $, dir: string, saved: Saved, where: Where, status: string) => {
156 await $.fs.write(`${dir}/state.json`, JSON.stringify(saved, null, 2))
157 await $.fs.write(`${dir}/context.md`, contextMarkdown(saved, where, status))
158 await writeIndex($, dir.slice(0, dir.lastIndexOf('/')))
159}
160
161type CleanPlan = {
162 archive: { from: string; to: string }[]
163 remove: string[]
164 touched: string[]
165}
166
167type CleanOptions = { now: number; retentionDays: number; archiveDays: number; isArchiving: boolean; keep: string }
168
169const DAY = 86_400_000
170
171const dirs = async ($: $, path: string) => {
172 try {
173 return (await $.fs.list(path)).filter(e => e.kind === 'dir' && !e.name.startsWith('.'))
174 } catch {
175 return []
176 }
177}
178
179const planClean = async ($: $, root: string, o: CleanOptions): Promise<CleanPlan> => {
180 const plan: CleanPlan = { archive: [], remove: [], touched: [] }
181 for (const repo of await dirs($, root)) {
182 if (repo.name.startsWith('_')) continue
183 for (const wt of await dirs($, `${root}/${repo.name}`)) {
184 if (wt.name.startsWith('_')) continue
185 const wtDir = `${root}/${repo.name}/${wt.name}`
186 for (const session of await dirs($, wtDir)) {
187 const dir = `${wtDir}/${session.name}`
188 if (dir === o.keep) continue
189 const saved = await readSaved($, dir)
190 const updatedAt = saved?.updatedAt ?? (await $.fs.stat(dir)).mtimeMs
191 const isGone = saved !== null && !(await $.fs.exists(saved.worktree))
192 const isOld = o.now - updatedAt > o.retentionDays * DAY
193 if (!isGone && !isOld) continue
194 if (o.isArchiving) {
195 plan.archive.push({ from: dir, to: `${root}/_archive/${repo.name}/${wt.name}--${session.name}` })
196 } else {
197 plan.remove.push(dir)
198 }
199 if (!plan.touched.includes(wtDir)) plan.touched.push(wtDir)
200 }
201 }
202 }
203 for (const repo of await dirs($, `${root}/_archive`)) {
204 for (const entry of await dirs($, `${root}/_archive/${repo.name}`)) {
205 const dir = `${root}/_archive/${repo.name}/${entry.name}`
206 let archivedAt: number
207 try {
208 archivedAt = Number(await $.fs.read(`${dir}/.archived-at`))
209 } catch {
210 archivedAt = (await $.fs.stat(dir)).mtimeMs
211 }
212 if (o.now - archivedAt > o.archiveDays * DAY) plan.remove.push(dir)
213 }
214 }
215
216 return plan
217}
218
219const applyClean = async ($: $, root: string, plan: CleanPlan, now: number) => {
220 // Every path came from listing `root`, but a bad join must never reach rm.
221 const isInside = (p: string) => p.startsWith(`${root}/`) && !p.includes('/../')
222 for (const { from, to } of plan.archive) {
223 if (!isInside(from) || !isInside(to)) continue
224 await $.process.run(['mkdir', '-p', to.slice(0, to.lastIndexOf('/'))])
225 const moved = await $.process.run(['mv', from, to])
226 if (moved.exitCode === 0) await $.fs.write(`${to}/.archived-at`, String(now))
227 }
228 for (const dir of plan.remove) {
229 if (isInside(dir)) await $.process.run(['rm', '-rf', dir])
230 }
231 for (const wtDir of plan.touched) {
232 if ((await dirs($, wtDir)).length === 0) {
233 await $.process.run(['rm', '-rf', wtDir])
234 } else {
235 await writeIndex($, wtDir)
236 }
237 }
238}
239
240const cfg = { model: 'haiku', testerModel: 'sonnet', retentionDays: 30, archiveDays: 60, isArchiving: true }
241
242let sessionId = ''
243let dir: string | null = null
244// Identifies the conversation across a resume, which starts it under a new session id but keeps its transcript.
245let transcript: string | null = null
246let turnTools: string[] = []
247let hasSavedNote = false
248
249const ensureDir = async ($: $, title: string): Promise<string> => {
250 if (dir) return dir
251 const place = (await read($, where)) ?? (await locate($))
252 const now = await $.clock.now()
253 dir = `${worktreeDir(await notesRoot($), place)}/${day(now)}-${slug(title, 32)}-${sessionId.slice(0, 6)}`
254 await $.store.set(`dir:${sessionId}`, dir)
255
256 return dir
257}
258
259const save = async ($: $, status: string) => {
260 const state = await readCrumbs($)
261 const place = await read($, where)
262 if (!place || (state.tasks.length === 0 && state.notes.length === 0)) return
263 const target = await ensureDir($, state.tasks[0]?.title ?? 'session')
264 const saved: Saved = { ...state, session: sessionId, worktree: place.worktree, updatedAt: await $.clock.now(), ...(transcript ? { transcript } : {}) }
265 await persist($, target, saved, place, status)
266}
267
268const clean = async ($: $) => {
269 const root = await notesRoot($)
270 const now = await $.clock.now()
271 const plan = await planClean($, root, { ...cfg, now, keep: dir ?? '' })
272
273 return { root, now, plan }
274}
275
276// `transcript` set means a one-off catch-up over a session that predates the mod.
277type Turn = { answer: string; hasSavedNote: boolean; tools: string[]; transcript?: string }
278
279const sidePass = async ($: $, { answer, hasSavedNote, tools, transcript }: Turn) => {
280 const cap = transcript ? 6 : 3
281 const state = await readCrumbs($)
282 const prompt = state.prompts.at(-1)?.text ?? ''
283 const request = [
284 'You keep a running log of a coding session for a developer who switches between many sessions.',
285 transcript
286 ? 'Read this transcript of the session so far and answer with one JSON object and nothing else. It is a catch-up: read "this turn" below as "the session so far", allow up to 6 decisions, attempts and done items, and take needsYou only from the last assistant message.'
287 : 'Read the latest exchange and answer with one JSON object and nothing else:',
288 '{"task": string, "isNewTask": boolean, "decisions": string[], "attempts": [{"text": string, "isOk": boolean}], "needsYou": [{"question": string, "context": string, "options": [{"label": string, "description": string}]}], "done": {"title": string, "items": string[]} | null, "note": {"title": string, "markdown": string} | null}',
289 '- task: the overall goal of the whole session in under 60 characters, imperative ("Fix websocket reconnect loop"), judged from all the recent prompts, not just this turn\'s step. Keep the current task\'s wording unless it is wrong or too narrow: when the prompts show the current task is one step of a bigger goal, widen it to that goal ("Verify the restart" becomes "Build the breadcrumbs mod").',
290 '- isNewTask: true only when the user clearly moved on to a different goal, not a follow-up.',
291 '- decisions: design or approach choices the assistant made on its own this turn where another option was reasonable and the user did not specify it ("Capped backoff at 30s instead of 60s"). Not actions taken, checks run or instructions given to the user. At most 3, under 90 characters each. Usually empty.',
292 '- done: what this turn changed outside the conversation, from the assistant reply and tools used. Not reads, checks, explanations or plans; null when nothing changed. title: the turn\'s outcome in under 40 characters, past tense ("Added the secrets mod", "Pushed main"). items: the concrete results behind it, naming what changed ("Pushed main to crockalet/breadcrumbs", "Fixed reconnect race in socket.ts"), at most 3, under 90 characters each; empty when the title says it all.',
293 '- needsYou: what the assistant reply itself asks the user to answer or decide (never questions inferred from the earlier prompts or the task): explicit questions, approvals, choices between options. Each question is short, under 80 characters ("Approve the PR description?"). context: one or two sentences from the reply that someone needs to answer well (what is at stake, what each choice leads to). options: 2 to 4 when the question has discrete choices, each a short label ("Yes, I\'ll run it") and a one-line description of what that choice means, else []. Empty list when the reply asks nothing.',
294 '- attempts: approaches tried this turn, isOk false when one failed or was abandoned (at most 3). Empty when none.',
295 '- note: only when the user asked for an explanation or summary, the reply contains it at a paragraph or more (not a one-line answer), and it was not saved already. markdown is that explanation, kept close to the reply\'s own words. Otherwise null.',
296 '',
297 `Current task: ${state.tasks[0]?.title ?? '(none yet)'}`,
298 `Earlier prompts, oldest first (context for the task only; already handled): ${state.prompts.slice(0, -1).map(p => JSON.stringify(clip(p.text, 200))).join(' | ') || 'none'}`,
299 `Already saved a note this turn: ${hasSavedNote}`,
300 `Notes saved earlier (reuse a title exactly to update that note instead of adding one): ${state.notes.map(n => JSON.stringify(n.title)).join(', ') || 'none'}`,
301 `Tools used this turn: ${tools.slice(0, 30).join('; ') || 'none'}`,
302 '',
303 ...(transcript
304 ? ['<transcript>', transcript, '</transcript>']
305 : ['<user_prompt>', clip(prompt, 4000), '</user_prompt>', '<assistant_reply>', answer.slice(0, 12000), '</assistant_reply>']),
306 ].join('\n')
307
308 const reply = await $.model.complete({ model: cfg.model, prompt: request, maxTokens: 4000, effort: 'low', timeoutMs: 60_000 })
309 if (!reply.isAnswered) {
310 $.ui.log(`side pass skipped (${reply.reason})`)
311 return
312 }
313 const out = parseObject(reply.text)
314 if (!out) {
315 $.ui.log(`side pass: unparseable reply (${clip(reply.text, 120)})`, { to: 'debug' })
316 return
317 }
318
319 const now = await $.clock.now()
320 const task = typeof out.task === 'string' ? clip(out.task, 70) : ''
321 const attempts = Array.isArray(out.attempts)
322 ? out.attempts
323 .filter((a): a is { text: string; isOk?: unknown } => typeof a?.text === 'string')
324 .slice(0, cap)
325 .map(a => ({ text: clip(a.text, 100), isOk: a.isOk !== false }))
326 : []
327 await mutate($, c => {
328 let tasks = c.tasks
329 if (task && (tasks.length === 0 || out.isNewTask === true)) {
330 tasks = [{ title: task, at: now }, ...tasks].slice(0, 6)
331 } else if (task && tasks[0]) {
332 tasks = [{ ...tasks[0], title: task }, ...tasks.slice(1)]
333 }
334
335 const done = Array.isArray(out.done)
336 ? doneOf(null, strings(out.done, cap, 90), now)
337 : doneOf((out.done as { title?: unknown } | null)?.title, strings((out.done as { items?: unknown } | null)?.items, cap, 90), now)
338
339 return {
340 ...c,
341 tasks,
342 decided: [...c.decided, ...strings(out.decisions, cap, 90)].slice(-20),
343 tried: [...c.tried, ...attempts].slice(-20),
344 // Haiku sometimes turns the user's own requests into questions; a reply that asks nothing has none.
345 needsYou: answer.includes('?') ? asks(out.needsYou) : [],
346 done: done ? [...c.done, done].slice(-30) : c.done,
347 }
348 })
349
350 const note = out.note as { title?: unknown; markdown?: unknown } | null
351 if (!hasSavedNote && note && typeof note.title === 'string' && typeof note.markdown === 'string' && note.markdown.trim()) {
352 await saveNote($, note.title, note.markdown)
353 }
354}
355
356// Pending lives in session state, so a reload (which drops timers) can pick the pass back up.
357const runPending = async ($: $) => {
358 try {
359 const raw = await read($, pending)
360 if (raw === null) return
361 await update($, pending, () => null)
362 await sidePass($, JSON.parse(raw) as Turn)
363 await save($, 'idle')
364 } catch (error) {
365 // A timer callback's rejection is otherwise swallowed without a trace.
366 $.ui.log(`side pass failed: ${error instanceof Error ? error.message : String(error)}`)
367 }
368}
369
370const noteFile = (title: string, task: string, markdown: string) =>
371 `---\ntitle: ${title.replace(/\n/g, ' ')}\ntask: ${task}\nsession: ${sessionId}\n---\n\n# ${title}\n\n${markdown.trim()}\n`
372
373const FRONTMATTER = /^---\n[\s\S]*?\n---\n+/
374
375// A rewritten note moves to the top of the list but keeps its file and number.
376const touchNote = async ($: $, id: string) => {
377 const now = await $.clock.now()
378 const pinned = (await readCrumbs($)).notes.find(n => n.id === id && n.isPinned)
379 if (pinned) await copyPinned($, pinned)
380 await mutate($, c => {
381 const note = c.notes.find(n => n.id === id)
382 return note ? { ...c, notes: [...c.notes.filter(n => n.id !== id), { ...note, at: now }] } : c
383 })
384 await save($, 'working')
385}
386
387const saveNote = async ($: $, title: string, markdown: string): Promise<Note> => {
388 const state = await readCrumbs($)
389 const task = state.tasks[0]?.title ?? ''
390 // Saving under a title already used replaces that note rather than listing a second copy.
391 const same = state.notes.find(n => slug(n.title) === slug(title))
392 if (same) {
393 await $.fs.write(same.file, noteFile(title, task, markdown))
394 await touchNote($, same.id)
395 return same
396 }
397 const target = await ensureDir($, task || title)
398 const now = await $.clock.now()
399 const number = String(state.notes.length + 1).padStart(2, '0')
400 const file = `${target}/${number}-${slug(title)}.md`
401 await $.fs.write(file, noteFile(title, task, markdown))
402 const note: Note = { id: `${now}-${number}`, title: clip(title, 80), file, at: now, isPinned: false }
403 await mutate($, c => ({ ...c, notes: [...c.notes, note] }))
404 await save($, 'working')
405
406 return note
407}
408
409type NoteEdit = { old: string; new: string }
410
411const editNote = async ($: $, ref: string, edits: NoteEdit[], markdown: string): Promise<{ note: Note } | { error: string }> => {
412 const { notes } = await readCrumbs($)
413 const note = notes.find(n => n.id === ref) ?? [...notes].reverse().find(n => slug(n.title) === slug(ref))
414 if (!note) return { error: `No note "${ref}". Notes: ${notes.map(n => `${n.id} (${n.title})`).join(', ') || 'none'}.` }
415 let text: string
416 try {
417 text = await $.fs.read(note.file)
418 } catch {
419 return { error: `The file for "${note.title}" is gone; save it again with save_note.` }
420 }
421 const header = FRONTMATTER.exec(text)?.[0] ?? ''
422 let body = markdown.trim() ? `# ${note.title}\n\n${markdown.trim()}\n` : text.slice(header.length)
423 for (const edit of edits) {
424 const count = body.split(edit.old).length - 1
425 if (count !== 1) {
426 return { error: `"${clip(edit.old, 60)}" appears ${count} times in "${note.title}"; ${count === 0 ? 'copy it exactly from the note' : 'include more of the text around it'}. Nothing was changed.` }
427 }
428 body = body.replace(edit.old, () => edit.new)
429 }
430 await $.fs.write(note.file, `${header}${body}`)
431 await touchNote($, note.id)
432
433 return { note }
434}
435
436const copyPinned = async ($: $, note: Note): Promise<boolean> => {
437 const place = await read($, where)
438 if (!place) return false
439 await $.fs.write(`${await notesRoot($)}/${place.repo}/_pinned/${basename(note.file)}`, await $.fs.read(note.file))
440 return true
441}
442
443const pin = async ($: $, note: Note) => {
444 if (!(await copyPinned($, note))) return
445 await mutate($, c => ({ ...c, notes: c.notes.map(n => (n.id === note.id ? { ...n, isPinned: true } : n)) }))
446 await save($, 'working')
447 $.ui.toast(`Pinned "${note.title}"`)
448}
449
450const track = async ($: $, e: Record<string, unknown>) => {
451 const file = typeof e.file_path === 'string' ? e.file_path : typeof e.notebook_path === 'string' ? e.notebook_path : ''
452 if ((e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') && file) {
453 await mutate($, c => ({ ...c, edited: [...c.edited.filter(f => f !== file), file].slice(-50) }))
454 }
455 if (e.tool === 'Bash' && typeof e.command === 'string') {
456 const dir = cdTarget(e.command, (await $.env.get('HOME')) ?? '')
457 if (dir) await mutate($, c => ({ ...c, touched: [...c.touched.filter(d => d !== dir), dir].slice(-20) }))
458 }
459}
460
461const isTyped = (m: SessionMessage) =>
462 m.role === 'user' && m.text.trim() !== '' && !m.toolResults?.length && !m.text.trimStart().startsWith('<')
463
464// The newest session folder in this worktree that holds the same conversation, by its transcript.
465const findResumed = async ($: $, wtDir: string, path: string): Promise<string | null> => {
466 let best: { dir: string; at: number } | null = null
467 for (const entry of await dirs($, wtDir)) {
468 const saved = await readSaved($, `${wtDir}/${entry.name}`)
469 if (saved?.transcript === path && (!best || saved.updatedAt > best.at)) best = { dir: `${wtDir}/${entry.name}`, at: saved.updatedAt }
470 }
471
472 return best?.dir ?? null
473}
474
475const restoreFrom = async ($: $, from: string) => {
476 const current = await readCrumbs($)
477 const saved = await readSaved($, from)
478 if (saved && current.tasks.length === 0 && current.notes.length === 0) {
479 const { session: _s, worktree: _w, updatedAt: _u, transcript: _t, ...restored } = saved
480 await mutate($, () => ({ ...normalize(restored), activity: null, asking: null }))
481 }
482 // Restored on its own: on a reload the breadcrumbs state can survive while the test list doesn't.
483 const savedTests = await readTests($, from)
484 if (savedTests && (await read($, testRun)).tests.length === 0) {
485 // A tester or a waiting step doesn't outlive its session.
486 await update($, testRun, () => ({
487 brief: savedTests.brief ?? '',
488 tests: savedTests.tests.map(t => ({ ...t, status: t.status === 'running' ? 'todo' : t.status, agentId: null, asking: null })),
489 waits: [],
490 unreported: savedTests.unreported,
491 }))
492 }
493}
494
495// A resumed conversation starts under a new session id, so its folder is found by its transcript.
496const adoptResumed = async ($: $, path: string) => {
497 transcript = path
498 const place = (await read($, where)) ?? (await locate($))
499 const found = dir ? null : await findResumed($, worktreeDir(await notesRoot($), place), path)
500 if (!found) return
501 dir = found
502 await $.store.set(`dir:${sessionId}`, found)
503 await restoreFrom($, found)
504}
505
506// A session that was running before the mod was installed has history but no breadcrumbs yet.
507const backfill = async ($: $) => {
508 const current = await readCrumbs($)
509 if (current.tasks.length > 0 || current.prompts.length > 0) return
510 let rows: readonly SessionMessage[]
511 try {
512 rows = await $.session.messages()
513 } catch {
514 return
515 }
516 if (!rows.some(isTyped)) return
517
518 const now = await $.clock.now()
519 const home = (await $.env.get('HOME')) ?? ''
520 const edited: string[] = []
521 const touched: string[] = []
522 const lines: string[] = []
523 for (const m of rows) {
524 if (isTyped(m)) lines.push(`User: ${clip(m.text, 1500)}`)
525 if (m.role !== 'assistant') continue
526 if (m.text.trim()) lines.push(`Claude: ${clip(m.text, 2500)}`)
527 for (const use of m.toolUses) {
528 lines.push(` [${toolLabel(use.tool, use.input)}]`)
529 if (use.isError) continue
530 const file = use.input.file_path ?? use.input.notebook_path
531 if ((use.tool === 'Edit' || use.tool === 'Write' || use.tool === 'NotebookEdit') && typeof file === 'string') edited.push(file)
532 const dir = use.tool === 'Bash' && typeof use.input.command === 'string' ? cdTarget(use.input.command, home) : null
533 if (dir) touched.push(dir)
534 }
535 }
536 const lastSaid = [...rows].reverse().find(m => m.role === 'assistant' && m.text.trim())
537 await mutate($, c => ({
538 ...c,
539 prompts: rows.filter(isTyped).slice(-5).map(m => ({ text: m.text.trim(), at: now })),
540 lastSaid: lastSaid ? { text: head(lastSaid.text.trim(), 600), at: now } : c.lastSaid,
541 edited: [...new Set(edited)].slice(-50),
542 touched: [...new Set(touched)].slice(-20),
543 }))
544 await refreshRepos($)
545 await sidePass($, { answer: lastSaid?.text ?? '', hasSavedNote: true, tools: [], transcript: lines.join('\n').slice(-30_000) })
546 await save($, 'idle')
547}
548
549const dropAsk = ($: $, question: string) =>
550 mutate($, c => ({ ...c, needsYou: c.needsYou.filter(a => a.question !== question) }))
551
552const answerAsk = async ($: $, ask: Ask, option: string) => {
553 await dropAsk($, ask.question)
554 // Queued by the engine until the session is idle, so pressing mid-turn is safe.
555 await $.prompt.submit({ text: `Re: "${ask.question}" — ${option}`, asUser: true })
556}
557
558const setView = ($: $, fn: (v: View) => View) => update($, view, old => fn({ ...VIEW, ...old }))
559
560const answerOf = (v: View, question: string) => v.typed[question]?.trim() || v.picked[question] || ''
561
562const withDetails = (v: View, question: string, answer: string) => {
563 const details = v.details[question]?.trim()
564 if (!details) return answer
565 return answer ? `${answer} (details: ${details})` : details
566}
567
568// With one question a choice is the whole answer; with several, choices collect until Send.
569const pickAsk = async ($: $, ask: Ask, option: string) => {
570 const { needsYou } = await readCrumbs($)
571 const v = { ...VIEW, ...(await read($, view)) }
572 // An open question is where details get added, so a choice there waits for Send.
573 if (needsYou.length <= 1 && v.expanded !== ask.question) return answerAsk($, ask, withDetails(v, ask.question, option))
574 await setView($, v => {
575 const picked = { ...v.picked }
576 if (picked[ask.question] === option) delete picked[ask.question]
577 else picked[ask.question] = option
578 return { ...v, picked }
579 })
580}
581
582const typeAsk = ($: $, ask: Ask, text: string) =>
583 setView($, v => ({ ...v, typed: { ...v.typed, [ask.question]: text } }))
584
585const submitTyped = async ($: $, ask: Ask, text: string) => {
586 const { needsYou } = await readCrumbs($)
587 const v = { ...VIEW, ...(await read($, view)) }
588 if (needsYou.length <= 1 && v.expanded !== ask.question && text.trim()) return answerAsk($, ask, withDetails(v, ask.question, text.trim()))
589 await typeAsk($, ask, text)
590}
591
592const sendAnswers = async ($: $) => {
593 const { needsYou } = await readCrumbs($)
594 const v = { ...VIEW, ...(await read($, view)) }
595 const answered = needsYou
596 .map(a => ({ ask: a, answer: withDetails(v, a.question, answerOf(v, a.question)) }))
597 .filter(a => a.answer)
598 if (answered.length === 0) {
599 $.ui.toast('Pick or type an answer first')
600 return
601 }
602 const text =
603 answered.length === 1
604 ? `Re: "${answered[0]?.ask.question}" — ${answered[0]?.answer}`
605 : ['Answers:', ...answered.map(a => `- "${a.ask.question}" — ${a.answer}`)].join('\n')
606 const done = new Set(answered.map(a => a.ask.question))
607 await mutate($, c => ({ ...c, needsYou: c.needsYou.filter(a => !done.has(a.question)) }))
608 await setView($, old => ({ ...old, picked: {}, typed: {}, details: {}, expanded: null }))
609 await $.prompt.submit({ text, asUser: true })
610}
611
612const detailAsk = ($: $, ask: Ask, text: string) =>
613 setView($, v => ({ ...v, details: { ...v.details, [ask.question]: text } }))
614
615const toggleAsk = ($: $, ask: Ask) =>
616 setView($, v => ({ ...v, expanded: v.expanded === ask.question ? null : ask.question }))
617
618const draftAsk = async ($: $, ask: Ask) => {
619 await $.prompt.fill({ text: `Re: "${ask.question}" — `, mode: 'insert' })
620 $.ui.toast('Finish your answer in the prompt box')
621}
622
623// A run saved before the brief existed has none.
624const readRun = async ($: $): Promise<TestRun> => {
625 const r = await read($, testRun)
626 return { ...r, brief: r.brief ?? '' }
627}
628
629// Kept in its own file and written only when a test changes, so a turn's save never rewrites it from an empty live list.
630const mutateTests = async ($: $, fn: (r: TestRun) => TestRun) => {
631 const now = await $.clock.now()
632 await update($, testRun, r => settleNeeds(fn(r), now))
633 const state = await readCrumbs($)
634 const target = await ensureDir($, state.tasks[0]?.title ?? 'tests')
635 await $.fs.write(`${target}/tests.json`, JSON.stringify(await read($, testRun), null, 2))
636}
637
638const readTests = async ($: $, dir: string): Promise<TestRun | null> => {
639 try {
640 return JSON.parse(await $.fs.read(`${dir}/tests.json`)) as TestRun
641 } catch {
642 return null
643 }
644}
645
646const setTestsView = ($: $, fn: (v: TestsView) => TestsView) => update($, testsView, fn)
647
648const patchTest = ($: $, id: string, fn: (t: ManualTest) => ManualTest) =>
649 mutateTests($, r => ({ ...r, tests: r.tests.map(t => (t.id === id ? fn(t) : t)) }))
650
651const logTest = async ($: $, id: string, from: Entry['from'], text: string) => {
652 const at = await $.clock.now()
653 await patchTest($, id, t => ({ ...t, log: [...t.log, { from, text: clip(text, 400), at }].slice(-30) }))
654}
655
656const showWaiting = async ($: $) => {
657 const open = (await readRun($)).waits.filter(w => w.answer === null).length
658 $.ui.status(open > 0 ? `⏳ ${open} test step${open > 1 ? 's' : ''} waiting on you` : undefined)
659}
660
661// A hook's own state reads don't see writes made while it runs, so a waiting step polls this instead.
662const answered = new Map<string, string>()
663
664const answerWait = async ($: $, waitId: string, answer: string) => {
665 answered.set(waitId, answer)
666 await mutateTests($, r => ({ ...r, waits: r.waits.map(w => (w.id === waitId ? { ...w, answer } : w)) }))
667 await showWaiting($)
668}
669
670// Where a message about a test goes: the step it is waiting on, its live tester, or the main agent.
671const routeTest = async ($: $, t: ManualTest, message: string, forMain: string) => {
672 const r = await readRun($)
673 const wait = openWait(r, t.id)
674 if (wait) return answerWait($, wait.id, message)
675 if (isLive(t) && t.agentId) {
676 await $.session.send({ to: { agentId: t.agentId }, text: message })
677 return
678 }
679 await $.prompt.submit({ text: forMain, asUser: true })
680}
681
682const replyTest = async ($: $, t: ManualTest, typed: string) => {
683 const message = typed.trim()
684 if (!message) return
685 await logTest($, t.id, 'you', message)
686 await setTestsView($, v => ({ ...v, typed: { ...v.typed, [t.id]: '' } }))
687 await routeTest($, t, message, `[manual test ${t.id} · ${t.title}] ${message}`)
688}
689
690const markTest = async ($: $, t: ManualTest, status: 'passed' | 'failed', typed: string) => {
691 const why = typed.trim()
692 await patchTest($, t.id, old => ({ ...old, status }))
693 await logTest($, t.id, 'you', `${status === 'passed' ? '✓ passed' : '✗ failed'}${why ? `: ${why}` : ''}`)
694 await setTestsView($, v => ({ ...v, typed: { ...v.typed, [t.id]: '' } }))
695 const line = `${ICON[status]} test ${t.id} ${t.title}${why ? `: ${why}` : ''}`
696 const r = await readRun($)
697 if (openWait(r, t.id) || isLive(t)) {
698 await routeTest($, t, `The person marked this test ${status}${why ? `: ${why}` : ''}. Wrap up.`, line)
699 return
700 }
701 // A pass with nothing to logTest waits for the next prompt instead of costing a turn.
702 if (status === 'passed' && !why) {
703 await mutateTests($, old => ({ ...old, unreported: [...old.unreported, line] }))
704 return
705 }
706 await $.prompt.submit({ text: `[manual test ${t.id} ${ICON[status]}] ${t.title}${why ? `: ${why}` : ''}`, asUser: true })
707}
708
709const startTester = async ($: $, t: ManualTest) => {
710 const { brief } = await readRun($)
711 const prompt = [
712 `Test ${t.id}: ${t.title}`,
713 brief ? `Brief from the planner:\n${brief}` : '',
714 t.steps.length > 0 ? `Steps:\n${t.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}` : '',
715 t.expect ? `Expected: ${t.expect}` : '',
716 t.trigger ? `Trigger: ${t.trigger}` : '',
717 (t.assumes ?? []).length > 0 ? `Assumes:\n${(t.assumes ?? []).map(a => `- ${a}`).join('\n')}` : '',
718 t.watch ? `Follow along with: ${t.watch}` : '',
719 t.log.length > 0 ? `Earlier on this test:\n${t.log.map(l => `- ${l.from}: ${l.text}`).join('\n')}` : '',
720 ]
721 .filter(Boolean)
722 .join('\n\n')
723 // Plugin hooks don't run inside an agent a plugin spawns, so the tester's own tool calls would go unanswered; the main agent spawns it instead.
724 await patchTest($, t.id, old => ({ ...old, status: 'running', agentId: null }))
725 await setTestsView($, v => ({ ...v, expanded: t.id }))
726 await $.prompt.submit({
727 text: [
728 `Start the tester for manual test ${t.id}: call the Agent tool with subagent_type "${TESTER}", run_in_background true, description "${clip(`Test ${t.id}: ${t.title}`, 40)}" and exactly this prompt. Then end your turn without walking the user through the test yourself.`,
729 '',
730 prompt,
731 ].join('\n'),
732 })
733}
734
735// Links a tester the main agent spawned to its test, by the "Test <id>:" line its prompt opens with.
736const bindTester = async ($: $, prompt: string, agentId: string) => {
737 const id = /^Test (\S+):/.exec(prompt.trim())?.[1]
738 if (!id) return
739 await patchTest($, id, old => ({ ...old, status: 'running', agentId }))
740}
741
742const planTests = async ($: $, e: Record<string, unknown>) => {
743 const raw = Array.isArray(e.tests) ? (e.tests as Record<string, unknown>[]) : []
744 const isAppending = e.mode === 'append'
745 const r = await readRun($)
746 const kept = isAppending ? r.tests : []
747 const incoming: ManualTest[] = raw
748 .filter(t => str(t?.title))
749 .map((t, i) => ({
750 id: str(t.id) || String(kept.length + i + 1),
751 title: clip(str(t.title), 60),
752 steps: listOf(t.steps),
753 expect: str(t.expect),
754 watch: str(t.watch),
755 trigger: str(t.trigger),
756 assumes: listOf(t.assumes),
757 needs: listOf(t.needs),
758 ...(str(t.model) ? { model: str(t.model) } : {}),
759 status: 'todo',
760 agentId: null,
761 log: [],
762 }))
763 if (incoming.length === 0) return { deny: 'test_plan needs at least one test with a title.' }
764 const untriggered = incoming.filter(t => t.expect && !t.trigger)
765 if (untriggered.length > 0) {
766 return {
767 deny: `Give tests ${untriggered.map(t => t.id).join(', ')} a trigger: the code that produces the expected signal and what decides whether it fires, checked against the code. Write "none: <why>" when no code path is involved.`,
768 }
769 }
770 const ids = new Set(incoming.map(t => t.id))
771 const brief = str(e.brief)
772 await mutateTests($, old => ({
773 ...old,
774 brief: brief || (isAppending ? old.brief ?? '' : ''),
775 tests: [...kept.filter(t => !ids.has(t.id)), ...incoming],
776 waits: isAppending ? old.waits : [],
777 unreported: isAppending ? old.unreported : [],
778 }))
779 void $.ui.open({ id: TESTS_PANE, title: 'tests' })
780
781 return {
782 result: `Listed ${incoming.length} test(s) in the tests pane (/tests). The user starts each one there; a tester subagent guides them and its final report reaches you when it finishes. Tell the user in one line that the tests are in the pane.`,
783 }
784}
785
786const updateTest = async ($: $, e: Record<string, unknown> & { agentId?: string }) => {
787 const brief = str(e.brief)
788 if (brief) await mutateTests($, r => ({ ...r, brief }))
789 const t = testOf(await readRun($), e)
790 if (!t) return brief && e.id === undefined ? { result: 'Updated the brief.' } : { deny: 'No such test: pass the id from test_plan.' }
791 const status = STATUSES.find(s => s === e.status)
792 const note = str(e.note)
793 const steps = listOf(e.steps)
794 const expect = str(e.expect)
795 const reply = str(e.reply)
796 const trigger = str(e.trigger)
797 const assumes = listOf(e.assumes)
798 await patchTest($, t.id, old => ({
799 ...old,
800 status: status ?? old.status,
801 steps: steps.length > 0 ? steps : old.steps,
802 expect: expect || old.expect,
803 trigger: trigger || old.trigger,
804 assumes: assumes.length > 0 ? assumes : old.assumes,
805 needs: Array.isArray(e.needs) ? listOf(e.needs) : old.needs,
806 model: str(e.model) || old.model,
807 }))
808 if (note || status) await logTest($, t.id, e.agentId === undefined ? 'claude' : 'tester', `${status && status !== t.status ? `${ICON[status]} ` : ''}${note || status}`)
809 if (!reply) return { result: `Updated test ${t.id}.` }
810 await logTest($, t.id, 'claude', `↩ ${reply}`)
811 if (!t.asking || !t.agentId) return { result: `Test ${t.id}'s tester is not waiting on a question; the reply is only in the pane log.` }
812 await patchTest($, t.id, old => ({ ...old, asking: null }))
813 const text = [
814 `The planner answered: ${reply}`,
815 steps.length > 0 ? `Steps are now:\n${steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}` : '',
816 expect ? `Expected now: ${expect}` : '',
817 ]
818 .filter(Boolean)
819 .join('\n\n')
820
821 // A tester the plugin resumes from inside this hook gets no answer from this hook again, so the main agent sends it.
822 return { result: `Now resume test ${t.id}'s tester: call SendMessage with to "${t.agentId}" and exactly this message:\n\n${text}` }
823}
824
825const awaitUser = async ($: $, e: Record<string, unknown> & { agentId?: string }, signal: AbortSignal) => {
826 const instruction = str(e.instruction)
827 const steps = listOf(e.steps).map(s => clip(s, 120))
828 if (!instruction && steps.length === 0) return { deny: 'await_user needs an instruction or steps.' }
829 const t = testOf(await readRun($), e)
830 const now = await $.clock.now()
831 const wait: Wait = {
832 id: `${now}-${Math.random().toString(36).slice(2, 7)}`,
833 testId: t?.id ?? null,
834 step: typeof e.step === 'number' ? e.step : null,
835 instruction: clip(instruction, 200),
836 steps,
837 options: listOf(e.options).slice(0, 4).map(o => clip(o, 30)),
838 at: now,
839 answer: null,
840 }
841 await mutateTests($, r => ({ ...r, waits: [...r.waits.filter(w => w.answer === null || w.testId !== wait.testId), wait] }))
842 if (t) await setTestsView($, v => ({ ...v, expanded: t.id }))
843 await showWaiting($)
844 $.ui.toast(t ? `Test ${t.id} is waiting on you` : 'A test step is waiting on you')
845
846 // Waiting on a button inside the hook counts against its budget; time inside a $ call does not.
847 let answer: string | null = null
848 while (answer === null && !signal.aborted && (await $.clock.now()) - now < WAIT_LIMIT_MS) {
849 await $.process.run(['sleep', '1'])
850 answer = answered.get(wait.id) ?? null
851 }
852 answered.delete(wait.id)
853 await mutateTests($, r => ({ ...r, waits: r.waits.filter(w => w.id !== wait.id) }))
854 await showWaiting($)
855 if (answer === null) {
856 return { result: signal.aborted ? 'Interrupted before the person answered.' : 'No answer after 30 minutes. Ask again, or end the test as blocked.' }
857 }
858
859 return { result: `The person answered: ${answer}` }
860}
861
862// The question reaches the main agent as the tester's hand-back; a reply resumes the same tester.
863const askPlanner = async ($: $, e: Record<string, unknown> & { agentId?: string }) => {
864 const question = str(e.question)
865 if (!question) return { deny: 'ask_planner needs a question.' }
866 const t = testOf(await readRun($), e)
867 if (!t) return { deny: 'No such test: pass the id from the brief.' }
868 await patchTest($, t.id, old => ({ ...old, asking: clip(question, 200) }))
869 await logTest($, t.id, 'tester', `? ${question}`)
870
871 return {
872 result: [
873 `Your question is with the planner. End your turn now, with this as your whole final message: "[tester asks · manual test ${t.id} · ${t.title}] ${question}".`,
874 'Leave your background captures running: the planner fixes what it can and its reply resumes you in this same run.',
875 ].join(' '),
876 }
877}
878
879const testerDone = async ($: $, agentId: string, answer: string, isAborted: boolean) => {
880 const t = (await readRun($)).tests.find(x => x.agentId === agentId)
881 if (!t) return
882 // Stopped to ask the planner: still this test's tester, resumed by the reply.
883 if (t.asking && !isAborted) return
884 const status: TestStatus = t.status === 'running' ? 'todo' : t.status
885 await patchTest($, t.id, old => ({ ...old, status, agentId: null, asking: null }))
886 await mutateTests($, r => ({ ...r, waits: r.waits.filter(w => w.testId !== t.id || w.answer !== null) }))
887 await showWaiting($)
888 // Claude Code hands the tester's final report to the main agent itself; the pane just records it.
889 await logTest($, t.id, 'tester', answer || (isAborted ? 'stopped before it reported' : 'ended without a summary'))
890}
891
892const TEST_TOOLS = [TOOL('test_plan'), TOOL('test_update'), TOOL('await_user'), TOOL('ask_planner')]
893
894const testTool = async ($: $, e: Record<string, unknown> & { tool: unknown; agentId?: string }, signal: AbortSignal) => {
895 if (e.tool === TOOL('test_plan')) return planTests($, e)
896 if (e.tool === TOOL('test_update')) return updateTest($, e)
897 if (e.tool === TOOL('ask_planner')) return askPlanner($, e)
898
899 return awaitUser($, e, signal)
900}
901
902const OWN_TOOLS = [SAVE_NOTE, EDIT_NOTE, ...TEST_TOOLS]
903
904const briefInput = (tool: string, input: unknown): Record<string, string> | null => {
905 const i = (input ?? {}) as Record<string, unknown>
906 if (tool === SAVE_NOTE) return { title: str(i.title) }
907 if (tool === EDIT_NOTE) {
908 const n = Array.isArray(i.edits) ? i.edits.length : 0
909 return { note: str(i.id), ...(str(i.markdown) ? { markdown: 'rewritten' } : { edits: `${n} edit${n === 1 ? '' : 's'}` }) }
910 }
911 if (tool === TOOL('test_plan')) {
912 const n = Array.isArray(i.tests) ? i.tests.length : 0
913 return { tests: `${n} test${n === 1 ? '' : 's'}`, ...(i.mode === 'append' ? { mode: 'append' } : {}) }
914 }
915 if (tool === TOOL('test_update')) {
916 const text = str(i.reply) || str(i.note)
917 const changed = ['steps', 'expect', 'brief'].filter(k => i[k] !== undefined)
918 return {
919 ...(str(i.id) ? { id: str(i.id) } : {}),
920 ...(str(i.status) ? { status: str(i.status) } : {}),
921 ...(text ? { [str(i.reply) ? 'reply' : 'note']: clip(text, 60) } : {}),
922 ...(changed.length > 0 ? { changed: changed.join(', ') } : {}),
923 }
924 }
925 if (tool === TOOL('ask_planner')) return { question: clip(str(i.question), 60) }
926 return null
927}
928
929// Results end with a note to the model that the person needn't read.
930const firstSentence = (output: unknown): unknown => {
931 const cut = (s: string) => s.match(/^[\s\S]*?\.(?=\s|$)/)?.[0] ?? s
932 if (typeof output === 'string') return cut(output)
933 if (Array.isArray(output))
934 return output.map(b => (b && typeof b === 'object' && typeof b.text === 'string' ? { ...b, text: cut(b.text) } : b))
935 return output
936}
937
938// Passes with nothing to say ride along with the next prompt instead of costing a turn each.
939const takePasses = async ($: $): Promise<string | null> => {
940 const { unreported } = await readRun($)
941 if (unreported.length === 0) return null
942 await mutateTests($, r => ({ ...r, unreported: [] }))
943
944 return `Manual tests the user passed since your last turn:\n${unreported.join('\n')}`
945}
946
947export const register: Register = (on, options) => {
948 cfg.model = String(options.model ?? 'haiku')
949 cfg.testerModel = String(options.testerModel ?? 'sonnet')
950 cfg.retentionDays = Number(options.retentionDays ?? 30)
951 cfg.archiveDays = Number(options.archiveDays ?? 60)
952 cfg.isArchiving = options.archive !== false
953
954 on('session.start', async ($, e, next) => {
955 const started = await next(e)
956 sessionId = await $.session.id()
957 const place = await locate($)
958 await update($, where, () => place)
959
960 const known = await $.store.get(`dir:${sessionId}`)
961 dir = typeof known === 'string' ? known : null
962 if (dir) await restoreFrom($, dir)
963
964 await $.command.register({
965 name: 'whereami',
966 description: 'Show or hide the breadcrumbs pane; `/whereami clean` tidies old session notes',
967 })
968 await $.tool.register({
969 name: 'save_note',
970 description:
971 'Save an explanation, summary or walkthrough the user asked for as a markdown note in their breadcrumbs pane, so it does not get buried in the transcript. The pane reflows the note to its width, tables included.',
972 inputSchema: {
973 type: 'object',
974 properties: {
975 title: { type: 'string', description: 'A short title, under 60 characters' },
976 markdown: { type: 'string', description: 'The full explanation in markdown' },
977 },
978 required: ['title', 'markdown'],
979 },
980 })
981 await $.tool.register({
982 name: 'edit_note',
983 description:
984 'Change a note saved with save_note in place, sending only the text that changes. Each edit replaces one exact, unique piece of the note; markdown instead replaces the whole body.',
985 inputSchema: {
986 type: 'object',
987 properties: {
988 id: { type: 'string', description: 'The id save_note returned, or the note\'s title' },
989 edits: {
990 type: 'array',
991 items: {
992 type: 'object',
993 properties: {
994 old: { type: 'string', description: 'Text in the note, exactly as written and found once' },
995 new: { type: 'string', description: 'What replaces it' },
996 },
997 required: ['old', 'new'],
998 },
999 },
1000 markdown: { type: 'string', description: 'A whole new body, for a rewrite' },
1001 },
1002 required: ['id'],
1003 },
1004 })
1005 await $.command.register({ name: 'tests', description: 'Show or hide the manual tests pane' })
1006 try {
1007 await $.agent.register({
1008 name: 'tester',
1009 description: 'Guides the person through one manual end-to-end test. Spawn it only when the tests pane asks you to, with the prompt it gives.',
1010 prompt: TESTER_PROMPT,
1011 })
1012 } catch (error) {
1013 // Without the tester the rest of the pane still works; Start reports the missing agent.
1014 $.ui.log(`breadcrumbs: tester agent not registered (${error instanceof Error ? error.message : String(error)})`)
1015 }
1016 await $.tool.register({
1017 name: 'test_plan',
1018 description: 'List manual or end-to-end tests the user has to run by hand in their tests pane, each with its steps. The user starts each test from the pane.',
1019 inputSchema: {
1020 type: 'object',
1021 properties: {
1022 brief: {
1023 type: 'string',
1024 description: 'Read by every tester before its test: the change, the environment, how to observe results, what is already verified',
1025 },
1026 tests: {
1027 type: 'array',
1028 items: {
1029 type: 'object',
1030 properties: {
1031 id: { type: 'string', description: 'Short id, e.g. "1"; defaults to its position' },
1032 title: { type: 'string', description: 'Under 40 characters' },
1033 steps: { type: 'array', items: { type: 'string' }, description: 'What the person does, each under 70 characters' },
1034 expect: { type: 'string', description: 'What should happen' },
1035 watch: { type: 'string', description: 'Logs or watchers to follow along with' },
1036 trigger: {
1037 type: 'string',
1038 description: 'The code that produces the expected signal and what decides whether it fires, e.g. "sent by completeRide() in rides.ts, driver app only"; required with expect',
1039 },
1040 assumes: { type: 'array', items: { type: 'string' }, description: 'What the plan relies on that the code cannot show' },
1041 needs: { type: 'array', items: { type: 'string' }, description: 'Ids of tests that must pass first' },
1042 model: { type: 'string', description: 'The tester\'s model for this test, e.g. opus; leave out for the default' },
1043 },
1044 required: ['title'],
1045 },
1046 },
1047 mode: { type: 'string', enum: ['replace', 'append'], description: 'replace (default) starts a new list' },
1048 },
1049 required: ['tests'],
1050 },
1051 })
1052 await $.tool.register({
1053 name: 'test_update',
1054 description:
1055 'Change a manual test: its status (todo, running, passed, failed, blocked, retest), a short note shown in the pane, new steps or a new expect, or a reply to its tester\'s question. With brief and no id, replaces the brief testers read.',
1056 inputSchema: {
1057 type: 'object',
1058 properties: {
1059 id: { type: 'string', description: 'The test id; a tester may leave it out' },
1060 status: { type: 'string', enum: [...STATUSES] },
1061 note: { type: 'string', description: 'One line' },
1062 steps: { type: 'array', items: { type: 'string' } },
1063 expect: { type: 'string' },
1064 brief: { type: 'string', description: 'The whole new brief' },
1065 trigger: { type: 'string' },
1066 assumes: { type: 'array', items: { type: 'string' } },
1067 needs: { type: 'array', items: { type: 'string' } },
1068 model: { type: 'string' },
1069 reply: { type: 'string', description: 'Your answer to the tester\'s ask_planner question; the result says how to send it' },
1070 },
1071 },
1072 })
1073 await $.tool.register({
1074 name: 'await_user',
1075 description:
1076 'Show the person steps to do by hand in the tests pane and wait until they answer: an option, Done, Can\'t or a reply. Returns their answer.',
1077 inputSchema: {
1078 type: 'object',
1079 properties: {
1080 id: { type: 'string', description: 'The test id; a tester may leave it out' },
1081 step: { type: 'number', description: 'The number of the first step shown' },
1082 steps: { type: 'array', items: { type: 'string' }, description: 'Steps to do in a row before answering, each under 100 characters' },
1083 instruction: { type: 'string', description: 'A single step, or the question to answer after the steps; under 100 characters' },
1084 options: { type: 'array', items: { type: 'string' }, description: '2 to 4 short answers to the question; they replace Done and Can\'t' },
1085 },
1086 },
1087 })
1088 await $.tool.register({
1089 name: 'ask_planner',
1090 description:
1091 'For a tester: ask the main agent, who planned the test, when a step is impossible, the setup breaks or a fixable failure shows up. Then end your turn with the question; the planner\'s reply, maybe with new steps, resumes you.',
1092 inputSchema: {
1093 type: 'object',
1094 properties: {
1095 id: { type: 'string', description: 'The test id; a tester may leave it out' },
1096 question: { type: 'string', description: 'What you saw, and what you need from the planner' },
1097 },
1098 required: ['question'],
1099 },
1100 })
1101 await showWaiting($)
1102
1103 if (e.isInteractive && options.panel !== 'command') void $.ui.open({ id: PANE, title: 'breadcrumbs' })
1104 $.clock.every(30_000, () => $.ui.invalidate('ui.render'))
1105 $.clock.after(0, () => refreshRepos($))
1106 $.clock.after(0, () => backfill($))
1107 // A hot reload drops the old module's timers, including a pass it just queued; poll so this copy picks it up.
1108 $.clock.every(15_000, () => runPending($))
1109
1110 const now = await $.clock.now()
1111 const lastClean = Number((await $.store.get('lastClean')) ?? 0)
1112 if (now - lastClean > 86_400_000) {
1113 await $.store.set('lastClean', now)
1114 $.clock.after(5000, async () => {
1115 const { root, plan } = await clean($)
1116 await applyClean($, root, plan, now)
1117 const count = plan.archive.length + plan.remove.length
1118 if (count > 0) $.ui.log(`breadcrumbs: tidied ${count} old session folder(s)`, { to: 'debug' })
1119 })
1120 }
1121
1122 return started
1123 })
1124
1125 on('classic.SessionStart', async ($, e, next) => {
1126 const done = await next(e)
1127 if (e.transcript_path) await adoptResumed($, e.transcript_path)
1128
1129 return done
1130 })
1131
1132 on('prompt.compose', async ($, e, next) => {
1133 const composed = await next(e)
1134
1135 return {
1136 sections: [
1137 ...composed.sections,
1138 { id: 'breadcrumbs:notes', text: NOTE_GUIDANCE, scope: 'session' },
1139 { id: 'breadcrumbs:tests', text: TESTS_GUIDANCE, scope: 'session' },
1140 ],
1141 }
1142 })
1143
1144 on('prompt.submit', async ($, e, next) => {
1145 const isPerson = e.origin.kind === 'composer' || (e.origin.kind === 'plugin' && e.origin.name === 'breadcrumbs')
1146 if (isPerson && e.text.trim() && !e.text.trimStart().startsWith('/')) {
1147 const now = await $.clock.now()
1148 // A new prompt usually answers what was pending; the next side pass re-asks anything still open.
1149 await mutate($, c => ({ ...c, needsYou: [], prompts: [...c.prompts, { text: e.text.trim(), at: now }].slice(-5) }))
1150 await setView($, v => ({ ...v, picked: {}, typed: {}, details: {}, expanded: null }))
1151 if (e.turnId === undefined) {
1152 turnTools = []
1153 hasSavedNote = false
1154 }
1155 }
1156 const passes = await takePasses($)
1157
1158 return next(passes ? { ...e, context: [...(e.context ?? []), passes] } : e)
1159 })
1160
1161 // A subagent's call to a plugin tool is answered only by a hook matched on that tool.
1162 for (const tool of TEST_TOOLS) {
1163 on('tool.call', { tool: tool as typeof SAVE_NOTE }, async ($, e, next) => {
1164 try {
1165 return await testTool($, e as Record<string, unknown> & { tool: unknown; agentId?: string }, next.signal)
1166 } catch (error) {
1167 return { deny: `breadcrumbs: ${error instanceof Error ? error.message : String(error)}` }
1168 }
1169 })
1170 }
1171
1172 on('tool.call', { tool: SAVE_NOTE }, async ($, e) => {
1173 const title = typeof e.title === 'string' ? e.title : 'Note'
1174 const markdown = typeof e.markdown === 'string' ? e.markdown : ''
1175 if (!markdown.trim()) return { deny: 'save_note needs a non-empty markdown body.' }
1176 hasSavedNote = true
1177 const note = await saveNote($, title, markdown)
1178
1179 return {
1180 result: `Saved "${note.title}" to the breadcrumbs pane (${note.file}). Its id is ${note.id}; change it later with edit_note. Tell the user in one line where to find it.`,
1181 }
1182 })
1183
1184 on('tool.call', { tool: EDIT_NOTE as typeof SAVE_NOTE }, async ($, e) => {
1185 const i = e as Record<string, unknown>
1186 const edits = (Array.isArray(i.edits) ? i.edits : []).filter(
1187 (x): x is NoteEdit => typeof x?.old === 'string' && x.old !== '' && typeof x?.new === 'string',
1188 )
1189 const markdown = str(i.markdown)
1190 if (!str(i.id) || (edits.length === 0 && !markdown)) return { deny: 'edit_note needs an id and either edits or markdown.' }
1191 const edited = await editNote($, str(i.id), edits, markdown)
1192 if ('error' in edited) return { deny: edited.error }
1193 hasSavedNote = true
1194
1195 return { result: `Updated "${edited.note.title}" in the breadcrumbs pane. Tell the user in one line that it changed.` }
1196 })
1197
1198 on('tool.call', async ($, e, next) => {
1199 if (e.agentId !== undefined || OWN_TOOLS.includes(String(e.tool))) return next(e)
1200 const label = toolLabel(String(e.tool), e as Record<string, unknown>)hooks/markdown.ts 56 lines1import type { Crumbs, Where } from '../types'
2import type { Repo } from '../types'
3import { asks, basename, dones, stamp } from './text'
4
5export type Saved = Crumbs & { session: string; worktree: string; updatedAt: number; transcript?: string }
6
7export const repoState = (r: Repo): string => {
8 const parts: string[] = []
9 if (r.changed > 0) parts.push(`${r.changed} uncommitted`)
10 if (!r.hasUpstream) parts.push('no upstream')
11 else if (r.ahead > 0) parts.push(`↑${r.ahead} unpushed`)
12 if (r.behind > 0) parts.push(`↓${r.behind} behind`)
13
14 return parts.length > 0 ? parts.join(' · ') : '✓ pushed'
15}
16
17export const contextMarkdown = (saved: Saved, where: Where, status: string): string => {
18 const task = saved.tasks[0]?.title ?? 'No task yet'
19 const lines = [
20 '---',
21 `session: ${saved.session}`,
22 `worktree: ${saved.worktree}`,
23 `repo: ${where.repo}`,
24 `branch: ${where.branch}`,
25 `status: ${status}`,
26 `updated: ${stamp(saved.updatedAt)}`,
27 '---',
28 '',
29 `# ${task}`,
30 '',
31 ]
32 if (saved.tasks.length > 1) {
33 lines.push('Earlier in this session:', ...saved.tasks.slice(1).map(t => `- ${t.title}`), '')
34 }
35 const waiting = asks(saved.needsYou)
36 if (waiting.length > 0) lines.push('## Needs you', '', ...waiting.map(a => `- [ ] ${a.question}`), '')
37 const done = dones(saved.done)
38 if (done.length > 0) lines.push('## Done', '', ...done.flatMap(d => [`- ${d.title}`, ...d.items.map(i => ` - ${i}`)]), '')
39 const repos = saved.repos ?? []
40 if (repos.length > 0) {
41 lines.push('## Repos', '', ...repos.map(r => `- ${basename(r.root)} · ${r.branch} · ${repoState(r)}`), '')
42 }
43 const prompt = saved.prompts.at(-1)
44 if (prompt) lines.push('## You asked', '', ...prompt.text.split('\n').map(l => `> ${l}`), '')
45 if (saved.lastSaid) lines.push('## Claude last said', '', saved.lastSaid.text, '')
46 if (saved.notes.length > 0) {
47 lines.push('## Notes', '', ...saved.notes.map(n => `- [${n.title}](${basename(n.file)})`), '')
48 }
49 if (saved.decided.length > 0) lines.push('## Decided', '', ...saved.decided.map(d => `- ${d}`), '')
50 if (saved.tried.length > 0) {
51 lines.push('## Tried', '', ...saved.tried.map(t => `- ${t.isOk ? '✓' : '✗'} ${t.text}`), '')
52 }
53
54 return lines.join('\n')
55}
56hooks/state.ts 25 lines1import type { Crumbs } from '../types'
2import { asks, dones } from './text'
3
4const list = <T>(value: T[] | undefined): T[] => (Array.isArray(value) ? value : [])
5
6export const normalize = (value: Partial<Crumbs> | null | undefined): Crumbs => {
7 const c = value ?? {}
8
9 return {
10 tasks: list(c.tasks),
11 prompts: list(c.prompts),
12 activity: c.activity ?? null,
13 lastSaid: c.lastSaid ?? null,
14 notes: list(c.notes),
15 decided: list(c.decided),
16 tried: list(c.tried),
17 needsYou: asks(c.needsYou),
18 done: dones(c.done),
19 edited: list(c.edited),
20 touched: list(c.touched),
21 repos: list(c.repos),
22 asking: c.asking ?? null,
23 }
24}
25hooks/tests.ts 69 lines1import type { ManualTest, TestRun, TestStatus } from '../types'
2
3// Prompts, tool schemas and pure helpers for the manual tests pane; everything that touches $ is in register.tsx.
4export const TESTS_PANE = 'tests'
5export const TOOL = (name: string) => `mcp__breadcrumbs__${name}`
6export const TESTER = 'breadcrumbs:tester'
7export const WAIT_LIMIT_MS = 30 * 60_000
8
9export const TESTS_GUIDANCE = [
10 `When the user needs to run manual or end-to-end tests that you cannot run yourself, list them with the ${TOOL('test_plan')} tool instead of writing the steps in your reply.`,
11 'The user starts each test from their tests pane, where a tester subagent guides them through it and sends you its final report when it finishes.',
12 'You are the planner. Give test_plan a brief that every tester reads first and trusts without re-checking: what the change does, the environment (devices, builds, servers and how to reach them), how to observe results (log tags, commands, signals known to mislead) and what is already verified.',
13 'Before listing, make sure every step can actually happen, checked against the code and the setup; testers do not re-check. Do the preconditions you can check yourself (builds, deploys, registrations) instead of making them a test.',
14 'For each test, trigger names the code that produces the expected signal and what decides whether it fires ("ride_ended is sent by completeRide() in rides.ts, from the driver app only"), so a step never takes a path that skips it; assumes lists what you could not check in the code (one ride per driver at a time, how another agent will behave); needs lists ids of tests that must pass first. Testers run on a fast default model; set model "opus" on a test only when the tester will have to diagnose something subtle.',
15 'Group by physical setup: one setup such as a single ride can verify several behaviours, so prefer fewer, longer tests. Steps are only what the person does by hand; checks you or the tester run go in expect or watch, and each expect names a signal that tells a pass from a fail.',
16 'After each tester report, revise the tests still to run with test_update: new steps or expect, status blocked with the reason when one can no longer be done or failed when an earlier result already decides it, and new facts added to the brief.',
17 'A tester may hand back a question instead of a report ("[tester asks · manual test …] …"). It is paused with its setup still running, so fix what it needs if you can (rebuild, restart a server, sync data), then answer with test_update: its id and reply, plus new steps or expect if they change, and send the message it gives you to the tester with SendMessage, which resumes it. Reply "end the test" when it should stop.',
18 'When the tests pane asks you to start a tester, spawn it exactly as asked and end your turn. Act on testers\' reports as the orchestrator: fix what failed, then mark the test for a retest with test_update. Do not walk the user through a listed test yourself unless they ask.',
19 'The pane can be narrow: keep titles under 40 characters and each step under 70.',
20].join(' ')
21
22export const TESTER_PROMPT = [
23 'You guide a person through one manual end-to-end test. You cannot see their screen or device; they do every step by hand.',
24 '- The planner wrote the brief and the test and already checked the steps can be done. Trust the brief: do not re-verify the environment, tooling or facts it states; start the test straight away and read code only to explain a result.',
25 `- When a step turns out to be impossible, the setup breaks (a watcher or server dies, data or config is missing) or a result fails in a way the planner could fix, call ${TOOL('ask_planner')} with what you saw and what you need, then end your turn as it says. The planner fixes things or revises the steps, and its reply resumes you in the same run with the same setup.`,
26 '- Do not swap in a different path to the same end on your own. End the test as blocked or failed only when the planner says to or does not answer.',
27 '- Before the first step, start one background capture of the logs or watchers named in the test and note its PID. Check it at checkpoints and analyse it in full at the end.',
28 `- Hand the person steps with ${TOOL('await_user')}. Put a run of steps they can do without stopping in one call (steps, step = the first one's number), and stop only at a checkpoint: where the result decides whether the remaining steps still make sense, or where only the person can see the result (screen, notification tray).`,
29 '- When you ask the person something, put the question in instruction and give 2 to 4 short answer options ("Gone", "Still there", "Not sure"). Leave options out for plain actions; they then get Done and Can\'t.',
30 `- Post short observations with ${TOOL('test_update')} (note), so the person sees what you saw.`,
31 '- When an expected signal does not show, check the test\'s trigger first: say whether the steps reached that code path, and if an assumption turned out wrong, say which.',
32 '- Do not edit code or config. Diagnose and propose; the main agent decides on fixes.',
33 '- Before your final report, kill every background process you started; keep them while you wait on ask_planner.',
34 '- When the test is settled, call test_update with status passed, failed or blocked and a one-line note, then end with at most five lines: the result, the evidence, the likely cause if it failed, and anything you learned that affects the tests still to run.',
35].join('\n')
36
37export const ICON: Record<TestStatus, string> = { todo: '·', running: '●', passed: '✓', failed: '✗', blocked: '⊘', retest: '⟳' }
38
39export const STATUSES: readonly TestStatus[] = ['todo', 'running', 'passed', 'failed', 'blocked', 'retest']
40
41export const listOf = (value: unknown): string[] =>
42 Array.isArray(value) ? value.filter((s): s is string => typeof s === 'string' && s.trim() !== '').map(s => s.trim()) : []
43
44export const str = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
45
46export const testOf = (r: TestRun, e: { id?: unknown; agentId?: string }) =>
47 r.tests.find(t => (typeof e.id === 'string' && e.id ? t.id === e.id : e.agentId !== undefined && t.agentId === e.agentId))
48
49// Ids this test needs that have not passed yet.
50export const unmet = (r: TestRun, t: ManualTest): string[] =>
51 (t.needs ?? []).filter(id => r.tests.find(x => x.id === id)?.status !== 'passed')
52
53// A test whose prerequisite failed or was blocked cannot run either.
54export const settleNeeds = (r: TestRun, at: number): TestRun => ({
55 ...r,
56 tests: r.tests.map(t => {
57 if (t.status !== 'todo' && t.status !== 'retest') return t
58 const dead = r.tests.find(x => (t.needs ?? []).includes(x.id) && (x.status === 'failed' || x.status === 'blocked'))
59 if (!dead) return t
60 const text = `${ICON.blocked} needs test ${dead.id}, which ${dead.status === 'failed' ? 'failed' : 'is blocked'}`
61 return { ...t, status: 'blocked', log: [...t.log, { from: 'claude', text, at }].slice(-30) }
62 }),
63})
64
65export const isLive = (t: ManualTest) => t.status === 'running' && t.agentId !== null
66
67export const openWait = (r: TestRun, testId: string) => r.waits.find(w => w.testId === testId && w.answer === null)
68
69hooks/rich.ts 464 lines1// Lays markdown out as styled, pre-wrapped lines for the terminal pane, which has no Markdown element of its own worth reading.
2
3export type Style = {
4 color?: string
5 bold?: boolean
6 italic?: boolean
7 underline?: boolean
8 strikethrough?: boolean
9}
10export type Span = { text: string; style: Style; href?: string }
11export type Line = Span[]
12
13// Theme keys, not raw colors, so the pane follows whichever theme the person picked.
14const S = {
15 text: { color: 'text' },
16 h1: { color: 'claude', bold: true, underline: true },
17 h2: { color: 'claude', bold: true },
18 h3: { color: 'suggestion', bold: true },
19 h4: { color: 'text', bold: true },
20 strong: { bold: true },
21 em: { italic: true },
22 del: { color: 'inactive', strikethrough: true },
23 code: { color: 'permission' },
24 link: { color: 'suggestion', underline: true },
25 bullet: { color: 'claude' },
26 number: { color: 'claude' },
27 done: { color: 'success' },
28 todo: { color: 'inactive' },
29 quote: { color: 'subtle', italic: true },
30 bar: { color: 'subtle' },
31 rule: { color: 'subtle' },
32 th: { color: 'claude', bold: true },
33 block: { color: 'text' },
34 lang: { color: 'inactive', italic: true },
35} satisfies Record<string, Style>
36
37const CODE = {
38 comment: { color: 'inactive', italic: true },
39 string: { color: 'success' },
40 number: { color: 'warning' },
41 keyword: { color: 'merged' },
42 call: { color: 'suggestion' },
43 type: { color: 'claude' },
44 operator: { color: 'permission' },
45} satisfies Record<string, Style>
46
47// MARK: Blocks
48
49type Align = 'left' | 'center' | 'right'
50type Item = { marker: string; task?: boolean; blocks: Block[] }
51type Block =
52 | { kind: 'heading'; level: number; text: string }
53 | { kind: 'paragraph'; text: string }
54 | { kind: 'code'; lang: string; lines: string[] }
55 | { kind: 'quote'; blocks: Block[] }
56 | { kind: 'list'; ordered: boolean; items: Item[] }
57 | { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
58 | { kind: 'rule' }
59
60const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)/
61const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
62const RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/
63const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
64const QUOTE = /^ {0,3}>\s?(.*)$/
65const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
66
67const cells = (row: string) =>
68 row
69 .trim()
70 .replace(/^\||(?<!\\)\|$/g, '')
71 .split(/(?<!\\)\|/)
72 .map(c => c.trim().replace(/\\\|/g, '|'))
73
74const startsBlock = (line: string, next: string | undefined) =>
75 FENCE.test(line) ||
76 HEADING.test(line) ||
77 RULE.test(line) ||
78 QUOTE.test(line) ||
79 ITEM.test(line) ||
80 (line.includes('|') && next !== undefined && TABLE_RULE.test(next) && next.includes('-'))
81
82const indentOf = (line: string) => (/^\s*/.exec(line)?.[0] ?? '').replace(/\t/g, ' ').length
83
84export const parse = (markdown: string): Block[] => {
85 const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
86 const blocks: Block[] = []
87 let i = 0
88 while (i < lines.length) {
89 const line = lines[i] ?? ''
90 if (!line.trim()) {
91 i++
92 continue
93 }
94 const fence = FENCE.exec(line)
95 if (fence) {
96 const close = new RegExp(`^ {0,3}${fence[1]?.[0] === '`' ? '`' : '~'}{${fence[1]?.length ?? 3},}\\s*$`)
97 const body: string[] = []
98 i++
99 while (i < lines.length && !close.test(lines[i] ?? '')) body.push(lines[i++] ?? '')
100 i++
101 blocks.push({ kind: 'code', lang: fence[2] ?? '', lines: body })
102 continue
103 }
104 const heading = HEADING.exec(line)
105 if (heading) {
106 blocks.push({ kind: 'heading', level: heading[1]?.length ?? 1, text: heading[2] ?? '' })
107 i++
108 continue
109 }
110 if (RULE.test(line)) {
111 blocks.push({ kind: 'rule' })
112 i++
113 continue
114 }
115 if (QUOTE.test(line)) {
116 const body: string[] = []
117 while (i < lines.length && (lines[i] ?? '').trim() && QUOTE.test(lines[i] ?? '')) body.push(QUOTE.exec(lines[i++] ?? '')?.[1] ?? '')
118 blocks.push({ kind: 'quote', blocks: parse(body.join('\n')) })
119 continue
120 }
121 const next = lines[i + 1]
122 if (line.includes('|') && next !== undefined && TABLE_RULE.test(next) && next.includes('-')) {
123 const header = cells(line)
124 const align = cells(next).map((c): Align => (c.endsWith(':') ? (c.startsWith(':') ? 'center' : 'right') : 'left'))
125 const rows: string[][] = []
126 i += 2
127 while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim()) rows.push(cells(lines[i++] ?? ''))
128 blocks.push({ kind: 'table', header, align, rows })
129 continue
130 }
131 const item = ITEM.exec(line)
132 if (item) {
133 const base = indentOf(line)
134 const ordered = /\d/.test(item[2] ?? '')
135 const items: Item[] = []
136 while (i < lines.length) {
137 const head = ITEM.exec(lines[i] ?? '')
138 if (!head || indentOf(lines[i] ?? '') !== base || /\d/.test(head[2] ?? '') !== ordered) break
139 const body = [head[3] ?? '']
140 const contentIndent = base + (head[2]?.length ?? 1) + 1
141 i++
142 while (i < lines.length) {
143 const l = lines[i] ?? ''
144 if (!l.trim()) {
145 const after = lines[i + 1] ?? ''
146 if (after.trim() && indentOf(after) >= contentIndent) {
147 body.push('')
148 i++
149 continue
150 }
151 break
152 }
153 if (indentOf(l) < contentIndent && (ITEM.test(l) || startsBlock(l, lines[i + 1]))) break
154 body.push(indentOf(l) >= contentIndent ? l.replace(/\t/g, ' ').slice(contentIndent) : l.trim())
155 i++
156 }
157 const task = /^\[([ xX])\]\s+/.exec(body[0] ?? '')
158 if (task) body[0] = (body[0] ?? '').slice(task[0].length)
159 items.push({ marker: head[2] ?? '-', task: task ? task[1] !== ' ' : undefined, blocks: parse(body.join('\n')) })
160 while (i < lines.length && !(lines[i] ?? '').trim() && ITEM.test(lines[i + 1] ?? '') && indentOf(lines[i + 1] ?? '') === base) i++
161 }
162 blocks.push({ kind: 'list', ordered, items })
163 continue
164 }
165 const text = [line.trim()]
166 i++
167 while (i < lines.length && (lines[i] ?? '').trim() && !startsBlock(lines[i] ?? '', lines[i + 1])) text.push((lines[i++] ?? '').trim())
168 blocks.push({ kind: 'paragraph', text: text.join(' ') })
169 }
170
171 return blocks
172}
173
174// MARK: Inline
175
176const merge = (a: Style, b: Style): Style => ({ ...a, ...b })
177
178export const inline = (text: string, base: Style): Span[] => {
179 const out: Span[] = []
180 const push = (t: string, style: Style, href?: string) => {
181 if (!t) return
182 const last = out.at(-1)
183 if (last && !href && !last.href && JSON.stringify(last.style) === JSON.stringify(style)) last.text += t
184 else out.push(href ? { text: t, style, href } : { text: t, style })
185 }
186 let i = 0
187 while (i < text.length) {
188 const rest = text.slice(i)
189 const ch = rest[0] ?? ''
190 if (ch === '\\' && /^\\[\\`*_{}[\]()#+\-.!|~<>]/.test(rest)) {
191 push(rest[1] ?? '', base)
192 i += 2
193 continue
194 }
195 if (ch === '`') {
196 const run = /^`+/.exec(rest)?.[0] ?? '`'
197 const end = rest.indexOf(run, run.length)
198 if (end > 0) {
199 push(rest.slice(run.length, end).trim(), merge(base, S.code))
200 i += end + run.length
201 continue
202 }
203 }
204 const link = /^\[((?:[^\]\\]|\\.)*)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
205 if (link) {
206 for (const s of inline(link[1] ?? '', merge(base, S.link))) push(s.text, s.style, link[2] || undefined)
207 i += link[0].length
208 continue
209 }
210 const auto = /^<(https?:\/\/[^>\s]+)>/.exec(rest) ?? /^(https?:\/\/[^\s<]*[^\s<.,;:!?)'"])/.exec(rest)
211 if (auto && (i === 0 || /[\s(]/.test(text[i - 1] ?? ''))) {
212 push(auto[1] ?? '', merge(base, S.link), auto[1])
213 i += auto[0].length
214 continue
215 }
216 const strike = /^~~(?=\S)([\s\S]*?\S)~~/.exec(rest)
217 if (strike) {
218 for (const s of inline(strike[1] ?? '', merge(base, S.del))) push(s.text, s.style, s.href)
219 i += strike[0].length
220 continue
221 }
222 const strong = /^(\*\*|__)(?=\S)([\s\S]*?\S)\1/.exec(rest)
223 if (strong && (ch === '*' || !/\w/.test(text[i - 1] ?? ''))) {
224 for (const s of inline(strong[2] ?? '', merge(base, S.strong))) push(s.text, s.style, s.href)
225 i += strong[0].length
226 continue
227 }
228 const em = /^([*_])(?=[^\s*_])([\s\S]*?[^\s\\])\1(?![*_])/.exec(rest)
229 if (em && (ch === '*' || !/\w/.test(text[i - 1] ?? ''))) {
230 for (const s of inline(em[2] ?? '', merge(base, S.em))) push(s.text, s.style, s.href)
231 i += em[0].length
232 continue
233 }
234 const plain = /^[^\\`[<h~*_]+/.exec(rest)?.[0] ?? ch
235 push(plain, base)
236 i += plain.length
237 }
238
239 return out
240}
241
242// MARK: Wrapping
243
244const width = (spans: Span[]) => spans.reduce((n, s) => n + s.text.length, 0)
245
246const pad = (n: number, style: Style = {}): Span => ({ text: ' '.repeat(Math.max(0, n)), style })
247
248// Greedy word wrap across spans; `first` and `rest` prefix the first and later lines and count toward `max`.
249export const wrap = (spans: Span[], max: number, first: Span[] = [], rest: Span[] = first): Line[] => {
250 const lines: Line[] = []
251 let line: Line = [...first]
252 let used = width(first)
253 let room = Math.max(4, max - used)
254 const flush = () => {
255 const last = line.at(-1)
256 if (last) last.text = last.text.replace(/ +$/, '')
257 lines.push(line)
258 line = [...rest]
259 used = width(rest)
260 room = Math.max(4, max - used)
261 }
262 const add = (text: string, span: Span) => {
263 const last = line.at(-1)
264 if (last && last.style === span.style && last.href === span.href) last.text += text
265 else line.push({ ...span, text })
266 used += text.length
267 }
268 for (const span of spans) {
269 for (const token of span.text.match(/\s+|\S+/g) ?? []) {
270 if (/^\s/.test(token)) {
271 if (used > width(lines.length === 0 ? first : rest) && used < max) add(' ', span)
272 continue
273 }
274 let word = token
275 while (word.length > 0) {
276 const left = max - used
277 if (word.length <= left) {
278 add(word, span)
279 word = ''
280 } else if (word.length > room) {
281 if (left <= 0) flush()
282 const cut = Math.max(1, max - used)
283 add(word.slice(0, cut), span)
284 word = word.slice(cut)
285 if (word) flush()
286 } else {
287 flush()
288 }
289 }
290 }
291 }
292 if (line.length > rest.length || lines.length === 0) flush()
293
294 return lines
295}
296
297// MARK: Code
298
299const KEYWORDS = new Set(
300 (
301 'abstract as async await break case catch class const continue def default defer del delete do elif else enum export extends ' +
302 'false final finally fn for from func function go if impl implements import in interface is lambda let loop match mod mut new nil ' +
303 'none null package pass private protected pub public raise return self static struct super switch this throw true try type typeof ' +
304 'undefined unless until use val var void when where while with yield local then end fi done esac echo'
305 ).split(' '),
306)
307
308const TOKEN =
309 /(\/\/.*|#(?![!\[]).*|--\s.*)|("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|`(?:[^`\\]|\\.)*`?)|(\b0x[\da-f]+\b|\b\d[\d_.]*\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)|([^\w\s"'`]+)|(\s+)/gi
310
311const highlight = (line: string, lang: string): Span[] => {
312 const spans: Span[] = []
313 const hashComments = /^(sh|bash|zsh|shell|py|python|rb|ruby|ya?ml|toml|conf|ini|dockerfile|make|r)$/i.test(lang)
314 for (const m of line.matchAll(TOKEN)) {
315 const [text, comment, str, num, call, word, punct] = m
316 let style: Style = S.block
317 if (comment && (comment.startsWith('//') || hashComments || comment.startsWith('--'))) style = CODE.comment
318 else if (str) style = CODE.string
319 else if (num) style = CODE.number
320 else if (call) style = KEYWORDS.has(call) ? CODE.keyword : CODE.call
321 else if (word && KEYWORDS.has(word)) style = CODE.keyword
322 else if (word && /^[A-Z]/.test(word)) style = CODE.type
323 else if (punct && /[=<>!+\-*/%&|^?:]/.test(punct)) style = CODE.operator
324 spans.push({ text, style })
325 }
326
327 return spans
328}
329
330const codeBlock = (lang: string, body: string[], max: number): Line[] => {
331 const bar: Span = { text: '▎ ', style: S.bar }
332 const inner = Math.max(4, max - 2)
333 const out: Line[] = lang ? [[bar, { text: lang.slice(0, inner), style: S.lang }]] : []
334 for (const raw of body.length > 0 ? body : ['']) {
335 // Code keeps its indentation, so it is cut into rows rather than word-wrapped.
336 let row: Span[] = []
337 let used = 0
338 const rows: Span[][] = []
339 for (const s of highlight(raw.replace(/\t/g, ' '), lang)) {
340 let t = s.text
341 while (t.length > 0) {
342 const take = t.slice(0, inner - used)
343 row.push({ ...s, text: take })
344 used += take.length
345 t = t.slice(take.length)
346 if (used >= inner) {
347 rows.push(row)
348 row = []
349 used = 0
350 }
351 }
352 }
353 if (row.length > 0 || rows.length === 0) rows.push(row)
354 out.push(...rows.map(r => [bar, ...r]))
355 }
356
357 return out
358}
359
360// MARK: Tables
361
362const fit = (natural: number[], max: number): number[] => {
363 const widths = natural.map(n => Math.max(1, n))
364 const budget = max - (widths.length - 1) * 3 - 2
365 while (widths.reduce((a, b) => a + b, 0) > budget) {
366 const big = widths.indexOf(Math.max(...widths))
367 if ((widths[big] ?? 0) <= 3) break
368 widths[big] = (widths[big] ?? 0) - 1
369 }
370
371 return widths
372}
373
374const table = (header: string[], align: Align[], rows: string[][], max: number): Line[] => {
375 const columns = Math.max(header.length, ...rows.map(r => r.length))
376 const head = Array.from({ length: columns }, (_, k) => inline(header[k] ?? '', S.th))
377 const body = rows.map(r => Array.from({ length: columns }, (_, k) => inline(r[k] ?? '', S.text)))
378 const widths = fit(
379 Array.from({ length: columns }, (_, k) => Math.max(width(head[k] ?? []), ...body.map(r => width(r[k] ?? [])))),
380 max,
381 )
382 const sep: Span = { text: ' │ ', style: S.bar }
383 const row = (cellsOf: Span[][]): Line[] => {
384 const wrapped = cellsOf.map((c, k) => wrap(c, widths[k] ?? 1))
385 const height = Math.max(1, ...wrapped.map(w => w.length))
386 return Array.from({ length: height }, (_, y) => {
387 const line: Line = [pad(1)]
388 wrapped.forEach((w, k) => {
389 if (k > 0) line.push(sep)
390 const cell = w[y] ?? []
391 const gap = (widths[k] ?? 1) - width(cell)
392 const a = align[k] ?? 'left'
393 const left = a === 'right' ? gap : a === 'center' ? Math.floor(gap / 2) : 0
394 line.push(pad(left), ...cell, pad(gap - left))
395 })
396 return line
397 })
398 }
399 const rule: Line = [{ text: `─${widths.map(w => '─'.repeat(w)).join('─┼─')}─`, style: S.rule }]
400
401 return [...row(head), rule, ...body.flatMap(row)]
402}
403
404// MARK: Layout
405
406const indent = (lines: Line[], first: Span[], rest: Span[]): Line[] => lines.map((l, i) => [...(i === 0 ? first : rest), ...l])
407
408const headingStyle = (level: number): Style => (level === 1 ? S.h1 : level === 2 ? S.h2 : level === 3 ? S.h3 : S.h4)
409
410const layoutBlocks = (blocks: Block[], max: number, base: Style, tight = false): Line[] => {
411 const out: Line[] = []
412 blocks.forEach((b, n) => {
413 if (n > 0 && !tight) out.push([])
414 out.push(...layoutBlock(b, max, base))
415 })
416
417 return out
418}
419
420const layoutBlock = (b: Block, max: number, base: Style): Line[] => {
421 switch (b.kind) {
422 case 'heading':
423 return wrap(
424 inline(b.text, headingStyle(b.level)).map(s => ({ ...s, style: { ...s.style, ...headingStyle(b.level) } })),
425 max,
426 [{ text: `${'#'.repeat(b.level)} `, style: { ...headingStyle(b.level), bold: false, underline: false } }],
427 [pad(b.level + 1)],
428 )
429 case 'paragraph':
430 return wrap(inline(b.text, base), max)
431 case 'rule':
432 return [[{ text: '─'.repeat(max), style: S.rule }]]
433 case 'code':
434 return codeBlock(b.lang, b.lines, max)
435 case 'table':
436 return table(b.header, b.align, b.rows, max)
437 case 'quote': {
438 const bar: Span = { text: '│ ', style: S.bar }
439 return indent(layoutBlocks(b.blocks, max - 2, S.quote), [bar], [bar])
440 }
441 case 'list': {
442 const loose = b.items.some(it => it.blocks.length > 1 && it.blocks.some(x => x.kind === 'paragraph') && it.blocks.filter(x => x.kind === 'paragraph').length > 1)
443 const out: Line[] = []
444 b.items.forEach((it, n) => {
445 if (n > 0 && loose) out.push([])
446 const marker: Span[] =
447 it.task !== undefined
448 ? [{ text: it.task ? '✓ ' : '☐ ', style: it.task ? S.done : S.todo }]
449 : b.ordered
450 ? [{ text: `${it.marker} `, style: S.number }]
451 : [{ text: '• ', style: S.bullet }]
452 const w = width(marker)
453 const itemBase = it.task ? { ...base, ...S.del } : base
454 const lines = layoutBlocks(it.blocks, max - w, itemBase, true)
455 out.push(...indent(lines.length > 0 ? lines : [[]], marker, [pad(w)]))
456 })
457 return out
458 }
459 }
460}
461
462/** The lines a markdown text draws as, each at most `max` columns wide. */
463export const richLines = (markdown: string, max: number): Line[] => layoutBlocks(parse(markdown), Math.max(12, max), S.text)
464hooks/text.ts 209 lines1export const slug =(text: string, max = 40): string =>
2 text
3 .toLowerCase()
4 .replace(/[^a-z0-9]+/g, '-')
5 .replace(/^-+|-+$/g, '')
6 .slice(0, max)
7 .replace(/-+$/, '') || 'untitled'
8
9export const clip = (text: string, max: number): string => {
10 const flat = text.replace(/\s+/g, ' ').trim()
11
12 return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`
13}
14
15// Unlike clip, keeps newlines so a markdown preview still renders as markdown.
16export const head = (text: string, max: number): string =>
17 text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
18
19const cells = (row: string) =>
20 row
21 .trim()
22 .replace(/^\||\|$/g, '')
23 .split(/(?<!\\)\|/)
24 .map(c => c.trim())
25
26const isRule = (row: string) => /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(row) && row.includes('-')
27
28// A table wider than the pane wraps into an unreadable grid, so the preview restacks it as a list.
29// With `minCell`, the drawing wraps cells itself, so only a table that can't give each column that much is restacked.
30export const narrowTables = (markdown: string, width: number, minCell?: number): string => {
31 const lines = markdown.split('\n')
32 const out: string[] = []
33 let isFenced = false
34 for (let i = 0; i < lines.length; i++) {
35 const line = lines[i] ?? ''
36 if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
37 if (isFenced || !line.includes('|') || !isRule(lines[i + 1] ?? '')) {
38 out.push(line)
39 continue
40 }
41 let end = i + 2
42 while (end < lines.length && (lines[end] ?? '').includes('|') && (lines[end] ?? '').trim()) end++
43 const header = cells(line)
44 const rows = lines.slice(i + 2, end).map(cells)
45 const widths = header.map((h, k) => Math.max(h.length, ...rows.map(r => (r[k] ?? '').length)))
46 if (widths.reduce((sum, w) => sum + (minCell ? Math.min(w, minCell) : w) + 3, 1) <= width) {
47 out.push(...lines.slice(i, end))
48 } else {
49 for (const row of rows) {
50 out.push(`- ${row[0] ?? ''}`)
51 for (let k = 1; k < header.length; k++) if (row[k]) out.push(` - *${header[k]}*: ${row[k]}`)
52 }
53 }
54 i = end - 1
55 }
56
57 return out.join('\n')
58}
59
60export const ago =(at: number, now: number): string => {
61 const seconds = Math.max(0, Math.round((now - at) / 1000))
62 if (seconds < 45) return 'now'
63 const minutes = Math.round(seconds / 60)
64 if (minutes < 60) return `${minutes}m ago`
65 const hours = Math.round(minutes / 60)
66 if (hours < 24) return `${hours}h ago`
67
68 return `${Math.round(hours / 24)}d ago`
69}
70
71const pad = (n: number) => String(n).padStart(2, '0')
72
73export const day = (at: number): string => {
74 const d = new Date(at)
75
76 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
77}
78
79export const stamp = (at: number): string => {
80 const d = new Date(at)
81
82 return `${day(at)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
83}
84
85export const basename = (path: string): string => path.replace(/\/+$/, '').split('/').pop() ?? path
86
87// Models wrap JSON in fences or prose often enough that a strict parse loses turns.
88export const parseObject = (text: string): Record<string, unknown> | null => {
89 const start = text.indexOf('{')
90 const end = text.lastIndexOf('}')
91 if (start < 0 || end <= start) return null
92 try {
93 const value: unknown = JSON.parse(text.slice(start, end + 1))
94
95 return value !== null && typeof value === 'object' && !Array.isArray(value)
96 ? (value as Record<string, unknown>)
97 : null
98 } catch {
99 return null
100 }
101}
102
103export const strings = (value: unknown, max: number, each = 100): string[] =>
104 Array.isArray(value)
105 ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '').map(v => clip(v, each)).slice(0, max)
106 : []
107
108const str = (value: unknown): string => (typeof value === 'string' ? value : '')
109
110// Older state.json files stored needs-you items as bare strings.
111type RawAsk = { question: string; context?: unknown; options?: unknown; optionNotes?: unknown }
112type RawOption = { label: string; description?: unknown }
113
114const text = (value: unknown, max: number) => (typeof value === 'string' ? clip(value, max) : '')
115
116// Accepts every shape this has had: bare strings, string options, and {label, description} options.
117export const asks = (value: unknown): { question: string; context: string; options: string[]; optionNotes: string[] }[] =>
118 Array.isArray(value)
119 ? value
120 .map(v => (typeof v === 'string' ? { question: v } : v))
121 .filter((v): v is RawAsk => typeof v?.question === 'string' && v.question.trim() !== '')
122 .slice(0, 4)
123 .map(v => {
124 const notes = Array.isArray(v.optionNotes) ? v.optionNotes : []
125 const options = (Array.isArray(v.options) ? v.options : [])
126 .map((o, i): RawOption | null =>
127 typeof o === 'string' ? { label: o, description: notes[i] } : typeof o?.label === 'string' ? o : null,
128 )
129 .filter((o): o is RawOption => o !== null && o.label.trim() !== '')
130 .slice(0, 4)
131 return {
132 question: clip(v.question, 160),
133 context: text(v.context, 400),
134 options: options.map(o => clip(o.label, 80)),
135 optionNotes: options.map(o => text(o.description, 160)),
136 }
137 })
138 : []
139
140export const DONE_TITLE = 40
141
142// One turn's results as a done entry; a single result short enough to be the title needs no list under it.
143export const doneOf = (title: unknown, items: string[], at: number): { title: string; items: string[]; at: number } | null => {
144 const named = typeof title === 'string' ? clip(title, DONE_TITLE) : ''
145 const first = items[0]
146 if (!named && first === undefined) return null
147 if (!named && items.length === 1 && first !== undefined && first.length <= DONE_TITLE) return { title: first, items: [], at }
148
149 return { title: named || clip(first ?? '', DONE_TITLE), items, at }
150}
151
152// Older state.json files stored one {text, at} per result.
153export const dones = (value: unknown): { title: string; items: string[]; at: number }[] =>
154 Array.isArray(value)
155 ? value.flatMap(v => {
156 const at = typeof v?.at === 'number' ? v.at : 0
157 const done = typeof v?.text === 'string' ? doneOf(null, [clip(v.text, 100)], at) : doneOf(v?.title, strings(v?.items, 6, 100), at)
158 return done ? [done] : []
159 })
160 : []
161
162export const parseStatus =(out: string) => {
163 const [first = '', ...rest] = out.split('\n')
164 const header = first.replace(/^## /, '')
165 const [branchPart = '', trackPart = ''] = header.split('...')
166 const branch = branchPart.replace(/^No commits yet on /, '').replace(/ \[.*$/, '')
167
168 return {
169 branch: branch === 'HEAD (no branch)' ? 'detached' : branch,
170 hasUpstream: trackPart !== '',
171 ahead: Number(/ahead (\d+)/.exec(header)?.[1] ?? 0),
172 behind: Number(/behind (\d+)/.exec(header)?.[1] ?? 0),
173 changed: rest.filter(line => line.trim() !== '').length,
174 }
175}
176
177// `cd <dir> && …` is how most commands reach another repo, so it marks that repo as touched.
178export const cdTarget = (command: string, home: string): string | null => {
179 const match = /^\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/.exec(command)
180 const raw = match?.[2] ?? match?.[3] ?? match?.[4]
181 if (!raw) return null
182 const dir = raw.replace(/^~(?=\/|$)/, home).replace(/\/+$/, '')
183
184 return dir.startsWith('/') ? dir : null
185}
186
187export const toolLabel = (tool: string, input: Record<string, unknown>): string => {
188 const file = str(input.file_path) || str(input.notebook_path)
189 switch (tool) {
190 case 'Bash':
191 return `Bash: ${clip(str(input.description) || str(input.command), 48)}`
192 case 'Read':
193 case 'Edit':
194 case 'Write':
195 case 'NotebookEdit':
196 return `${tool} ${basename(file)}`
197 case 'Grep':
198 case 'Glob':
199 return `${tool} ${clip(str(input.pattern), 40)}`
200 case 'Agent':
201 return `Agent: ${clip(str(input.description), 44)}`
202 case 'WebFetch':
203 case 'WebSearch':
204 return `${tool} ${clip(str(input.url) || str(input.query), 40)}`
205 default:
206 return tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' ') : tool
207 }
208}
209types/index.d.ts 114 lines1export type Note = {
2 id: string
3 title: string
4 file: string
5 at: number
6 isPinned: boolean
7}
8
9export type Task = { title: string; at: number }
10
11export type Attempt = { text: string; isOk: boolean }
12
13export type Ask = { question: string; context: string; options: string[]; optionNotes: string[] }
14
15export type Repo = {
16 root: string
17 branch: string
18 changed: number
19 ahead: number
20 behind: number
21 hasUpstream: boolean
22}
23
24export type Stamped = { text: string; at: number }
25
26// One per turn: a one-line title, with what changed listed under it.
27export type Done = { title: string; items: string[]; at: number }
28
29export type Crumbs = {
30 tasks: Task[]
31 prompts: Stamped[]
32 activity: string | null
33 lastSaid: Stamped | null
34 notes: Note[]
35 decided: string[]
36 tried: Attempt[]
37 needsYou: Ask[]
38 done: Done[]
39 edited: string[]
40 touched: string[]
41 repos: Repo[]
42 asking: string | null
43}
44
45export type Where = {
46 repo: string
47 branch: string
48 worktree: string
49}
50
51export type View = {
52 openNote: string | null
53 isShowingPrompts: boolean
54 isShowingMore: boolean
55 isShowingStatus: boolean
56 openDone: number | null
57 picked: Record<string, string>
58 typed: Record<string, string>
59 details: Record<string, string>
60 expanded: string | null
61}
62
63export type TestStatus = 'todo' | 'running' | 'passed' | 'failed' | 'blocked' | 'retest'
64
65export type Entry = { from: 'you' | 'tester' | 'claude'; text: string; at: number }
66
67export type ManualTest = {
68 id: string
69 title: string
70 steps: string[]
71 expect: string
72 watch: string
73 status: TestStatus
74 agentId: string | null
75 log: Entry[]
76 // The tester's open question to the planner; absent on tests saved before it existed.
77 asking?: string | null
78 // What makes the expected signal happen; absent on older tests, as are the two below.
79 trigger?: string
80 assumes?: string[]
81 needs?: string[]
82 // The tester's model for this test, over the testerModel setting.
83 model?: string
84}
85
86export type Wait = {
87 id: string
88 testId: string | null
89 step: number | null
90 instruction: string
91 // Absent on waits from before these fields existed.
92 steps?: string[]
93 options?: string[]
94 at: number
95 answer: string | null
96}
97
98export type TestRun = { brief: string; tests: ManualTest[]; waits: Wait[]; unreported: string[] }
99
100export type TestsView = { expanded: string | null; typed: Record<string, string> }
101
102declare module 'claude-code' {
103 interface PluginState {
104 breadcrumbs: {
105 crumbs: Crumbs
106 where: Where | null
107 view: View
108 pending: string | null
109 tests: TestRun
110 testsView: TestsView
111 }
112 }
113}
114