A per-project backlog of items needing your attention, parked by you or the orchestrator

A Claude Code mod that keeps a backlog of things that need your attention, so they don't scroll away during long agentic sessions.
When you orchestrate subagents, the main agent relays job reports full of findings, questions and decisions. You can't deal with all of them as they arrive, and after an unattended run you'd otherwise have to search the transcript for them. With this mod the agent parks each one as it comes up, and you work through them later in a side pane.
park, resolve, list_parked) and a system-prompt section telling it when to use them. Items are either needs attention (a deferred decision, an unanswered question, a blocker, a finding to act on) or FYI (a finished job, a notable finding, an assumption made on your behalf).a accepts the agent's pick, and w lets you answer in your own words. Accepting the pick of work already under way sends nothing. Other answers are gathered for three seconds and sent to the main agent as one message./park [note] parks the assistant's last reply./parked opens the backlog: needs attention, FYI, then done. Nothing is deleted; done items can be reopened.📌 2 need attention · 1 FYI.This mod uses Claude Code's function-hooks plugin API.
Clone it into your personal skills folder, where Claude Code loads it in every session:
git clone https://github.com/BuddyLim/claude-code-mod-parked ~/.claude/skills/parked
Or clone it anywhere and load it for one session:
claude --plugin-dir /path/to/claude-code-mod-parked
Run /parked, then click the pane once. Arrow keys only reach the pane after a click; that is a limit of the plugin API, not a choice.
| Key | Does |
|---|---|
↑ ↓ or k j | move the cursor |
Enter, →, l or o | open the item |
1–9 | open that row |
An item has three levels, marked with ▸. ↑ ↓ (or k j) move between them.
| Level | ← → (or h l) | Other keys |
|---|---|---|
| Tickets | previous / next item | |
| Actions | choose an action | Enter or o runs it |
| Composer | type a question, Enter sends, ↑ leaves |
1–9 answer with that option; a accepts the default; w opens the composer for an answer in your own words (a number typed there picks that option). These need the click; without one, reach Accept and Answer on the actions row.d done, r reopen, n next, p previous, b back to the list.g opens the item's file reference in the lens mod, where that is installed; with several references, each press opens the next.i jumps to the composer.s sends the thread's summary to the main agent, once the subagent has replied.PgUp PgDn or the mouse wheel scroll the thread; the wheel over the ticket scrolls the ticket.After Ctrl+X Tab (or a fresh /parked) the pane has the keyboard but the arrows do not work. Tab moves down, as ↓ does, in both views. The letter row at the bottom does: h j k l, o for Enter and i for the composer.
/parked lab switches between the keyboard view and an older view built from standard buttons.
claude plugin validate . # check the manifest and hooks module
claude plugin test . # run the tests
hooks/register.tsx: the hooks module: tools, commands, storage, the pane.hooks/detail.tsx: the pane's keyboard region.hooks/backlog.ts: the backlog's logic, free of the engine.types/index.d.ts: the state contract.tests/parked.test.ts: the tests.MIT
hooks/register.tsx 1458 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ParkedItem } from '../types'
5import { PAD } from './kit/layout'
6import {
7 EMPTY,
8 ICON,
9 ageOf,
10 answer,
11 answerOf,
12 answersNote,
13 sent,
14 ticketParts,
15 ticketLines,
16 mergedRuns,
17 placeOfRef,
18 syncLedger,
19 unsend,
20 unsentOf,
21 orderOf,
22 briefRequest,
23 describe,
24 doneNote,
25 handoff,
26 investigationPrompt,
27 isBacklog,
28 park,
29 reopen,
30 resolve,
31 say,
32 sayOnce,
33 statusText,
34 threadLines,
35 summaryRequest,
36 titleOf,
37 wrap,
38} from './backlog'
39import type { Backlog } from './backlog'
40
41const PANE = 'parked'
42const PARK = 'mcp__parked__park'
43const RESOLVE = 'mcp__parked__resolve'
44const LIST = 'mcp__parked__list_parked'
45
46const items = atom({ plugin: 'parked', key: 'items' } as const, [])
47// The id of the item the pane shows in full; 0 while it shows the list.
48const selected = atom({ plugin: 'parked', key: 'selected' } as const, 0)
49// The ids of the items whose side thread awaits its subagent's reply.
50const pending = atom({ plugin: 'parked', key: 'pending' } as const, [])
51// The detail view scrolls its two regions itself: rows the thread is scrolled
52// up from its newest message, and rows the ticket is scrolled down from its top.
53const back = atom({ plugin: 'parked', key: 'back' } as const, 0)
54const top = atom({ plugin: 'parked', key: 'top' } as const, 0)
55// Where the last drawing put the ticket, and how far each region can move.
56// Whether the detail view is the keyboard view (a Client region) where the surface has one.
57const lab = atom({ plugin: 'parked', key: 'lab' } as const, true)
58let layout = { ticketStart: 0, ticketEnd: 0, maxTop: 0, maxBack: 0 }
59// The last key pressed on one of the pane's own letter buttons, and how many
60// have been: the keyboard region answers each new count as a key of its own.
61const nudge = atom({ plugin: 'parked', key: 'nudge' } as const, { key: '', n: 0 })
62// Whether the composer, the pane's own text field, has the focus ring.
63const typing = atom({ plugin: 'parked', key: 'typing' } as const, false)
64const typingAt = atom({ plugin: 'parked', key: 'typingAt' } as const, 0)
65// What the keyboard region or a letter button has typed for the composer while
66// the text field itself did not have the keyboard; the field is drawn with it.
67const draft = atom({ plugin: 'parked', key: 'draft' } as const, '')
68// Whether what the composer sends is the user's answer for the main agent,
69// not a question for the side thread's subagent.
70const answering = atom({ plugin: 'parked', key: 'answering' } as const, false)
71// The place last asked to be shown: the lens mod, where loaded, opens it.
72const jump = atom({ plugin: 'parked', key: 'jump' } as const, null)
73// Which of an item's file references the next "go" opens, by item.
74const goAt = new Map<number, number>()
75
76// How long after the last answer the batch of them is sent, so several items
77// answered in a row reach the main agent as one message.
78const FLUSH_MS = 3000
79// Whether the main loop is in a turn; a subagent's run raises no `turn.start`.
80let isRunning = false
81// The timer that sends the batch of answers, while one is waiting.
82let flushTimer: { cancel: () => void } | undefined
83
84// The thread's input is keyed by how many messages the user has sent, so each
85// send draws a fresh, empty field.
86const askKey = (item: ParkedItem) =>
87 `ask-${item.id}-${(item.thread ?? []).filter(one => one.role === 'you').length}`
88
89const GUIDE = `# Parked items
90
91This session has a backlog of parked items the user reviews later in a pane, often after leaving the session unattended. Keep it current with the ${PARK}, ${RESOLVE} and ${LIST} tools.
92
93Park an item with kind "needs-you" for: a decision you deferred to the user, a question you could not answer, a blocker, or a finding the user must act on.
94Park an item with kind "fyi" for: a completed job worth knowing about, a notable finding, or an assumption you made on the user's behalf.
95Park one item per distinct thing to address, not one per report. Give each a short title and a body that stands on its own: what is needed from the user and the relevant findings. Do not park routine progress.
96When an item is a choice, give "options" (short labels, nine at most) and "default", the number of the one you would pick; the user sees that one listed first, as option 1. Set "blocking" to true only when no remaining work can go on without the answer; otherwise go ahead on your default and leave the item open for the user to accept or overturn. Name the places an item is about in "refs", each a path or path:line.
97The user's answers arrive as a prompt that begins "My answers to parked items", and each answered item is already done: do not resolve it again. An answer that differs from your default replaces it, so revise what you built on the default.
98When the user has addressed an item in conversation, call ${RESOLVE} with its id and a one-line record of the outcome.
99Call ${LIST} when resuming work or when unsure what is open.`
100
101// Who parked an item, and the ledger task or unit it belongs to ('' for none).
102const parkerOf = (item: ParkedItem) =>
103 item.origin !== undefined ? 'the ledger' : item.parkedBy === 'user' ? 'you' : 'the agent'
104const tagOf = (item: ParkedItem) =>
105 item.origin?.kind === 'review' ? item.origin.unit : (item.task ?? '')
106
107const SUBAGENT_REFUSAL =
108 'Only the main agent parks items. Report this finding to the orchestrator in your final answer instead.'
109
110const keyOf = async ($: EngineInterface) => `backlog:${await $.session.cwd()}`
111
112const load = async ($: EngineInterface): Promise<Backlog> => {
113 const stored = await $.store.get(await keyOf($))
114
115 return isBacklog(stored) ? stored : EMPTY
116}
117
118const show = async ($: EngineInterface, backlog: Backlog) => {
119 await update($, items, () => backlog.items)
120 $.ui.status(statusText(backlog.items))
121}
122
123// Reads the stored backlog, applies the change and writes it back; answers
124// the reason when the change was refused or the store failed.
125const changeNow = async (
126 $: EngineInterface,
127 apply: (backlog: Backlog) => Backlog | string,
128): Promise<{ backlog: Backlog } | { error: string }> => {
129 try {
130 const after = apply(await load($))
131
132 if (typeof after === 'string') {
133 return { error: after }
134 }
135
136 await $.store.set(await keyOf($), after)
137 await show($, after)
138
139 return { backlog: after }
140 } catch (error) {
141 const reason = `Parked items could not be saved: ${error instanceof Error ? error.message : String(error)}`
142 $.ui.toast(reason)
143
144 return { error: reason }
145 }
146}
147
148// One change at a time. Two hooks recording the same reply would otherwise
149// both read the backlog before either wrote it, and both would add it.
150let changes: Promise<unknown> = Promise.resolve()
151
152const change = (
153 $: EngineInterface,
154 apply: (backlog: Backlog) => Backlog | string,
155): Promise<{ backlog: Backlog } | { error: string }> => {
156 const run = changes.then(() => changeNow($, apply))
157 changes = run.catch(() => undefined)
158
159 return run
160}
161
162// The main session's briefing for an item's subagent: one tool-less question
163// over the session's own transcript, which the main conversation never sees.
164// Empty when the session has nothing to fork or the call fails.
165const briefOf = async ($: EngineInterface, item: ParkedItem): Promise<string> => {
166 try {
167 const reply = await $.model.fork({ prompt: briefRequest(item) })
168
169 return reply.isAnswered ? reply.text.trim() : ''
170 } catch {
171 return ''
172 }
173}
174
175const wordsOf = (text: string) => text.split(/\s+/).filter(Boolean).length
176
177// Sends the user's message to the item's subagent: the one already on the
178// thread while this session still has it, else a fresh one given the thread.
179const ask = async ($: EngineInterface, item: ParkedItem, text: string) => {
180 const settle = () => update($, pending, list => list.filter(id => id !== item.id))
181 const fail = async (reason: string) => {
182 const at = await $.clock.now()
183 await change($, backlog => say(backlog, item.id, { role: 'agent', text: reason, at }))
184 await settle()
185 }
186
187 try {
188 const at = await $.clock.now()
189 const said = await change($, backlog => say(backlog, item.id, { role: 'you', text, at }))
190
191 if ('error' in said) {
192 return
193 }
194
195 await update($, pending, list => [...list.filter(id => id !== item.id), item.id])
196 const next = said.backlog.items.find(one => one.id === item.id)
197
198 if (next !== undefined) {
199 void $.ui.focus({ requestId: PANE, key: askKey(next) }).catch(() => undefined)
200 }
201
202 if (item.agentId !== undefined) {
203 const sent = await $.session.send({ to: { agentId: item.agentId }, text })
204
205 if (sent.isDelivered) {
206 return
207 }
208 }
209
210 const brief = await briefOf($, item)
211 const noted = await $.clock.now()
212 await change($, backlog =>
213 say(backlog, item.id, {
214 role: 'note',
215 text:
216 brief !== ''
217 ? `The subagent was briefed from the main session (${wordsOf(brief)} words).`
218 : 'No briefing: the main session had no context to give. The subagent has the ticket only.',
219 at: noted,
220 }),
221 )
222
223 const spawned = await $.agent.spawn({
224 prompt: investigationPrompt(item, text, brief),
225 description: `Parked #${item.id}: ${item.title}`.slice(0, 60),
226 })
227
228 if (spawned.agentId === undefined) {
229 await fail(`The subagent could not start: ${spawned.deny ?? 'no reason given'}`)
230
231 return
232 }
233
234 const agentId = spawned.agentId
235 await change($, backlog => ({
236 ...backlog,
237 items: backlog.items.map(one => (one.id === item.id ? { ...one, agentId } : one)),
238 }))
239 } catch (error) {
240 await fail(`The subagent could not be reached: ${error instanceof Error ? error.message : String(error)}`)
241 }
242}
243
244// A subagent's report, read from its own transcript: it hands its report back
245// through a tool call, so its turn can end with no visible answer.
246const reportOf = async ($: EngineInterface, agentId: string): Promise<string> => {
247 const rows = await $.session.messages({ agentId })
248
249 if (!Array.isArray(rows)) {
250 return ''
251 }
252
253 for (const row of [...rows].reverse()) {
254 if (row.role !== 'assistant') {
255 continue
256 }
257
258 const handback = row.toolUses.findLast(use => /handback/i.test(use.tool))
259 // The report is the hand-back's longest text argument.
260 const report = Object.values(handback?.input ?? {})
261 .filter((value): value is string => typeof value === 'string')
262 .sort((a, b) => b.length - a.length)[0]
263
264 if (report !== undefined && report.trim() !== '') {
265 return report.trim()
266 }
267
268 if (row.text.trim() !== '') {
269 return row.text.trim()
270 }
271 }
272
273 return ''
274}
275
276const goTo = async ($: EngineInterface, id: number) => {
277 await update($, back, () => 0)
278 await update($, draft, () => '')
279 await update($, answering, () => false)
280 await update($, top, () => 0)
281 await update($, selected, () => id)
282}
283
284// Gives the pane's text field the focus ring, so what is typed lands in it,
285// clicked or not.
286const focusComposer = async ($: EngineInterface, item: ParkedItem) => {
287 // The ring may still be resting on the text field from an earlier
288 // visit, the region having taken the keys with a click since. Focusing
289 // where the ring already is moves nothing, so it goes to the letter
290 // row first: the move back is what hands the field the keyboard.
291 await $.ui.focus({ requestId: PANE, key: 'key-k' }).catch(() => undefined)
292 await $.ui.focus({ requestId: PANE, key: askKey(item) }).catch(() => undefined)
293 // A new visit, whether or not the ring reported a move.
294 await update($, typing, () => true)
295 await update($, typingAt, count => count + 1)
296}
297
298// Tells the main agent every answer it has not had, as one message. While a
299// turn runs the message goes into the prompt box, where the user's Enter
300// delivers it into that turn; a prompt a plugin submits waits for the turn's end.
301const flush = async ($: EngineInterface) => {
302 // The answers given in this session are taken and marked sent in one change,
303 // before they are delivered: a second flush that starts while this one waits
304 // on the prompt finds none of them and so cannot send them again.
305 const sessionId = await $.session.id()
306 let list: ParkedItem[] = []
307 await change($, backlog => {
308 list = unsentOf(backlog.items, sessionId)
309
310 return sent(backlog, list.map(one => one.id))
311 })
312
313 if (list.length === 0) {
314 return
315 }
316
317 try {
318 await deliver($, list)
319 } catch (error) {
320 // Not delivered: they are unsent again, for the next flush to take.
321 await change($, backlog => unsend(backlog, list.map(one => one.id)))
322 throw error
323 }
324}
325
326const deliver = async ($: EngineInterface, list: readonly ParkedItem[]) => {
327 const text = answersNote(list)
328 const count = `${list.length} answer${list.length === 1 ? '' : 's'}`
329 let isFilled = false
330
331 if (isRunning) {
332 const box = await $.prompt.read().then(
333 read => read.text,
334 () => '',
335 )
336 const lead = box === '' || box.endsWith('\n') ? '' : '\n'
337 isFilled = await $.prompt.fill({ text: `${lead}${text}\n`, mode: 'append' }).then(
338 filled => filled.isFilled,
339 () => false,
340 )
341 }
342
343 if (!isFilled) {
344 await $.prompt.submit({ text })
345 }
346
347 $.ui.toast(
348 isFilled
349 ? `Parked: ${count} in the prompt box. Press Enter to send into the running turn.`
350 : `Parked: ${count} sent to the main agent.`,
351 )
352}
353
354// Sends the batch of answers once no new one has come for FLUSH_MS.
355const queueFlush = ($: EngineInterface) => {
356 flushTimer?.cancel()
357 flushTimer = $.clock.after(FLUSH_MS, () => {
358 flushTimer = undefined
359 void flush($).catch(() => undefined)
360 })
361}
362
363// Answers the open item for the user: an option by its number, or their words.
364const respond = async ($: EngineInterface, item: ParkedItem, value: number | string) => {
365 const made = answerOf(item, value)
366
367 if (typeof made === 'string') {
368 $.ui.toast(made)
369
370 return
371 }
372
373 const order = orderOf(await read($, items))
374 const when = await $.clock.now()
375 const sessionId = await $.session.id()
376 const changed = await change($, backlog => answer(backlog, item.id, value, when, sessionId))
377
378 if ('error' in changed) {
379 $.ui.toast(changed.error)
380
381 return
382 }
383
384 if (made.isSilent) {
385 $.ui.toast(`Parked #${item.id}: default accepted. The agent already went that way.`)
386 } else {
387 $.ui.toast(`Parked #${item.id}: answer queued for the main agent.`)
388 queueFlush($)
389 }
390
391 // On to the next open item, or back to the list when none is left.
392 const following = order.find(one => one.status === 'open' && one.id !== item.id)
393 await goTo($, following?.id ?? 0)
394}
395
396// A side thread in a few lines, written by a small model. Empty when the call
397// fails, and the hand-off then falls back to the start of the last reply.
398const summaryOf = async ($: EngineInterface, item: ParkedItem): Promise<string> => {
399 try {
400 const reply = await $.model.complete({
401 model: 'haiku',
402 prompt: summaryRequest(item),
403 maxTokens: 400,
404 timeoutMs: 30000,
405 })
406
407 return reply.isAnswered ? reply.text.trim() : ''
408 } catch {
409 return ''
410 }
411}
412
413// What the composer sends: the user's answer for the main agent while they are
414// answering, else a question for the side thread's subagent.
415const send = async ($: EngineInterface, item: ParkedItem, text: string) => {
416 if (await read($, answering)) {
417 await respond($, item, text)
418 } else {
419 await ask($, item, text)
420 }
421}
422
423// Runs one action on the open item.
424const perform = async ($: EngineInterface, item: ParkedItem, act: string) => {
425 const order = orderOf(await read($, items))
426 const at = order.findIndex(one => one.id === item.id)
427
428 if (act === 'prev' || act === 'next') {
429 const to = order[at + (act === 'next' ? 1 : -1)]
430
431 if (to !== undefined) {
432 await goTo($, to.id)
433 }
434 } else if (act === 'done' && item.status === 'open') {
435 const when = await $.clock.now()
436 await change($, backlog => resolve(backlog, item.id, doneNote(item), 'user', when))
437 // On to the next open item, or back to the list when none is left.
438 const following = order.find(one => one.status === 'open' && one.id !== item.id)
439 await goTo($, following?.id ?? 0)
440 } else if (act === 'accept' && item.status === 'open' && item.preferred !== undefined) {
441 await respond($, item, item.preferred)
442 } else if (/^pick-[1-9]$/.test(act) && item.status === 'open') {
443 await respond($, item, Number(act.slice(5)))
444 } else if (act === 'go' && (item.refs ?? []).length > 0) {
445 // Each press asks for the next of the item's places, round and round.
446 const refs = item.refs ?? []
447 const at = (goAt.get(item.id) ?? 0) % refs.length
448 const place = placeOfRef(refs[at] ?? '')
449
450 goAt.set(item.id, at + 1)
451 await update($, jump, last => ({ ...place, n: (last?.n ?? 0) + 1 }))
452 $.ui.toast(
453 `Parked #${item.id}: ${refs[at]} asked of lens${refs.length > 1 ? ` (${at + 1} of ${refs.length})` : ''}`,
454 )
455 } else if (act === 'write' && item.status === 'open') {
456 await focusComposer($, item)
457 await update($, answering, () => true)
458 } else if (act === 'reopen') {
459 await change($, backlog => reopen(backlog, item.id))
460 } else if (act === 'send' && (item.thread ?? []).some(one => one.role === 'agent')) {
461 $.ui.toast(`Parked #${item.id}: summarising the thread for the main agent…`)
462 await $.prompt.submit({ text: handoff(item, await summaryOf($, item)) })
463 $.ui.toast(`Parked #${item.id}: findings sent to the main agent.`)
464 } else if (act === 'back') {
465 await goTo($, 0)
466 }
467}
468
469export const register: Register = on => {
470 on('session.start', async ($, e, next) => {
471 // Answers a reload or a restart left unsent go out with the next batch.
472 queueFlush($)
473
474 await $.command.register({
475 name: 'park',
476 description: "Park a note, or the assistant's last reply: /park <note> · /park · /park + <note> for both",
477 })
478 await $.command.register({
479 name: 'parked',
480 description: 'Show parked items in a pane',
481 })
482 await $.tool.register({
483 name: 'park',
484 description:
485 'Park an item for the user to review later. Use kind "needs-you" for decisions, unanswered questions, blockers and findings the user must act on; "fyi" for completed jobs, notable findings and assumptions made on their behalf.',
486 inputSchema: {
487 type: 'object',
488 properties: {
489 kind: { type: 'string', enum: ['needs-you', 'fyi'] },
490 title: { type: 'string', description: 'One line naming the item' },
491 body: {
492 type: 'string',
493 description: 'What is needed from the user and the relevant findings; must stand on its own',
494 },
495 options: {
496 type: 'array',
497 items: { type: 'string' },
498 maxItems: 9,
499 description: 'For a choice: a short label per option, which the user picks by number',
500 },
501 default: {
502 type: 'integer',
503 description: 'The number of the option you would pick, 1 for the first',
504 },
505 blocking: {
506 type: 'boolean',
507 description:
508 'True only when no remaining work can go on without the answer; otherwise proceed on your default',
509 },
510 refs: {
511 type: 'array',
512 items: { type: 'string' },
513 description: 'The places the item is about, each a path or path:line',
514 },
515 task: {
516 type: 'string',
517 description: 'The id of the ledger task the item belongs to, when the ledger has a run',
518 },
519 },
520 required: ['kind', 'title', 'body'],
521 },
522 })
523 await $.tool.register({
524 name: 'resolve',
525 description: 'Mark a parked item done once the user has addressed it.',
526 inputSchema: {
527 type: 'object',
528 properties: {
529 id: { type: 'number' },
530 resolution: { type: 'string', description: 'One line on how it was addressed' },
531 },
532 required: ['id', 'resolution'],
533 },
534 })
535 await $.tool.register({
536 name: 'list_parked',
537 description: 'List the parked items of this project: open ones, and done ones if asked.',
538 inputSchema: {
539 type: 'object',
540 properties: { includeDone: { type: 'boolean' } },
541 },
542 })
543
544 try {
545 await show($, await load($))
546 } catch {
547 $.ui.toast('Parked items could not be loaded.')
548 }
549
550 return next(e)
551 })
552
553 on('prompt.compose', async ($, e, next) => {
554 const composed = await next(e)
555
556 return {
557 sections: [
558 ...composed.sections,
559 { id: 'parked:guide', text: GUIDE, scope: 'session' },
560 ],
561 }
562 })
563
564 on('tool.call', { tool: PARK }, async ($, e) => {
565 if (e.agentId !== undefined) {
566 return { deny: SUBAGENT_REFUSAL }
567 }
568
569 const { kind, title, body } = e
570
571 if (
572 (kind !== 'needs-you' && kind !== 'fyi') ||
573 typeof title !== 'string' ||
574 typeof body !== 'string' ||
575 title.trim() === ''
576 ) {
577 return { deny: 'park needs kind ("needs-you" or "fyi"), a title and a body.' }
578 }
579
580 const texts = (value: unknown) =>
581 Array.isArray(value) && value.every(one => typeof one === 'string' && one.trim() !== '')
582 ? value.map(one => String(one).trim())
583 : undefined
584 const options = e.options === undefined ? [] : texts(e.options)
585 const refs = e.refs === undefined ? [] : texts(e.refs)
586 const preferred = e.default
587
588 if (options === undefined || options.length > 9 || refs === undefined) {
589 return { deny: 'park takes options as at most nine short labels, and refs as paths or path:line.' }
590 }
591
592 if (
593 preferred !== undefined &&
594 (typeof preferred !== 'number' || !Number.isInteger(preferred) || preferred < 1 || preferred > options.length)
595 ) {
596 return { deny: `park takes default as the number of one of its ${options.length} options, 1 for the first.` }
597 }
598
599 const sessionId = await $.session.id()
600 const now = await $.clock.now()
601 const changed = await change($, backlog =>
602 park(backlog, {
603 kind,
604 title: title.trim(),
605 body,
606 options,
607 ...(typeof preferred === 'number' ? { preferred } : {}),
608 isBlocking: e.blocking === true,
609 ...(typeof e.task === 'string' && e.task.trim() !== '' ? { task: e.task.trim() } : {}),
610 refs,
611 parkedBy: 'model',
612 sessionId,
613 now,
614 }),
615 )
616
617 if ('error' in changed) {
618 return { deny: changed.error }
619 }
620
621 return { result: `Parked as #${changed.backlog.nextId - 1}.` }
622 })
623
624 on('tool.call', { tool: RESOLVE }, async ($, e) => {
625 if (e.agentId !== undefined) {
626 return { deny: SUBAGENT_REFUSAL }
627 }
628
629 const { id, resolution } = e
630
631 if (typeof id !== 'number' || typeof resolution !== 'string') {
632 return { deny: 'resolve needs a numeric id and a resolution.' }
633 }
634
635 const now = await $.clock.now()
636 const changed = await change($, backlog => resolve(backlog, id, resolution, 'model', now))
637
638 return 'error' in changed ? { deny: changed.error } : { result: `Resolved #${id}.` }
639 })
640
641 on('tool.call', { tool: LIST }, async ($, e) => {
642 const backlog = await load($)
643 const list = backlog.items.filter(one => e.includeDone === true || one.status === 'open')
644
645 return {
646 result: list.length === 0 ? 'No parked items.' : list.map(describe).join('\n\n'),
647 }
648 })
649
650 // The ledger mod, where it is loaded, changed its run: a failed gate and a
651 // unit's open findings become items here, and close as the run moves on.
652 on('state.set', { plugin: 'ledger', key: 'runs' }, async ($, e, next) => {
653 const written = await next(e)
654 // Every run under way counts, not only the one planned last.
655 const seenRun = mergedRuns(e.value)
656 const sessionId = await $.session.id()
657 const now = await $.clock.now()
658 const before = await load($)
659
660 // Most changes of a run (a spawn, a token count) alter no item.
661 if (syncLedger(before, seenRun, now, sessionId) !== before) {
662 await change($, backlog => syncLedger(backlog, seenRun, now, sessionId))
663 }
664
665 return written
666 })
667
668 on('turn.start', async ($, e, next) => {
669 isRunning = true
670
671 return next(e)
672 })
673
674 // A thread's subagent finished a run: its answer is the thread's next message.
675 on('turn.complete', async ($, e, next) => {
676 if (e.agentId === undefined) {
677 isRunning = false
678 }
679
680 if (e.agentId !== undefined) {
681 const agentId = e.agentId
682 const item = (await load($)).items.find(one => one.agentId === agentId)
683
684 if (item !== undefined) {
685 const at = await $.clock.now()
686 const found = e.answer.trim() !== '' ? e.answer.trim() : await reportOf($, agentId).catch(() => '')
687 const text = found !== '' ? found : `(The subagent stopped without a report: ${e.reason}.)`
688 await change($, backlog => sayOnce(backlog, item.id, { role: 'agent', text, at }))
689 await update($, pending, list => list.filter(id => id !== item.id))
690 $.ui.toast(`Parked #${item.id}: the subagent replied.`)
691 }
692 }
693
694 return next(e)
695 })
696
697 // A thread's subagent also reports to the main conversation when it stops:
698 // its hand-back message, then a task notification, each a prompt of its own.
699 // The thread is private, so both are dropped before they start a turn.
700 on('prompt.submit', async ($, e, next) => {
701 const kind = e.origin.kind
702
703 if (kind !== 'peer' && kind !== 'peer-send-message' && kind !== 'task-notification') {
704 return next(e)
705 }
706
707 const item = (await load($)).items.find(
708 one => one.agentId !== undefined && e.text.includes(one.agentId),
709 )
710
711 if (item === undefined) {
712 return next(e)
713 }
714
715 // The hand-back carries the full report; keep it when the thread lacks it.
716 const report = (e.text.split('The report follows:')[1] ?? '')
717 .replace(/<\/agent-message>[\s\S]*$/, '')
718 .replace(/^ {2}/gm, '')
719 .trim()
720 const probe = report.slice(0, 60)
721 const isKept = (item.thread ?? []).some(one => one.role === 'agent' && one.text.includes(probe))
722
723 if (kind !== 'task-notification' && report !== '' && !isKept) {
724 const at = await $.clock.now()
725 await change($, backlog => sayOnce(backlog, item.id, { role: 'agent', text: report, at }))
726 await update($, pending, list => list.filter(id => id !== item.id))
727 }
728
729 return { drop: `Parked #${item.id}: the subagent's reply is in the pane.` }
730 })
731
732 on('command.run', { command: 'park' }, async ($, e) => {
733 // `/park` parks the assistant's last reply. `/park <note>` parks the note
734 // alone: what the user writes is often about something other than that
735 // reply. `/park + <note>` parks the reply with the note on it.
736 const typed = e.args.trim()
737 const hasReply = typed === '' || typed.startsWith('+')
738 const note = typed.replace(/^\+\s*/, '')
739 const reply = hasReply
740 ? (await $.session.messages()).findLast(one => one.role === 'assistant' && one.text.trim() !== '')
741 : undefined
742
743 if (hasReply && reply === undefined) {
744 return { text: 'Nothing to park yet: the assistant has not replied.' }
745 }
746
747 const body = reply?.text ?? ''
748 const sessionId = await $.session.id()
749 const now = await $.clock.now()
750 const changed = await change($, backlog =>
751 park(backlog, {
752 kind: 'needs-you',
753 title: titleOf(note, body),
754 body,
755 note,
756 parkedBy: 'user',
757 sessionId,
758 now,
759 }),
760 )
761
762 return {
763 text:
764 'error' in changed
765 ? changed.error
766 : `Parked as #${changed.backlog.nextId - 1}. /parked shows the list.`,
767 }
768 })
769
770 // What the keyboard region posts: a list row to open, an action on the open
771 // item, or a message for its side thread.
772 on('ui.message', async ($, e, next) => {
773 if (e.requestId !== PANE || typeof e.data !== 'object' || e.data === null) {
774 return next(e)
775 }
776
777 const data = e.data as {
778 act?: unknown
779 ask?: unknown
780 open?: unknown
781 focus?: unknown
782 blur?: unknown
783 draft?: unknown
784 }
785 const list = await read($, items)
786
787 // The region took the keys back from the composer with a key or a click.
788 if (data.blur === true) {
789 await update($, typing, () => false)
790 await update($, answering, () => false)
791
792 return {}
793 }
794
795 // The composer level: the pane's text field takes the focus ring.
796 if (data.focus === 'composer') {
797 const id = await read($, selected)
798 const target = list.find(one => one.id === id)
799
800 if (target !== undefined) {
801 await focusComposer($, target)
802 }
803
804 return {}
805 }
806
807 if (typeof data.open === 'number') {
808 if (list.some(one => one.id === data.open)) {
809 await goTo($, data.open)
810 }
811
812 return {}
813 }
814
815 const openId = await read($, selected)
816 const item = list.find(one => one.id === openId)
817
818 if (item === undefined) {
819 return next(e)
820 }
821
822 if (typeof data.draft === 'string') {
823 // The region typed for the composer: the field is drawn with its text.
824 const text = data.draft
825 await update($, draft, () => text)
826 } else if (typeof data.ask === 'string' && data.ask.trim() !== '') {
827 await update($, draft, () => '')
828 await update($, back, () => 0)
829 await send($, item, data.ask.trim())
830 } else if (typeof data.act === 'string') {
831 await perform($, item, data.act)
832 }
833
834 return {}
835 })
836
837 // The composer level follows the focus ring: on while the ring is on the
838 // text field, off when Tab or a click moves the ring elsewhere.
839 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
840 const moved = await next(e)
841 const isComposer = e.element?.startsWith('ask-') === true
842
843 // Each time the ring lands on the composer is a visit, numbered so the
844 // keyboard region can tell a new one from the one it last took the keys
845 // back from.
846 if (isComposer) {
847 await update($, typing, () => true)
848 await update($, typingAt, count => count + 1)
849 } else if (await read($, typing)) {
850 await update($, typing, () => false)
851 await update($, answering, () => false)
852 }
853
854 return moved
855 })
856
857 on('command.run', { command: 'parked' }, async ($, e) => {
858 if (e.args.trim() === 'lab') {
859 const isOn = !(await read($, lab))
860 await update($, lab, () => isOn)
861
862 return { text: `Parked: the keyboard view is ${isOn ? 'on' : 'off'}.` }
863 }
864
865 await show($, await load($))
866 // focus + closeOnEscape + holdToasts makes the pane a dialog: it owns the
867 // arrows and Tab until Esc, so they stop reaching the agents view.
868 await $.ui.open({
869 id: PANE,
870 title: 'Parked',
871 focus: true,
872 closeOnEscape: true,
873 holdToasts: true,
874 })
875
876 return { text: 'Parked items pane opened.' }
877 })
878
879 // In the detail view the pane's tree fits its window, so the engine has
880 // nothing to scroll: the wheel over the ticket moves the ticket, and the
881 // wheel elsewhere or the scroll keys move the thread.
882 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
883 if (e.origin.kind !== 'person') {
884 return next(e)
885 }
886
887 // An arrow pressed while the pane, not its keyboard region, has the keys
888 // arrives as a one-row scroll with no pointer: it is passed to the region
889 // as the arrow it was.
890 if (e.pointer === undefined && Math.abs(e.by) === 1 && (await read($, lab))) {
891 const key = e.by < 0 ? 'up' : 'down'
892 await update($, nudge, last => ({ key, n: last.n + 1 }))
893
894 return {}
895 }
896
897 if ((await read($, selected)) === 0) {
898 return next(e)
899 }
900
901 const row = e.pointer?.row
902 const { ticketStart, ticketEnd, maxTop, maxBack } = layout
903
904 if (row !== undefined && row >= ticketStart && row < ticketEnd) {
905 await update($, top, at => Math.min(maxTop, Math.max(0, at + e.by)))
906 } else {
907 await update($, back, at => Math.min(maxBack, Math.max(0, at - e.by)))
908 }
909
910 return {}
911 })
912
913 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
914 // The thread needs a text field, which the mobile surface does not draw.
915 if (e.surface === 'mobile') {
916 return next(e)
917 }
918
919 const table = $.ui.resolve(e)
920 const { Box, Button, Input, Text } = table
921 // The keyboard view is drawn only where the surface has Client.
922 const Client = (await read($, lab)) && 'Client' in table ? table.Client : undefined
923 const waiting = await read($, pending)
924 const list = await read($, items)
925 const openId = await read($, selected)
926 const now = await $.clock.now()
927 // What is drawn is narrower than the pane by the padding at each side.
928 const columns = Math.max(30, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2 * PAD)
929 const rule = '─'.repeat(columns)
930
931 const open = list.filter(one => one.status === 'open')
932 // Those work is blocked on come first, as `orderOf` has them.
933 const asked = open.filter(one => one.kind === 'needs-you')
934 const needs = [
935 ...asked.filter(one => one.isBlocking === true),
936 ...asked.filter(one => one.isBlocking !== true),
937 ]
938 const toneOf = (one: ParkedItem) => (one.isBlocking === true ? 'error' : 'warning')
939 const glyphOf = (one: ParkedItem) => (one.isBlocking === true ? ICON.blocking : ICON.needs)
940 const isAnswering = await read($, answering)
941 const fyi = open.filter(one => one.kind === 'fyi')
942 const done = list.filter(one => one.status === 'done').slice(-10).reverse()
943 // The order the list draws in, which Next and Prev walk.
944 const order = orderOf(list)
945 const item = order.find(one => one.id === openId)
946 // A row of the pane's own buttons, one per letter. Claude Code presses one
947 // when its letter is typed while the pane has the keyboard (ctrl+x tab),
948 // with no click; the press is handed to the keyboard region as that key.
949 const legend = (keys: readonly (readonly [string, string])[]) => (
950 <Box columnGap={2}>
951 {keys.map(([hot, means]) => (
952 <Button
953 key={`key-${hot}`}
954 plain
955 dimColor
956 hotkey={hot}
957 label={means}
958 onPress={async () => {
959 // While the composer is in use a letter is text for it, never a
960 // shortcut: the press means the text field did not get the key.
961 if (await read($, typing)) {
962 await update($, draft, text => `${text}${hot}`)
963 } else {
964 await update($, nudge, last => ({ key: hot, n: last.n + 1 }))
965 }
966 }}
967 />
968 ))}
969 </Box>
970 )
971
972 const go = async (id: number) => {
973 await update($, back, () => 0)
974 await update($, top, () => 0)
975 await update($, selected, () => id)
976 }
977
978 if (item !== undefined) {
979 const at = order.indexOf(item)
980 const before = order[at - 1]
981 const after = order[at + 1]
982 const isOpen = item.status === 'open'
983 const thread = item.thread ?? []
984 const isWaiting = waiting.includes(item.id)
985 const tone = !isOpen ? 'success' : item.kind === 'fyi' ? 'cyan' : toneOf(item)
986 const label = !isOpen
987 ? `${ICON.done} DONE`
988 : item.kind === 'fyi'
989 ? `${ICON.fyi} FYI`
990 : item.isBlocking === true
991 ? `${ICON.blocking} NEEDS ATTENTION · BLOCKING`
992 : `${ICON.needs} NEEDS ATTENTION`
993 const canAccept = isOpen && item.preferred !== undefined
994
995 // Every row is counted so the tree fits the pane's window exactly: the
996 // header, the ticket and the composer stay put, and only the two regions
997 // move. Fixed rows: status, title, meta, buttons, rule; then rule and
998 // thread heading; then the bordered composer (three rows) and the hint.
999 const bodyRows = Math.max(16, e.props.scroll.bodyRows) - 1
1000 // The note is part of the ticket's own rows, not a row above them.
1001 const noteRows = 0
1002 const doneRows = item.resolution !== undefined ? 1 : 0
1003 // The action buttons wrap in a narrow pane; count the rows they take.
1004 const actions = [
1005 canAccept ? 'a: Accept' : '',
1006 isOpen ? 'd: Done' : 'r: Reopen',
1007 isOpen ? 'w: Answer' : '',
1008 (item.refs ?? []).length > 0 ? 'g: Open' : '',
1009 after !== undefined ? 'n: Next' : '',
1010 before !== undefined ? 'p: Prev' : '',
1011 thread.some(one => one.role === 'agent') ? 's: Send' : '',
1012 'b: Back',
1013 ].filter(Boolean)
1014 let buttonRows = 1
1015 let used = 0
1016
1017 for (const action of actions) {
1018 // A button draws as `[ label ]`, with one column between neighbours.
1019 const width = action.length + 4
1020
1021 if (used > 0 && used + 1 + width > columns) {
1022 buttonRows += 1
1023 used = width
1024 } else {
1025 used += (used > 0 ? 1 : 0) + width
1026 }
1027 }
1028
1029 // The keyboard view has six rows beneath its region (actions, composer,
1030 // send, letter keys), one more than the classic view counts with one
1031 // button row.
1032 if (Client !== undefined) {
1033 buttonRows = 3
1034 }
1035
1036 const room = Math.max(6, bodyRows - (4 + buttonRows + noteRows + doneRows + 2 + 4))
1037 const ticket = ticketLines(item, columns)
1038 const ticketRows = Math.min(ticket.length, Math.max(3, Math.floor(room * 0.4)))
1039 const threadRows = Math.max(3, room - ticketRows)
1040 const talk = threadLines(thread, columns)
1041 const maxTop = Math.max(0, ticket.length - ticketRows)
1042 const maxBack = Math.max(0, talk.length - threadRows)
1043 const topAt = Math.min(await read($, top), maxTop)
1044 const backAt = Math.min(await read($, back), maxBack)
1045 const end = talk.length - backAt
1046 const shown = talk.slice(Math.max(0, end - threadRows), end)
1047 const ticketStart = 4 + buttonRows + noteRows + doneRows
1048 layout = { ticketStart, ticketEnd: ticketStart + ticketRows, maxTop, maxBack }
1049
1050 const more = (above: number, below: number) =>
1051 [above > 0 ? `↑${above}` : '', below > 0 ? `↓${below}` : ''].filter(Boolean).join(' ')
1052
1053 // Where the surface has Client, the whole item is one region that owns
1054 // the keyboard once clicked: ↑ and ↓ move between the tickets, the
1055 // actions, the composer and Send, and ← and → act on the level in focus.
1056 // What it posts is answered by the `ui.message` hook.
1057 if (Client !== undefined) {
1058 const inner = 5 + noteRows + doneRows
1059 layout = { ticketStart: inner, ticketEnd: inner + ticketRows, maxTop, maxBack }
1060 const isTyping = await read($, typing)
1061 const typed = await read($, draft)
1062
1063 return (
1064 <Box flexDirection="column" paddingX={PAD}>
1065 <Client
1066 key="detail"
1067 module="./detail.tsx"
1068 height={bodyRows - 5}
1069 props={{
1070 nudge: await read($, nudge),
1071 isTyping,
1072 typingAt: await read($, typingAt),
1073 draft: typed,
1074 view: 'detail',
1075 id: item.id,
1076 columns,
1077 label,
1078 tone,
1079 position: `${at + 1} of ${order.length}`,
1080 title: `#${item.id} ${item.title}`,
1081 meta: `parked by ${parkerOf(item)} · ${ageOf(item.createdAt, now)} ago${tagOf(item) === '' ? '' : ` · ${tagOf(item)}`}`,
1082 actions: [
1083 ...(canAccept ? [{ act: 'accept', label: 'a: Accept', hot: 'a' }] : []),
1084 isOpen ? { act: 'done', label: 'd: Done', hot: 'd' } : { act: 'reopen', label: 'r: Reopen', hot: 'r' },
1085 ...(isOpen ? [{ act: 'write', label: 'w: Answer', hot: 'w' }] : []),
1086 ...((item.refs ?? []).length > 0 ? [{ act: 'go', label: 'g: Open', hot: 'g' }] : []),
1087 ...(after !== undefined ? [{ act: 'next', label: 'n: Next', hot: 'n' }] : []),
1088 ...(before !== undefined ? [{ act: 'prev', label: 'p: Prev', hot: 'p' }] : []),
1089 { act: 'back', label: 'b: Back', hot: 'b' },
1090 ],
1091 canSend: thread.some(one => one.role === 'agent'),
1092 // How many options a digit can pick; none once the item is done.
1093 options: isOpen ? (item.options?.length ?? 0) : 0,
1094 isAnswering,
1095 note: '',
1096 resolution: item.resolution ?? '',
1097 // The newest rows are kept when a text outgrows what props may carry.
1098 ticket: ticket
1099 .slice(0, 400)
1100 .map(row =>
1101 row.isNote
1102 ? [{ text: row.text, shade: true }]
1103 : row.color !== undefined
1104 ? [{ text: row.text, color: row.color }]
1105 : ticketParts(row.text),
1106 ),
1107 ticketRows,
1108 top: topAt,
1109 talk: talk.slice(-400),
1110 threadRows,
1111 back: backAt,
1112 heading: `Side thread${thread.length === 0 ? '' : ` (${thread.length})`}`,
1113 busy: isWaiting ? 'the subagent is working…' : '',
1114 }}
1115 />
1116 <Box
1117 borderStyle="round"
1118 borderColor={isAnswering ? 'warning' : isTyping ? 'cyan' : 'gray'}
1119 paddingX={1}
1120 >
1121 <Input
1122 key={askKey(item)}
1123 value={typed}
1124 label={isTyping ? '▸ ' : ' '}
1125 placeholder={
1126 isAnswering
1127 ? 'Your answer for the main agent · enter sends'
1128 : isTyping
1129 ? 'Type a question · enter on an empty line leaves'
1130 : isWaiting
1131 ? 'Add to your question…'
1132 : 'Ask about this item…'
1133 }
1134 submitLabel="send"
1135 onSubmit={async (value: string) => {
1136 if (value.trim() !== '') {
1137 await update($, draft, () => '')
1138 await update($, back, () => 0)
1139 await send($, item, value.trim())
1140 } else {
1141 // Enter on an empty line leaves the composer: the ring
1142 // moves to the letter row, where h j k l navigate again.
1143 await $.ui.focus({ requestId: PANE, key: 'key-k' }).catch(() => undefined)
1144 }
1145 }}
1146 />
1147 </Box>
1148 <Box>
1149 {thread.some(one => one.role === 'agent') ? (
1150 <Button
1151 key="send"
1152 plain
1153 hotkey="s"
1154 label="Send findings to main agent"
1155 onPress={async () => {
1156 // While the composer is in use, s is a letter for it.
1157 if (await read($, typing)) {
1158 await update($, draft, text => `${text}s`)
1159 } else {
1160 await perform($, item, 'send')
1161 }
1162 }}
1163 />
1164 ) : (
1165 <Text dimColor>Findings can be sent to the main agent once the subagent replies.</Text>
1166 )}
1167 </Box>
1168 {legend([
1169 ['h', '←'],
1170 ['j', '↓'],
1171 ['k', '↑'],
1172 ['l', '→'],
1173 ['o', 'enter'],
1174 ['i', 'type'],
1175 ])}
1176 </Box>
1177 )
1178 }
1179
1180 return (
1181 <Box flexDirection="column" paddingX={PAD}>
1182 <Box justifyContent="space-between">
1183 <Text color={tone} bold>
1184 {label}
1185 </Text>
1186 <Text dimColor>
1187 {at + 1} of {order.length}
1188 </Text>
1189 </Box>
1190 <Text bold wrap="truncate-end">
1191 #{item.id} {item.title}
1192 </Text>
1193 <Text dimColor wrap="truncate-end">
1194 parked by {parkerOf(item)} · {ageOf(item.createdAt, now)} ago
1195 {tagOf(item) === '' ? '' : ` · ${tagOf(item)}`}
1196 </Text>
1197 <Box columnGap={1} flexWrap="wrap">
1198 {canAccept && (
1199 <Button key="accept" label="a: Accept" hotkey="a" onPress={() => perform($, item, 'accept')} />
1200 )}hooks/kit/layout.ts 24 lines1// Shared by the lens, parked and ledger mods. The source is the mod-kit folder;
2// each mod carries a copy under hooks/kit, written there by sync.sh. Edit the
3// source and sync, never a copy.
4
5// The cells a pane leaves clear at each side.
6export const PAD = 2
7
8// Text no longer than `most`, an ellipsis standing for what was cut.
9export const cut = (text: string, most: number): string =>
10 text.length > most ? `${text.slice(0, Math.max(0, most - 1))}…` : text
11
12// How long ago `then` was, as one short word: minutes, hours, then days.
13export const ageOf = (then: number, now: number): string => {
14 const minutes = Math.max(0, Math.round((now - then) / 60000))
15
16 if (minutes < 60) {
17 return `${minutes}m`
18 }
19
20 const hours = Math.round(minutes / 60)
21
22 return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`
23}
24hooks/backlog.ts 745 lines1import type { ParkedItem, ParkedKind, ParkedMessage } from '../types'
2import type { LedgerFindingSeen, LedgerRunSeen } from '../types/ledger'
3import { FILE_COLOR, FILE_ICONS, FOLDER_COLOR, ICON, STAR_COLOR, refIcon } from './kit/icons'
4
5// What of the ledger's run has already been turned into items: the tasks whose
6// gate stood failed, and the findings a review item has carried. An item is
7// made when one of these changes, so one the user closed is not made again.
8export type LedgerSeen = { gates: string[]; findings: number[] }
9
10export type Backlog = { nextId: number; items: ParkedItem[]; seen?: LedgerSeen }
11
12export const EMPTY: Backlog = { nextId: 1, items: [] }
13
14export type NewItem = {
15 kind: ParkedKind
16 title: string
17 body: string
18 note?: string
19 options?: string[]
20 preferred?: number
21 isBlocking?: boolean
22 refs?: string[]
23 task?: string
24 origin?: ParkedItem['origin']
25 parkedBy: 'model' | 'user'
26 sessionId: string
27 now: number
28}
29
30export const isBacklog = (value: unknown): value is Backlog =>
31 typeof value === 'object' &&
32 value !== null &&
33 typeof (value as Backlog).nextId === 'number' &&
34 Array.isArray((value as Backlog).items)
35
36// The glyphs and their colours are the kit's, shared with lens and ledger.
37export { ICON }
38
39// The colour each glyph that leads a ticket row is drawn in.
40const LEAD_COLORS: Record<string, string> = {
41 ...Object.fromEntries(FILE_ICONS.map(([, glyph, color]) => [glyph, color])),
42 [ICON.file]: FILE_COLOR,
43 [ICON.folderOpen]: FOLDER_COLOR,
44 [ICON.options]: 'cyan',
45}
46
47export type TicketPart = { text: string; color?: string }
48
49// One row of the ticket as the pieces it is drawn in: the glyph that leads it
50// and the default's star take a colour, the rest is plain.
51export const ticketParts = (row: string): TicketPart[] => {
52 const lead = /^(\s*)(\S) (.*)$/u.exec(row)
53 const color = lead === null ? undefined : LEAD_COLORS[lead[2] ?? '']
54 const head: TicketPart[] =
55 lead === null || color === undefined
56 ? []
57 : [...(lead[1] === '' ? [] : [{ text: lead[1] ?? '' }]), { text: `${lead[2]} `, color }]
58 const rest = head.length === 0 ? row : (lead?.[3] ?? '')
59 const mark = `${ICON.preferred} default`
60 const at = rest.indexOf(mark)
61
62 return [
63 ...head,
64 ...(at === -1
65 ? [{ text: rest }]
66 : [{ text: rest.slice(0, at) }, { text: mark, color: STAR_COLOR }, { text: rest.slice(at + mark.length) }]),
67 ].filter(part => part.text !== '')
68}
69
70// The glyph of a ref's file type; a ref is a path, or path:line.
71export const fileIcon = (ref: string): string => refIcon(ref).glyph
72
73// The options with the preferred one first, so the default is always option 1.
74const defaultFirst = (options: readonly string[], preferred: number | undefined): string[] =>
75 preferred === undefined
76 ? [...options]
77 : [...options.slice(preferred - 1, preferred), ...options.slice(0, preferred - 1), ...options.slice(preferred)]
78
79export const park = (backlog: Backlog, item: NewItem): Backlog => ({
80 ...backlog,
81 nextId: backlog.nextId + 1,
82 items: [
83 ...backlog.items,
84 {
85 id: backlog.nextId,
86 kind: item.kind,
87 title: item.title,
88 body: item.body,
89 ...(item.note ? { note: item.note } : {}),
90 ...(item.options !== undefined && item.options.length > 0
91 ? { options: defaultFirst(item.options, item.preferred) }
92 : {}),
93 ...(item.preferred !== undefined ? { preferred: 1 } : {}),
94 ...(item.isBlocking === true ? { isBlocking: true as const } : {}),
95 ...(item.refs !== undefined && item.refs.length > 0 ? { refs: item.refs } : {}),
96 ...(item.task ? { task: item.task } : {}),
97 ...(item.origin !== undefined ? { origin: item.origin } : {}),
98 parkedBy: item.parkedBy,
99 sessionId: item.sessionId,
100 createdAt: item.now,
101 status: 'open',
102 },
103 ],
104})
105
106// A string answer is the reason the change was refused.
107export const resolve = (
108 backlog: Backlog,
109 id: number,
110 resolution: string,
111 resolvedBy: 'model' | 'user',
112 now: number,
113): Backlog | string => {
114 const item = backlog.items.find(one => one.id === id)
115
116 if (item === undefined) {
117 return `No parked item #${id}.`
118 }
119
120 if (item.status === 'done') {
121 return `Parked item #${id} is already done.`
122 }
123
124 return {
125 ...backlog,
126 items: backlog.items.map(one =>
127 one.id === id
128 ? { ...one, status: 'done', resolution, resolvedBy, resolvedAt: now }
129 : one,
130 ),
131 }
132}
133
134export const reopen = (backlog: Backlog, id: number): Backlog | string => {
135 const item = backlog.items.find(one => one.id === id)
136
137 if (item === undefined) {
138 return `No parked item #${id}.`
139 }
140
141 const { resolution: _r, resolvedBy: _b, resolvedAt: _a, answer: _w, answeredIn: _s, isUnsent: _u, ...rest } = item
142
143 return {
144 ...backlog,
145 items: backlog.items.map(one =>
146 one.id === id ? { ...rest, status: 'open' } : one,
147 ),
148 }
149}
150
151export const statusText = (items: readonly ParkedItem[]): string | undefined => {
152 const open = items.filter(one => one.status === 'open')
153 const needs = open.filter(one => one.kind === 'needs-you').length
154 const blocking = open.filter(one => one.kind === 'needs-you' && one.isBlocking === true).length
155 const fyi = open.length - needs
156 const parts = [
157 needs > 0 ? `${needs} need${needs === 1 ? 's' : ''} attention${blocking > 0 ? ` (${blocking} blocking)` : ''}` : '',
158 fyi > 0 ? `${fyi} FYI` : '',
159 ].filter(Boolean)
160
161 // The row is for what waits on the person: FYIs alone are not worth one,
162 // and ride along only when something does need them.
163 return needs === 0 ? undefined : `📌 ${parts.join(' · ')}`
164}
165
166export const titleOf = (note: string, reply: string): string => {
167 const first =
168 note.trim() ||
169 (reply.split('\n').find(line => line.trim() !== '') ?? '').replace(/^[#>*\-\s]+/, '').trim()
170
171 return first.length > 80 ? `${first.slice(0, 79)}…` : first
172}
173
174export { ageOf } from './kit/layout'
175
176export const describe = (item: ParkedItem): string =>
177 [
178 `#${item.id} [${item.kind}] ${item.title}${item.status === 'done' ? ' (done)' : ''}`,
179 item.note ? `Note: ${item.note}` : '',
180 item.isBlocking === true && item.status === 'open' ? 'Blocking: work is waiting on the user.' : '',
181 item.body,
182 item.options !== undefined
183 ? `Options: ${item.options.map((label, at) => `${at + 1}) ${label}`).join(' ')}${item.preferred !== undefined ? ` (default ${item.preferred})` : ''}`
184 : '',
185 item.refs !== undefined ? `Files: ${item.refs.join(', ')}` : '',
186 item.resolution ? `Resolution: ${item.resolution}` : '',
187 ]
188 .filter(Boolean)
189 .join('\n')
190
191// Adds a message to an item's side thread, and names the subagent answering it.
192export const say = (
193 backlog: Backlog,
194 id: number,
195 message: ParkedMessage,
196 agentId?: string,
197): Backlog | string => {
198 if (!backlog.items.some(one => one.id === id)) {
199 return `No parked item #${id}.`
200 }
201
202 return {
203 ...backlog,
204 items: backlog.items.map(one =>
205 one.id === id
206 ? {
207 ...one,
208 thread: [...(one.thread ?? []), message],
209 ...(agentId !== undefined ? { agentId } : {}),
210 }
211 : one,
212 ),
213 }
214}
215
216const lastOf = (item: ParkedItem, role: ParkedMessage['role']) =>
217 (item.thread ?? []).findLast(one => one.role === role)
218
219// What a fresh subagent is told: the item, the thread so far and the new message.
220export const investigationPrompt = (item: ParkedItem, text: string, brief = ''): string =>
221 [
222 'You are helping the user look into one parked item from a long orchestration session, in a side thread the main agent does not see.',
223 'Investigate with your tools as far as the question needs, then answer the user directly and concisely. Do not change any files unless the user asks you to.',
224 '',
225 `Parked item #${item.id} [${item.kind}]: ${item.title}`,
226 item.body,
227 item.note ? `The user's note: ${item.note}` : '',
228 ...(brief.trim() !== '' ? ['', 'Briefing from the main session, written for you from its full context:', brief.trim()] : []),
229 ...((item.thread ?? []).some(one => one.role !== 'note')
230 ? ['', 'Earlier in this thread:', ...(item.thread ?? []).filter(one => one.role !== 'note').map(one => `${one.role === 'you' ? 'User' : 'You'}: ${one.text}`)]
231 : []),
232 '',
233 `The user's message: ${text}`,
234 ]
235 .filter((line, index, all) => line !== '' || all[index - 1] !== '')
236 .join('\n')
237
238// The one message "Send to agent" posts to the main conversation.
239const cut = (text: string, most: number) => (text.length > most ? `${text.slice(0, most - 1)}…` : text)
240
241// What a small model is asked, to turn a side thread into the few lines the
242// main agent needs: the thread itself stays out of the main conversation.
243export const summaryRequest = (item: ParkedItem): string =>
244 [
245 'Below is a parked item from a coding session and a side thread in which the user and a subagent looked into it.',
246 'Summarise the thread for the main agent in at most 120 words: what was found, what the user decided or wants, and what the main agent should do next, if anything.',
247 'Plain text, no preamble, no headings. State only what the thread supports. If it reached no conclusion, say so in one line.',
248 '',
249 `Parked item #${item.id} [${item.kind}]: ${item.title}`,
250 cut(item.body, 4000),
251 '',
252 'Side thread:',
253 // The newest part of a long thread is the part that holds its conclusion.
254 (item.thread ?? [])
255 .filter(one => one.role !== 'note')
256 .map(one => `${one.role === 'you' ? 'User' : 'Subagent'}: ${one.text}`)
257 .join('\n\n')
258 .slice(-12000),
259 ].join('\n')
260
261// The one message "Send" posts to the main conversation: the thread's summary,
262// or, when none could be made, the start of the subagent's last reply.
263export const handoff = (item: ParkedItem, summary = ''): string => {
264 const finding = lastOf(item, 'agent')
265 const said = lastOf(item, 'you')
266
267 return [
268 `About parked item #${item.id}: ${item.title}`,
269 '',
270 ...(summary.trim() !== ''
271 ? ['Summary of a side thread I held with a subagent on this:', summary.trim()]
272 : [
273 'I looked into this in a side thread with a subagent. The start of its latest findings:',
274 cut(finding?.text ?? '(none yet)', 600),
275 ...(said !== undefined ? ['', `My last message there: ${cut(said.text, 300)}`] : []),
276 ]),
277 ].join('\n')
278}
279
280export const doneNote = (item: ParkedItem): string => {
281 const finding = lastOf(item, 'agent')
282
283 if (finding === undefined) {
284 return 'Marked done by you.'
285 }
286
287 const line = finding.text.replace(/\s+/g, ' ').trim()
288
289 return `Marked done by you after a side thread. Last finding: ${line.length > 140 ? `${line.slice(0, 139)}…` : line}`
290}
291
292// Splits text into rows no wider than `width`, so a region of the pane can show
293// a window of them; a wrapped list line keeps its indent.
294export const wrap = (text: string, width: number): string[] =>
295 text.split('\n').flatMap(line => {
296 const clean = line.replace(/\*\*(.+?)\*\*/g, '$1').trimEnd()
297 const indent = Math.min(clean.match(/^\s*(?:[-*]\s+|\d+\.\s+)?/)?.[0].length ?? 0, Math.max(0, width - 10))
298 const rows: string[] = []
299 let rest = clean
300
301 while (rest.length > width) {
302 const space = rest.lastIndexOf(' ', width)
303 const cut = space > indent ? space : width
304 rows.push(rest.slice(0, cut))
305 rest = ' '.repeat(indent) + rest.slice(cut).trimStart()
306 }
307
308 return [...rows, rest]
309 })
310
311// What the main session is asked, in a fork of its own context, so a thread's
312// subagent starts with the background an orchestrator would hand it.
313export const briefRequest = (item: ParkedItem): string =>
314 [
315 'This is a request for a handover, not a continuation of your work. Do not act on it or call tools.',
316 'A separate subagent is about to help the user look into the parked item below. It knows nothing about this session.',
317 'Write the briefing you would give it: the goal of the work this item came from; what was done and found that bears on it; the files, commands and names it will need; decisions already made; and what is still unknown.',
318 'Be specific and brief, in plain text with no preamble. If nothing in this session bears on the item, say so in one line.',
319 '',
320 `Parked item #${item.id} [${item.kind}]: ${item.title}`,
321 item.body,
322 ].join('\n')
323
324// The order the pane lists items in, which Next and Prev walk: open items that
325// need the user (those blocking work first), then open FYIs, then the ten most
326// recently parked done items.
327export const orderOf = (items: readonly ParkedItem[]): ParkedItem[] => {
328 const open = items.filter(one => one.status === 'open')
329
330 return [
331 ...open.filter(one => one.kind === 'needs-you' && one.isBlocking === true),
332 ...open.filter(one => one.kind === 'needs-you' && one.isBlocking !== true),
333 ...open.filter(one => one.kind === 'fyi'),
334 ...items.filter(one => one.status === 'done').slice(-10).reverse(),
335 ]
336}
337
338// A thread as one Markdown text, for the formatted view.
339export const threadText = (item: ParkedItem): string =>
340 (item.thread ?? [])
341 .map(one =>
342 one.role === 'note' ? `*· ${one.text}*` : `**${one.role === 'you' ? 'You' : 'Subagent'}**\n\n${one.text}`,
343 )
344 .join('\n\n')
345
346// A reply's text with spacing and Markdown marks removed, cut short: two
347// routes deliver the same report with different escaping and indentation.
348const gist = (text: string) => text.replace(/[\s\\*`_>#-]/g, '').slice(0, 80)
349
350// Adds a subagent's reply unless the thread already holds it since the user's
351// last message: its turn's end and its hand-back both carry the same report.
352export const sayOnce = (backlog: Backlog, id: number, message: ParkedMessage): Backlog | string => {
353 const thread = backlog.items.find(one => one.id === id)?.thread ?? []
354 const since = thread.slice(thread.findLastIndex(one => one.role === 'you') + 1)
355
356 return since.some(one => one.role === 'agent' && gist(one.text) === gist(message.text))
357 ? backlog
358 : say(backlog, id, message)
359}
360
361export type ParkedAction = { act: 'done' | 'reopen' | 'next' | 'prev' | 'back'; label: string; hot: string }
362
363// The actions an item's row offers, in the order ← and → walk them.
364export const actionsOf = (order: readonly ParkedItem[], item: ParkedItem): ParkedAction[] => {
365 const at = order.findIndex(one => one.id === item.id)
366
367 return [
368 item.status === 'open'
369 ? { act: 'done', label: 'Done', hot: 'd' }
370 : { act: 'reopen', label: 'Reopen', hot: 'r' },
371 ...(at < order.length - 1 ? [{ act: 'next', label: 'Next', hot: 'n' } as const] : []),
372 ...(at > 0 ? [{ act: 'prev', label: 'Prev', hot: 'p' } as const] : []),
373 { act: 'back', label: 'Back', hot: 'b' },
374 ]
375}
376
377export type ThreadLine = { text: string; kind: 'you' | 'dot' | 'text' | 'note' }
378
379// A thread as rows a window can show a slice of. The user's message is shaded
380// rows under a ❯, as a prompt is; a reply opens with a dot and its later
381// rows are indented under it; a note is one dim line. A blank row parts them.
382export const threadLines = (thread: readonly ParkedMessage[], columns: number): ThreadLine[] =>
383 thread.flatMap((one, index): ThreadLine[] => {
384 const gap: ThreadLine[] = index > 0 ? [{ text: '', kind: 'text' }] : []
385
386 if (one.role === 'you') {
387 // As Claude Code draws a prompt: a ❯ on the first row, later rows
388 // indented under it, each row padded so its shading runs the full width.
389 return [
390 ...gap,
391 ...wrap(one.text, Math.max(4, columns - 2)).map(
392 (text, at): ThreadLine => ({ text: `${at === 0 ? '❯' : ' '} ${text}`.padEnd(columns), kind: 'you' }),
393 ),
394 ]
395 }
396
397 if (one.role === 'note') {
398 return [...gap, ...wrap(`· ${one.text}`, columns).map((text): ThreadLine => ({ text, kind: 'note' }))]
399 }
400
401 return [
402 ...gap,
403 ...wrap(one.text, Math.max(4, columns - 2)).map(
404 (text, at): ThreadLine => ({ text: `${at === 0 ? '●' : ' '} ${text}`, kind: at === 0 ? 'dot' : 'text' }),
405 ),
406 ]
407 })
408
409// What the ticket region shows: the choices first, so they are in view however
410// long the body is, then the body, then the places it is about.
411export const ticketText = (item: ParkedItem): string =>
412 [
413 item.options !== undefined
414 ? [
415 `${ICON.options} Options`,
416 ...item.options.map(
417 (label, at) =>
418 ` ${at + 1}. ${label}${item.preferred === at + 1 ? ` ${ICON.preferred} default` : ''}`,
419 ),
420 ].join('\n')
421 : '',
422 item.body,
423 item.refs !== undefined
424 ? [`${ICON.folderOpen} Files`, ...item.refs.map(ref => ` ${fileIcon(ref)} ${ref}`)].join('\n')
425 : '',
426 ]
427 .filter(Boolean)
428 .join('\n\n')
429
430const optionText = (item: ParkedItem, at: number) => `option ${at}, ${item.options?.[at - 1] ?? ''}`
431
432// `text` is the answer as the main agent reads it. A silent answer is not sent
433// at all: the user took the default of an item the agent had gone ahead on.
434export type ParkedAnswer = { text: string; isSilent: boolean }
435
436// The user's answer to an item: an option by its number (a typed number names
437// one too, where the item has options), or their own words. A string answer
438// is the reason it was refused.
439export const answerOf = (item: ParkedItem, reply: number | string): ParkedAnswer | string => {
440 const typed = typeof reply === 'string' ? reply.trim() : ''
441 const at =
442 typeof reply === 'number'
443 ? reply
444 : item.options !== undefined && /^[1-9]$/.test(typed)
445 ? Number(typed)
446 : undefined
447
448 if (at === undefined) {
449 return typed === '' ? `Parked item #${item.id} needs an answer.` : { text: typed, isSilent: false }
450 }
451
452 if (!Number.isInteger(at) || at < 1 || at > (item.options?.length ?? 0)) {
453 return `Parked item #${item.id} has no option ${at}.`
454 }
455
456 return { text: optionText(item, at), isSilent: item.isBlocking !== true && at === item.preferred }
457}
458
459// Marks an item done with the user's answer, to be sent to the main agent
460// unless it is silent.
461export const answer = (
462 backlog: Backlog,
463 id: number,
464 reply: number | string,
465 now: number,
466 sessionId?: string,
467): Backlog | string => {
468 const item = backlog.items.find(one => one.id === id)
469
470 if (item === undefined) {
471 return `No parked item #${id}.`
472 }
473
474 if (item.status === 'done') {
475 return `Parked item #${id} is already done.`
476 }
477
478 const made = answerOf(item, reply)
479
480 if (typeof made === 'string') {
481 return made
482 }
483
484 const line = made.text.replace(/\s+/g, ' ')
485 const resolution = made.isSilent
486 ? `You accepted the default: ${line}`
487 : `You answered: ${line.length > 140 ? `${line.slice(0, 139)}…` : line}`
488
489 return {
490 ...backlog,
491 items: backlog.items.map(one =>
492 one.id === id
493 ? {
494 ...one,
495 status: 'done',
496 resolution,
497 resolvedBy: 'user',
498 resolvedAt: now,
499 answer: made.text,
500 ...(sessionId !== undefined ? { answeredIn: sessionId } : {}),
501 ...(made.isSilent ? {} : { isUnsent: true as const }),
502 }
503 : one,
504 ),
505 }
506}
507
508// The answers the main agent has not been told yet. Given a session, only the
509// ones answered in it: another session's agent has no use for them.
510export const unsentOf = (items: readonly ParkedItem[], sessionId?: string): ParkedItem[] =>
511 items.filter(
512 one =>
513 one.isUnsent === true &&
514 one.answer !== undefined &&
515 (sessionId === undefined || one.answeredIn === sessionId),
516 )
517
518// Puts answers back among the unsent, when sending them failed.
519export const unsend = (backlog: Backlog, ids: readonly number[]): Backlog => ({
520 ...backlog,
521 items: backlog.items.map(one =>
522 ids.includes(one.id) && one.answer !== undefined ? { ...one, isUnsent: true as const } : one,
523 ),
524})
525
526export const sent = (backlog: Backlog, ids: readonly number[]): Backlog => ({
527 ...backlog,
528 items: backlog.items.map(one => {
529 if (!ids.includes(one.id)) {
530 return one
531 }
532
533 const { isUnsent: _u, ...rest } = one
534
535 return rest
536 }),
537})
538
539// The one message that carries the user's answers to the main agent. Where an
540// answer departs from the default the agent went ahead on, it says so.
541export const answersNote = (items: readonly ParkedItem[]): string =>
542 [
543 'My answers to parked items:',
544 ...items.map(one => {
545 const isOverride =
546 one.isBlocking !== true &&
547 one.preferred !== undefined &&
548 one.answer !== optionText(one, one.preferred)
549
550 return `- #${one.id} ${one.title}: ${one.answer ?? ''}${isOverride ? ` (not your default, ${optionText(one, one.preferred ?? 0)}: revise what was built on it)` : ''}`
551 }),
552 ].join('\n')
553
554// The unit findings with no task of the run are reviewed under.
555const NO_UNIT = 'Other changes'
556
557const placeOf = (finding: LedgerFindingSeen) =>
558 finding.line === undefined ? finding.path : `${finding.path}:${finding.line}`
559
560const many = (count: number, one: string) => `${count} ${one}${count === 1 ? '' : 's'}`
561
562// What a unit's review item says of its open findings.
563const reviewOf = (unit: string, open: readonly LedgerFindingSeen[]) => {
564 const errors = open.filter(one => one.severity === 'error').length
565
566 return {
567 kind: (errors > 0 ? 'needs-you' : 'fyi') as ParkedKind,
568 title: `Review of ${unit}: ${many(open.length, 'finding')}${errors > 0 ? ` (${many(errors, 'error')})` : ''}`,
569 body: open
570 .map(one => `- ${one.severity} ${placeOf(one)} ${one.summary}${one.task ? ` (${one.task})` : ''}`)
571 .join('\n'),
572 refs: [...new Set(open.map(placeOf))],
573 }
574}
575
576const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
577
578// Brings the backlog in step with the ledger mod's run, where that mod is
579// loaded: a task whose gate fails gets an FYI that closes when it leaves that
580// state, and each unit with open findings gets one item listing them, which
581// closes when all are fixed. Answers the same backlog when nothing changed.
582export const syncLedger = (
583 backlog: Backlog,
584 run: LedgerRunSeen | null | undefined,
585 now: number,
586 sessionId: string,
587): Backlog => {
588 const seen = backlog.seen ?? { gates: [], findings: [] }
589 const tasks = run?.tasks ?? []
590 const findings = run?.findings ?? []
591 let next: Backlog = backlog
592
593 const close = (isOurs: (item: ParkedItem) => boolean, resolution: string) => {
594 if (next.items.some(one => one.status === 'open' && isOurs(one))) {
595 next = {
596 ...next,
597 items: next.items.map(one =>
598 one.status === 'open' && isOurs(one)
599 ? { ...one, status: 'done', resolution, resolvedBy: 'model', resolvedAt: now }
600 : one,
601 ),
602 }
603 }
604 }
605
606 // Gates: an item as a task's gate starts failing, closed as it stops.
607 const failed = tasks.filter(one => one.status === 'gate-failed')
608
609 for (const task of failed.filter(one => !seen.gates.includes(one.id))) {
610 next = park(next, {
611 kind: 'fyi',
612 title: `Gate failed: ${task.id} ${task.title}`,
613 body: `${task.note ?? 'The gate failed; no note was given.'}\n\nThe agent is expected to fix this and run the gate again. This item closes by itself when it does.`,
614 task: task.id,
615 origin: { kind: 'gate', task: task.id },
616 parkedBy: 'model',
617 sessionId,
618 now,
619 })
620 }
621
622 for (const id of seen.gates.filter(one => !failed.some(task => task.id === one))) {
623 const status = tasks.find(one => one.id === id)?.status
624
625 close(
626 item => item.origin?.kind === 'gate' && item.origin.task === id,
627 status === 'done'
628 ? 'The gate passed.'
629 : status === undefined
630 ? 'The run was cleared or planned again.'
631 : 'The task is being reworked.',
632 )
633 }
634
635 // Reviews: one item per unit that has open findings.
636 const unitOf = (finding: LedgerFindingSeen) =>
637 tasks.find(one => one.id === finding.task)?.unit ?? NO_UNIT
638 const open = findings.filter(one => one.status === 'open')
639 const units = new Set([
640 ...open.map(unitOf),
641 ...next.items.flatMap(one =>
642 one.status === 'open' && one.origin?.kind === 'review' ? [one.origin.unit] : [],
643 ),
644 ])
645
646 for (const unit of units) {
647 const mine = open.filter(one => unitOf(one) === unit)
648 const item = next.items.find(
649 one => one.status === 'open' && one.origin?.kind === 'review' && one.origin.unit === unit,
650 )
651
652 if (mine.length === 0) {
653 close(
654 one => one.origin?.kind === 'review' && one.origin.unit === unit,
655 run ? 'Every finding is fixed.' : 'The run was cleared.',
656 )
657 } else if (item !== undefined) {
658 const { refs, ...said } = reviewOf(unit, mine)
659 const updated: ParkedItem = { ...item, ...said, refs }
660
661 if (!same(updated, item)) {
662 next = { ...next, items: next.items.map(one => (one.id === item.id ? updated : one)) }
663 }
664 } else if (mine.some(one => !seen.findings.includes(one.id))) {
665 next = park(next, {
666 ...reviewOf(unit, mine),
667 origin: { kind: 'review', unit },
668 parkedBy: 'model',
669 sessionId,
670 now,
671 })
672 }
673 }
674
675 const after: LedgerSeen = {
676 gates: failed.map(one => one.id),
677 findings: run ? [...new Set([...seen.findings, ...findings.map(one => one.id)])] : [],
678 }
679
680 return same(after, seen) && next === backlog ? backlog : { ...next, seen: after }
681}
682
683export type TicketLine = { text: string; isNote: boolean; color?: string }
684
685// The ticket as rows a window can show a slice of. What the user parked is in
686// labelled sections: "Your note", shaded under a ❯ as a prompt is, then
687// "Assistant reply" under a dot, as the assistant's replies are drawn. What
688// the agent parked is its own description and is drawn plain, with no label.
689export const ticketLines = (item: ParkedItem, columns: number): TicketLine[] => {
690 const note = (item.note ?? '').trim()
691 const body = ticketText(item)
692 const inner = Math.max(4, columns - 2)
693 const isReply = item.parkedBy === 'user'
694 const noteRows: TicketLine[] =
695 note === ''
696 ? []
697 : [
698 { text: 'Your note:', isNote: false, color: 'warning' },
699 ...wrap(note, inner).map((text, at) => ({
700 text: `${at === 0 ? '❯' : ' '} ${text}`.padEnd(columns),
701 isNote: true,
702 })),
703 ]
704 const bodyRows: TicketLine[] =
705 body === ''
706 ? []
707 : isReply
708 ? [
709 { text: 'Assistant reply:', isNote: false, color: 'cyan' },
710 ...wrap(body, inner).map((text, at) => ({ text: `${at === 0 ? '●' : ' '} ${text}`, isNote: false })),
711 ]
712 : wrap(body, columns).map(text => ({ text, isNote: false }))
713
714 return [
715 ...noteRows,
716 ...(noteRows.length > 0 && bodyRows.length > 0 ? [{ text: '', isNote: false }] : []),
717 ...bodyRows,
718 ]
719}
720
721// Every run the ledger keeps, as the one view `syncLedger` reads: the tasks of
722// the runs under way, and the findings of all. A task id names one task among
723// the runs under way and a finding's number one finding, so nothing clashes.
724// With no run at all there is nothing: undefined when the ledger is not there.
725export const mergedRuns = (
726 runs: readonly LedgerRunSeen[] | null | undefined,
727): LedgerRunSeen | null | undefined =>
728 runs === null || runs === undefined
729 ? undefined
730 : runs.length === 0
731 ? null
732 : {
733 goal: '',
734 tasks: runs.filter(one => one.doneAt === undefined).flatMap(one => one.tasks),
735 findings: runs.flatMap(one => one.findings),
736 }
737
738// A file reference as a place to show: its path, and its line (0 when the
739// reference names the file alone). A column after the line is left out.
740export const placeOfRef = (ref: string): { path: string; line: number } => {
741 const at = /^(.*?):(\d+)(?::\d+)?$/.exec(ref.trim())
742
743 return at === null ? { path: ref.trim(), line: 0 } : { path: at[1] ?? '', line: Number(at[2]) }
744}
745hooks/detail.tsx 528 lines1import type { ClientModule, ClientSurface } from 'claude-code'
2
3type Line = { text: string; kind: 'you' | 'dot' | 'text' | 'note' }
4type Action = { act: string; label: string; hot: string }
5
6// One row of the list: a section heading, or an item.
7type Entry = {
8 kind: 'head' | 'row'
9 id: number
10 glyph: string
11 tone: string
12 text: string
13 meta: string
14 isDone: boolean
15}
16
17type ListProps = {
18 view: 'list'
19 columns: number
20 rows: number
21 summary: string
22 entries: Entry[]
23 nudge: Nudge
24}
25
26type DetailProps = {
27 view: 'detail'
28 id: number
29 columns: number
30 label: string
31 tone: string
32 position: string
33 title: string
34 meta: string
35 actions: Action[]
36 // Whether the thread has findings the Send row can pass to the main agent.
37 canSend: boolean
38 // How many options the item offers: a digit up to it answers with that one.
39 options: number
40 // Whether the composer sends the user's answer to the main agent.
41 isAnswering: boolean
42 note: string
43 resolution: string
44 // The ticket's rows, how many its window shows, and how far it is scrolled.
45 // Each row is the pieces it is drawn in, a glyph's piece with its colour.
46 ticket: { text: string; color?: string; shade?: boolean }[][]
47 ticketRows: number
48 top: number
49 // The thread's rows, how many its window shows, and how far the pane's own
50 // scroll (the wheel) has moved it up from its newest row.
51 talk: Line[]
52 threadRows: number
53 back: number
54 heading: string
55 // What the thread is waiting on, shown beside a spinner; empty when idle.
56 busy: string
57 // Whether the pane's text field has the focus ring: the composer level.
58 isTyping: boolean
59 // Counts the times the ring has entered the text field.
60 typingAt: number
61 // The text the composer holds that was typed through this region.
62 draft: string
63 nudge: Nudge
64}
65
66type Props = ListProps | DetailProps
67
68// The levels of an item's view the keyboard moves between with ↑ and ↓.
69const TICKETS = 0
70const ACTIONS = 1
71const COMPOSER = 2
72
73// `id` is the item last shown in full (0 for none), and `cursor` the list row
74// the keyboard is on. One instance draws both views, so the keyboard stays
75// with the pane when it moves between the list and an item.
76type State = {
77 id: number
78 tier: number
79 pick: number
80
81 up: number
82 cursor: number
83 frame: number
84 // The visit to the composer this region has ended by taking the keys back.
85 left: number
86 // What this region has typed for the composer, ahead of what props hold;
87 // null when it has typed nothing.
88 typed: string | null
89}
90
91const START: State = { id: 0, tier: TICKETS, pick: 0, up: 0, cursor: 0, frame: 0, left: -1, typed: null }
92
93const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
94// Stops the spinner's timer while one runs; the module keeps it between calls.
95let stopSpin: (() => void) | undefined
96
97const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
98
99// The vim keys, as the arrows and Enter they stand for.
100const VIM: Record<string, string> = { h: 'left', j: 'down', k: 'up', l: 'right', o: 'return' }
101
102// A key pressed on one of the pane's own letter buttons, which need no click:
103// the hooks module counts them, and a new count is a key to answer.
104type Nudge = { key: string; n: number }
105type KeyInfo = { ctrl?: true; meta?: true; isNudge?: true }
106
107// The last nudge answered (-1 before the first drawing), and the last ↑ or ↓
108// that came as a real key: the pane may report the same press again as a
109// scroll, which the hooks module turns into a nudge.
110let seen = -1
111let lastArrow = { key: '', at: 0 }
112
113// Hands a view's key handler both routes: the region's own keys, once it is
114// clicked, and the nudges from the pane's letter buttons.
115const listen = (surface: ClientSurface<State>, nudge: Nudge, onKey: (key: string, info?: KeyInfo) => void) => {
116 surface.onKey(event => {
117 if (event.key === 'up' || event.key === 'down') {
118 lastArrow = { key: event.key, at: Date.now() }
119 }
120
121 onKey(event.key, event)
122 })
123
124 if (seen === -1) {
125 seen = nudge.n
126 } else if (nudge.n !== seen) {
127 seen = nudge.n
128
129 if (!(nudge.key === lastArrow.key && Date.now() - lastArrow.at < 80)) {
130 onKey(nudge.key, { isNudge: true })
131 }
132 }
133}
134
135// The backlog as a list the keyboard walks: ↑ and ↓ move, Enter or → opens.
136const List = (props: ListProps, surface: ClientSurface<State>) => {
137 const { Box, Text } = surface.elements
138 const state = surface.state ?? START
139 const rows = props.entries.filter(entry => entry.kind === 'row')
140 const last = Math.max(0, rows.length - 1)
141 // Coming back from an item, the cursor is on that item's row.
142 const came = state.id === 0 ? -1 : rows.findIndex(entry => entry.id === state.id)
143 const cursor = clamp(came >= 0 ? came : state.cursor, 0, last)
144 const move = (to: number) => surface.setState({ ...state, id: 0, cursor: clamp(to, 0, last) })
145 const open = (at: number) => {
146 const entry = rows[at]
147
148 if (entry !== undefined) {
149 // An item opened from the list starts on its tickets level.
150 surface.setState({ ...state, id: 0, cursor: at, tier: TICKETS })
151 surface.post({ open: entry.id })
152 }
153 }
154
155 // The window of entries that fits, kept around the cursor's row.
156 const height = Math.max(3, props.rows - 4)
157 const here = rows[cursor]
158 const cursorAt = here === undefined ? 0 : props.entries.indexOf(here)
159 const start = clamp(cursorAt - Math.floor(height / 2), 0, Math.max(0, props.entries.length - height))
160 const shown = props.entries.slice(start, start + height)
161
162 listen(surface, props.nudge, raw => {
163 const key = VIM[raw] ?? raw
164
165 if (key === 'up') {
166 move(cursor - 1)
167 } else if (key === 'down' || key === 'tab') {
168 move(cursor + 1)
169 } else if (key === 'pageup') {
170 move(cursor - height)
171 } else if (key === 'pagedown') {
172 move(cursor + height)
173 } else if (key === 'return' || key === 'right') {
174 open(cursor)
175 }
176 })
177
178 surface.onPointer(event => {
179 // Two rows sit above the entries: the summary and a rule.
180 const entry = event.type === 'down' ? shown[event.y - 2] : undefined
181
182 if (entry !== undefined && entry.kind === 'row') {
183 open(rows.indexOf(entry))
184 }
185 })
186
187 return (
188 <Box flexDirection="column">
189 <Text bold wrap="truncate-end">
190 📌 Parked{props.summary === '' ? '' : ` · ${props.summary}`}
191 </Text>
192 <Text dimColor>{'─'.repeat(props.columns)}</Text>
193 <Box flexDirection="column" height={height} overflow="hidden">
194 {rows.length === 0 && <Text dimColor>Nothing parked yet. /park [note] parks the last reply.</Text>}
195 {shown.map(entry => {
196 if (entry.kind === 'head') {
197 return (
198 <Text color={entry.tone} bold>
199 {entry.text}
200 </Text>
201 )
202 }
203
204 const at = rows.indexOf(entry)
205 const isHere = at === cursor
206
207 return (
208 <Box justifyContent="space-between">
209 <Text wrap="truncate-end">
210 <Text color="cyan">{isHere ? '▸ ' : ' '}</Text>
211 <Text color={entry.tone}>{entry.glyph} </Text>
212 <Text dimColor>#{entry.id} </Text>
213 <Text inverse={isHere} dimColor={entry.isDone && !isHere}>
214 {entry.text}
215 </Text>
216 </Text>
217 <Text dimColor>{entry.meta}</Text>
218 </Box>
219 )
220 })}
221 </Box>
222 <Text dimColor>{'─'.repeat(props.columns)}</Text>
223 <Text dimColor wrap="truncate-end">
224 click once for keys · ↑↓ or j k move · enter, → or l opens · esc releases
225 </Text>
226 </Box>
227 )
228}
229
230// One parked item's header, actions, ticket and side thread. ↑ and ↓ move
231// between the tickets, the actions and the composer; ← and → act on the level
232// in focus. The composer itself is the pane's own text field beneath this
233// region: reaching its level asks the hooks module to give it the focus ring,
234// so typing needs no click. What changes the backlog is posted there too.
235const Detail = (props: DetailProps, surface: ClientSurface<State>) => {
236 const { Box, Text } = surface.elements
237 const kept = surface.state
238 // Another item starts afresh, but stays on the level the keyboard was at.
239 const state: State =
240 kept !== undefined && kept.id === props.id
241 ? kept
242 : { ...START, id: props.id, tier: kept?.tier ?? TICKETS, cursor: kept?.cursor ?? 0 }
243 const set = (patch: Partial<State>) => surface.setState({ ...state, ...patch })
244 // The composer level is on while the pane's text field has the focus ring.
245 // A key or a click that reaches this region means the region has the keys
246 // instead, whatever the ring last reported: that visit to the composer is
247 // then over, and `left` remembers which one it was.
248 const isTyping = props.isTyping && state.left !== props.typingAt
249 const tier = isTyping ? COMPOSER : state.tier === COMPOSER ? ACTIONS : state.tier
250 const leave = isTyping ? { left: props.typingAt } : {}
251 const toComposer = () => surface.post({ focus: 'composer' })
252 const pick = clamp(state.pick, 0, props.actions.length - 1)
253 const mostBack = Math.max(0, props.talk.length - props.threadRows)
254 const backAt = clamp(props.back + state.up, 0, mostBack)
255 const scroll = (by: number) => set({ up: clamp(backAt + by, 0, mostBack) - props.back })
256
257 // The spinner turns on the surface's frame clock while the thread waits. The
258 // timer reads the state as it is when it fires, not as it was when it began.
259 if (props.busy !== '' && stopSpin === undefined) {
260 stopSpin = surface.every(120, () => {
261 const now = surface.state ?? state
262 surface.setState({ ...now, frame: now.frame + 1 })
263 })
264 } else if (props.busy === '' && stopSpin !== undefined) {
265 stopSpin()
266 stopSpin = undefined
267 }
268
269 const run = (act: string | undefined) => {
270 if (act !== undefined && (act !== 'send' || props.canSend)) {
271 surface.post({ act })
272 }
273 }
274
275 // Where each action sits on its row, for a click to find it.
276 let column = 2
277 const spans = props.actions.map(action => {
278 const from = column
279 column += action.label.length + 3
280
281 return { action, from, to: column - 2 }
282 })
283 const actionRow = 3
284
285 // Both listeners are set again on each call, so they read this call's state.
286 listen(surface, props.nudge, (raw, info = {}) => {
287 // On the composer level every key is for the composer, never a shortcut.
288 // A key that arrives here means this region has the keyboard and the text
289 // field does not, so the region does the typing itself: it keeps the text
290 // and posts it, and the hooks module draws it in the field. Only ↑ and ↓
291 // leave the level.
292 if (isTyping) {
293 const text = state.typed ?? props.draft
294
295 if (raw === 'up' || raw === 'down') {
296 set({ ...leave, tier: raw === 'up' ? ACTIONS : TICKETS, typed: null })
297 surface.post({ blur: true })
298 } else if (info.isNudge === true) {
299 // A letter button pressed while typing is answered by the hooks module.
300 } else if (raw === 'return') {
301 if (text.trim() !== '') {
302 surface.post({ ask: text.trim() })
303 set({ typed: '', up: -props.back })
304 }
305 } else if (raw === 'backspace' || raw === 'delete') {
306 const next = [...text].slice(0, -1).join('')
307 set({ typed: next })
308 surface.post({ draft: next })
309 } else if (raw === 'space' || (info.ctrl !== true && info.meta !== true && [...raw].length === 1)) {
310 const next = `${text}${raw === 'space' ? ' ' : raw}`
311 set({ typed: next })
312 surface.post({ draft: next })
313 }
314
315 return
316 }
317
318 // h j k l o are ← ↓ ↑ → Enter.
319 const key = VIM[raw] ?? raw
320 const page = Math.max(1, props.threadRows - 1)
321
322 if (key === 'pageup') {
323 scroll(page)
324 } else if (key === 'pagedown') {
325 scroll(-page)
326 } else if (key === 'i') {
327 toComposer()
328 } else if (key === 'up') {
329 // Up from the tickets wraps round to the composer.
330 if (tier === TICKETS) {
331 toComposer()
332 } else {
333 set({ tier: tier === COMPOSER ? ACTIONS : TICKETS })
334 }
335 } else if (key === 'down' || key === 'tab') {
336 if (tier === TICKETS) {
337 set({ tier: ACTIONS })
338 } else if (tier === ACTIONS) {
339 toComposer()
340 } else {
341 set({ tier: TICKETS })
342 }
343 } else if (tier === TICKETS && (key === 'left' || key === 'right')) {
344 run(key === 'left' ? 'prev' : 'next')
345 } else if (tier === ACTIONS && key === 'left') {
346 set({ pick: clamp(pick - 1, 0, props.actions.length - 1) })
347 } else if (tier === ACTIONS && key === 'right') {
348 set({ pick: clamp(pick + 1, 0, props.actions.length - 1) })
349 } else if (tier === ACTIONS && key === 'return') {
350 run(props.actions[pick]?.act)
351 } else if (key === 's') {
352 run('send')
353 } else if (/^[1-9]$/.test(key)) {
354 if (Number(key) <= props.options) {
355 run(`pick-${key}`)
356 }
357 } else {
358 run(props.actions.find(action => action.hot === key)?.act)
359 }
360 })
361
362 surface.onPointer(event => {
363 if (event.type !== 'down') {
364 return
365 }
366
367 if (event.y === actionRow) {
368 const hit = spans.find(span => event.x >= span.from && event.x <= span.to)
369
370 if (hit === undefined) {
371 set({ ...leave, tier: ACTIONS })
372 } else {
373 set({ ...leave, tier: ACTIONS, pick: props.actions.indexOf(hit.action) })
374 run(hit.action.act)
375
376 return
377 }
378 } else if (event.y < actionRow) {
379 set({ ...leave, tier: TICKETS })
380 } else if (isTyping) {
381 set({ ...leave, tier: ACTIONS })
382 }
383
384 // A click here took the keys from the composer.
385 if (isTyping) {
386 surface.post({ blur: true })
387 }
388 })
389
390 const mark = (level: number) => (tier === level ? '▸ ' : ' ')
391 const more = (above: number, below: number) =>
392 [above > 0 ? `↑${above}` : '', below > 0 ? `↓${below}` : ''].filter(Boolean).join(' ')
393 const topAt = clamp(props.top, 0, Math.max(0, props.ticket.length - props.ticketRows))
394 const end = props.talk.length - backAt
395 const shown = props.talk.slice(Math.max(0, end - props.threadRows), end)
396 // With options, what answers them leads the hint: the row is cut at the edge.
397 const picks = props.options > 0 ? `1-${props.options} answer · ` : ''
398 const hint =
399 tier === TICKETS
400 ? `${picks}←→ or h l switch ticket · ↓ or j actions · i composer`
401 : tier === ACTIONS
402 ? `${picks}←→ or h l choose · enter or o runs · ↑↓ or k j levels`
403 : props.isAnswering
404 ? 'answering the main agent · enter sends · ↑ leaves it'
405 : 'typing in the composer · enter sends · ↑ leaves it'
406
407 return (
408 <Box flexDirection="column">
409 <Box justifyContent="space-between">
410 <Text>
411 <Text color="cyan">{mark(TICKETS)}</Text>
412 <Text color={props.tone} bold>
413 {props.label}
414 </Text>
415 </Text>
416 <Text dimColor>
417 {tier === TICKETS ? '← ' : ''}
418 {props.position}
419 {tier === TICKETS ? ' →' : ''}
420 </Text>
421 </Box>
422 <Text bold wrap="truncate-end">
423 {' '}
424 {props.title}
425 </Text>
426 <Text dimColor wrap="truncate-end">
427 {' '}
428 {props.meta}
429 </Text>
430 <Text wrap="truncate-end">
431 <Text color="cyan">{mark(ACTIONS)}</Text>
432 {spans.map(({ action }, index) => (
433 <Text>
434 <Text inverse={tier === ACTIONS && index === pick}>[{action.label}]</Text>{' '}
435 </Text>
436 ))}
437 </Text>
438 <Box justifyContent="space-between">
439 <Text dimColor>── Ticket</Text>
440 <Text dimColor>{more(topAt, props.ticket.length - props.ticketRows - topAt)}</Text>
441 </Box>
442 {props.note !== '' && (
443 <Text wrap="truncate-end">
444 <Text color="warning">Your note: </Text>
445 {props.note}
446 </Text>
447 )}
448 {props.resolution !== '' && (
449 <Text wrap="truncate-end">
450 <Text color="success">Resolved: </Text>
451 {props.resolution}
452 </Text>
453 )}
454 <Box flexDirection="column" height={props.ticketRows} overflow="hidden">
455 {props.ticket.slice(topAt, topAt + props.ticketRows).map(parts =>
456 // A row of the user's own note: shaded under a ❯, as a prompt is.
457 parts[0]?.shade === true ? (
458 <Text backgroundColor="userMessageBackground">
459 <Text dimColor>{parts[0].text.slice(0, 2)}</Text>
460 {parts[0].text.slice(2)}
461 </Text>
462 ) : (
463 <Text wrap="truncate-end" bold={parts[0]?.text.startsWith('#') === true}>
464 {parts.length === 0
465 ? ' '
466 : parts.map(part =>
467 part.color === undefined ? <Text>{part.text}</Text> : <Text color={part.color}>{part.text}</Text>,
468 )}
469 </Text>
470 ),
471 )}
472 </Box>
473 <Text dimColor>{'─'.repeat(props.columns)}</Text>
474 <Box justifyContent="space-between">
475 <Text>
476 <Text bold>{props.heading}</Text>
477 {props.busy !== '' && (
478 <Text color="cyan">
479 {' '}
480 {[...SPIN][state.frame % 10]} {props.busy}
481 </Text>
482 )}
483 </Text>
484 <Text dimColor>{more(Math.max(0, end - props.threadRows), backAt)}</Text>
485 </Box>
486 <Box flexDirection="column" height={props.threadRows} overflow="hidden">
487 {props.talk.length === 0 && (
488 <Text dimColor wrap="wrap">
489 Ask a subagent to look into this. The main agent does not see this thread.
490 </Text>
491 )}
492 {shown.map(line =>
493 line.kind === 'you' ? (
494 <Text backgroundColor="userMessageBackground">
495 <Text dimColor>{line.text.slice(0, 2)}</Text>
496 {line.text.slice(2)}
497 </Text>
498 ) : (
499 <Text
500 wrap="truncate-end"
501 bold={line.text.startsWith('#')}
502 dimColor={line.kind === 'note'}
503 >
504 {line.text === '' ? ' ' : line.text}
505 </Text>
506 ),
507 )}
508 </Box>
509 <Text dimColor wrap="truncate-end">
510 {hint}
511 </Text>
512 </Box>
513 )
514}
515
516const Pane: ClientModule<Props, State> = (props, surface) => {
517 // A new instance has no state yet: a timer left by an earlier one died with
518 // it. The list shows no spinner, so it runs none either.
519 if (stopSpin !== undefined && (surface.state === undefined || props.view === 'list')) {
520 stopSpin()
521 stopSpin = undefined
522 }
523
524 return props.view === 'list' ? List(props, surface) : Detail(props, surface)
525}
526
527export default Pane
528hooks/kit/icons.ts 60 lines1// Shared by the lens, parked and ledger mods. The source is the mod-kit folder;
2// each mod carries a copy under hooks/kit, written there by sync.sh. Edit the
3// source and sync, never a copy.
4
5export type Icon = { glyph: string; color: string }
6
7// Nerd Font glyphs, as a terminal file tree draws them, in each language's
8// usual colour. They need a Nerd Font, or a terminal that ships the symbols.
9export const FILE_ICONS: readonly (readonly [pattern: RegExp, glyph: string, color: string])[] = [
10 [/\.pyi?$/, '\u{e73c}', '#ffd43b'],
11 [/\.[cm]?[tj]sx$/, '\u{e7ba}', '#20c2e3'],
12 [/\.[cm]?ts$/, '\u{e628}', '#519aba'],
13 [/\.[cm]?js$/, '\u{e74e}', '#cbcb41'],
14 [/\.json$/, '\u{e60b}', '#cbcb41'],
15 [/\.(tf|tfvars)$/, '\u{e69a}', '#7b42bc'],
16 [/\.(ya?ml|toml|ini|cfg|env)$/, '\u{e615}', '#6d8086'],
17 [/\.(md|mdx)$/, '\u{e73e}', '#dddddd'],
18 [/\.(sh|bash|zsh)$/, '\u{e795}', '#4d5a5e'],
19 [/\.(css|scss|less)$/, '\u{e749}', '#42a5f5'],
20 [/\.html?$/, '\u{e736}', '#e44d26'],
21 [/\.sql$/, '\u{e706}', '#dad8d8'],
22 [/\.(png|jpe?g|gif|svg|webp|ico)$/, '\u{f1c5}', '#a074c4'],
23 [/(^|\/)Dockerfile$/, '\u{f308}', '#458ee6'],
24 [/(^|\/)\.git(ignore|attributes)$/, '\u{e702}', '#f54d27'],
25 [/\.lock$/, '\u{f023}', '#bbbbbb'],
26]
27
28// The glyphs the mods draw for their own things, one name each.
29export const ICON = {
30 file: '\u{f15b}',
31 folder: '\u{f07b}',
32 folderOpen: '\u{f07c}',
33 needs: '\u{f059}',
34 blocking: '\u{f071}',
35 fyi: '\u{f05a}',
36 done: '\u{f058}',
37 failed: '\u{f057}',
38 running: '\u{f110}',
39 todo: '\u{f10c}',
40 thread: '\u{f075}',
41 options: '\u{f0cb}',
42 preferred: '\u{f005}',
43 tasks: '\u{f0ae}',
44 agents: '\u{f085}',
45 finding: '\u{f188}',
46} as const
47
48export const FILE_COLOR = '#6d8086'
49export const FOLDER_COLOR = '#dcb67a'
50export const STAR_COLOR = '#ffd43b'
51
52export const iconOf = (path: string): Icon => {
53 const hit = FILE_ICONS.find(([pattern]) => pattern.test(path))
54
55 return hit === undefined ? { glyph: ICON.file, color: FILE_COLOR } : { glyph: hit[1], color: hit[2] }
56}
57
58// The icon of a ref's file type; a ref is a path, or path:line.
59export const refIcon = (ref: string): Icon => iconOf(ref.replace(/:\d+(?::\d+)?$/, ''))
60types/index.d.ts 66 lines1export type ParkedKind = 'needs-you' | 'fyi'
2
3// 'note' is a line the mod itself adds to a thread, such as a briefing's record.
4export type ParkedMessage = { role: 'you' | 'agent' | 'note'; text: string; at: number }
5
6export type ParkedItem = {
7 id: number
8 kind: ParkedKind
9 title: string
10 body: string
11 note?: string
12 parkedBy: 'model' | 'user'
13 sessionId: string
14 createdAt: number
15 status: 'open' | 'done'
16 resolution?: string
17 resolvedBy?: 'model' | 'user'
18 resolvedAt?: number
19 // The choices a decision offers, and the one (counted from 1) the agent
20 // would pick: it proceeds on that one unless the item is blocking.
21 options?: string[]
22 preferred?: number
23 // Set when no remaining work can go on until the user answers.
24 isBlocking?: true
25 // The places the item is about, each a path or path:line.
26 refs?: string[]
27 // The ledger task the item belongs to, where the ledger mod has a run.
28 task?: string
29 // Set on an item parked from the ledger's run, not by the agent or the user:
30 // a task's failed gate, or the open findings of a unit's review.
31 origin?: { kind: 'gate'; task: string } | { kind: 'review'; unit: string }
32 // What the user answered, and whether the main agent is still to be told.
33 answer?: string
34 // The session the user answered in: its agent is the one to tell.
35 answeredIn?: string
36 isUnsent?: true
37 // The side conversation held in the pane; the main agent never reads it.
38 thread?: ParkedMessage[]
39 // The subagent answering the thread, while this session still has it.
40 agentId?: string
41}
42
43// A place parked asks to be shown, which the lens mod opens where it is loaded:
44// a path from the session's folder (or absolute), a line (0 for the file as a
45// whole), and a count of the asks, so the same place asked twice is two asks.
46export type ParkedJump = { path: string; line: number; n: number }
47
48declare module 'claude-code' {
49 interface PluginState {
50 parked: {
51 jump: ParkedJump | null
52 nudge: { key: string; n: number }
53 typing: boolean
54 typingAt: number
55 draft: string
56 answering: boolean
57 items: ParkedItem[]
58 selected: number
59 pending: number[]
60 back: number
61 top: number
62 lab: boolean
63 }
64 }
65}
66