Strip above the prompt: other sessions that need you or are done; this session's task bar, subagent models and files while it works, and a ◈ chip that counts…

A strip above the prompt that shows what needs you, across every Claude Code session on the machine.
assumptions)./sessions opens a view with four tabs: Sessions, Turns, Files and Context.Everything comes from events and git status. The only token cost is Claude's one-line summary of each turn.
The guard watches the size of the context.
auto mode, from the auto line (default 500K), a countdown can start (default 10 seconds) and then compact. It starts only right after a commit or push, with no job running and you not typing. Typing, the Keep going button, or sending a prompt cancels it.~/.claude/session-board/guard/. The Context tab sums up today's lines.A compaction costs what any compaction costs.
Set them in /config, or with claude plugin configure session-board. The mode and the countdown length you pick in the Context tab of /sessions win over these.
| Setting | Choices | Default |
|---|---|---|
| Context guard mode | advise (chip and button only) · auto (also counts down and compacts) · off | advise |
| Advise from (thousand tokens) | 200 · 250 · 300 · 400 | 300 |
| Auto from (thousand tokens) | 400 · 500 · 600 · 700 | 500 |
| Countdown seconds | 5 · 10 · 15 · 30 · 60 · 120 · 300 · 600 | 10 |
| Journal log | on · off | off |
Journal log is for the author's own journal workflow. It keeps a daily log of Claude's turns in ~/.claude/session-board/log for a journal skill to read, and adds a 📓 button to turn cards. Leave it off unless you have such a skill.
/plugin marketplace add richardmuucyi/rmahwj-mods
/plugin install session-board@rmahwj-mods
/reload-plugins
claude plugin test session-boardhooks/register.tsx 2823 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { AgentRow, BgRow, BoardEntry, BoardStatus, CardTask, FileKind, FileRow, TaskKind, ToolKind, TurnRecord } from '../types'
5import {
6 compactionNotes,
7 configureGuard,
8 contextPane,
9 guardCompactFailed,
10 guardCompacted,
11 guardCompacting,
12 guardMeasured,
13 guardPromptSent,
14 guardOpensWithChip,
15 guardRequestCounted,
16 guardRow,
17 guardSessionEnded,
18 guardTurnStarted,
19 guardTyped,
20 loadInstructions,
21 noteGuardCall,
22 pruneGuardLogs,
23 refreshDay,
24 refreshGuard,
25} from './guard-glue'
26import type { GuardIo } from './guard-glue'
27import { short, same, barCells, pad2, clockTime, FIRST_COL, LABEL_BG, LABEL_FG, LINK } from './shared'
28import type { Kit } from './shared'
29
30// Reads only what each session is already doing (prompts, tool calls, turn
31// ends). It adds nothing to any conversation, so it costs no tokens.
32
33const PANE = 'session-board'
34const board = atom({ plugin: 'session-board', key: 'board' } as const, [])
35const now = atom({ plugin: 'session-board', key: 'now' } as const, 0)
36const self = atom({ plugin: 'session-board', key: 'self' } as const, null)
37const alerts = atom({ plugin: 'session-board', key: 'alerts' } as const, { needs: [], replied: [] })
38const showEnded = atom({ plugin: 'session-board', key: 'showEnded' } as const, false)
39const liveAtom = atom({ plugin: 'session-board', key: 'live' } as const, {
40 isWorking: false,
41 progress: null,
42 showProgress: false,
43 agents: [],
44 turnFiles: 0,
45 bg: 0,
46 bgKinds: {},
47 bgRows: [],
48})
49const turnsAtom = atom({ plugin: 'session-board', key: 'turns' } as const, [])
50const journalAtom = atom({ plugin: 'session-board', key: 'journal' } as const, null)
51const expandedAtom = atom({ plugin: 'session-board', key: 'expanded' } as const, null)
52const reviewAtom = atom({ plugin: 'session-board', key: 'review' } as const, { open: 0, high: 0 })
53const recapAtom = atom({ plugin: 'session-board', key: 'recap' } as const, null)
54const filesView = atom({ plugin: 'session-board', key: 'files' } as const, {
55 view: 'board',
56 scope: 'turn',
57 list: [],
58 isLoading: false,
59 open: null,
60})
61const guardAtom = atom({ plugin: 'session-board', key: 'guard' } as const, {
62 chip: 'none',
63 saving: 0,
64 ctx: 0,
65 window: 1_000_000,
66 mode: 'advise',
67 phase: 'idle',
68 jobs: 0,
69 series: [],
70 history: [],
71 adviseAt: 300_000,
72 floor: 120_000,
73 hasFile: false,
74 pickOpen: false,
75 picks: { notes: true, lean: true, tasks: false, files: false, jobs: false, assumptions: false },
76 facts: { tasks: [], files: [], jobs: [], assumptions: 0, high: 0 },
77 command: '/compact',
78 boxHolds: false,
79})
80
81/** One colour per state, used everywhere: strip pills, panel edges, counts. */
82const COLOR: Record<BoardStatus, string> = {
83 'needs-you': 'yellow',
84 working: 'cyan',
85 replied: 'green',
86 background: 'magenta',
87 stopped: 'gray',
88 open: 'gray',
89 gone: 'gray',
90 closed: 'gray',
91}
92
93const CARD_VERSION = 4
94const REFRESH_MS = 5_000
95const HEARTBEAT_MS = 30_000
96// No heartbeat for this long: the session closed, crashed, or the computer slept.
97const GONE_MS = 2 * 60_000
98// Sessions that ended stay listed this long.
99const KEEP_ENDED_MS = 30 * 60_000
100// A card file untouched this long is past both limits above: not even read.
101const STALE_FILE_MS = KEEP_ENDED_MS + GONE_MS + HEARTBEAT_MS
102
103const ORDER: Record<BoardStatus, number> = {
104 'needs-you': 0,
105 working: 1,
106 replied: 2,
107 background: 3,
108 stopped: 4,
109 open: 5,
110 gone: 6,
111 closed: 7,
112}
113const ICON: Record<BoardStatus, string> = {
114 'needs-you': '?',
115 working: '◔',
116 replied: '✓',
117 background: '◌',
118 stopped: '‖',
119 open: '○',
120 gone: '×',
121 closed: '×',
122}
123function label(c: BoardEntry): string {
124 switch (c.status) {
125 case 'needs-you':
126 return 'Needs you'
127 case 'working':
128 return 'Working'
129 case 'replied':
130 return 'Done'
131 case 'background': {
132 const n = c.tasks?.length ?? 0
133 return n === 1 ? 'Waiting on a background task' : `Waiting on ${n} background tasks`
134 }
135 case 'stopped':
136 return 'Stopped'
137 case 'open':
138 return c.asked === '' ? 'Open · nothing asked yet' : 'Open · resumed'
139 case 'gone':
140 return 'Not responding'
141 case 'closed':
142 return 'Closed'
143 }
144}
145
146/** Cuts one line to `n` characters, keeping its spacing (for diff lines). */
147function clip(text: string, n: number): string {
148 return text.length > n ? `${text.slice(0, Math.max(1, n - 1))}…` : text
149}
150
151function ago(ms: number): string {
152 const s = Math.max(0, Math.round(ms / 1000))
153 if (s < 60) return `${s}s`
154 const m = Math.round(s / 60)
155 if (m < 60) return `${m}m`
156 return `${Math.round(m / 60)}h`
157}
158
159function baseName(path: string): string {
160 const parts = path.split(/[\\/]/).filter(p => p !== '')
161 return parts[parts.length - 1] ?? path
162}
163
164/** One spelling per folder: separators, a trailing separator and case folded. */
165const folderKey = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
166
167/** The folder's name, or `parent/name` where another listed folder shares the name. */
168function folderLabel(folder: string, project: string, all: readonly BoardEntry[]): string {
169 const key = folderKey(folder)
170 const clash = all.some(c => c.project.toLowerCase() === project.toLowerCase() && folderKey(c.folder) !== key)
171 if (!clash) return project
172 const parts = folder.split(/[\\/]/).filter(p => p !== '')
173 return parts.slice(-2).join('/')
174}
175
176const STATUSES = new Set<string>(Object.keys(ORDER))
177
178/** A card as another session wrote it, or undefined when it is not one this version reads. */
179function parseCard(text: string): BoardEntry | undefined {
180 const c = JSON.parse(text) as Partial<BoardEntry> | null
181 if (!c || typeof c !== 'object') return undefined
182 if (typeof c.id !== 'string' || typeof c.folder !== 'string' || typeof c.project !== 'string') return undefined
183 if (typeof c.status !== 'string' || !STATUSES.has(c.status)) return undefined
184 if (typeof c.changedAt !== 'number' || typeof c.seenAt !== 'number') return undefined
185 return {
186 ...c,
187 // A background task's report is never "Asked": drop one saved by an older
188 // version, so neither a reload nor another session's card brings it back.
189 asked: typeof c.asked === 'string' && !/^<task-notification[\s>]/.test(c.asked.trim()) ? c.asked : '',
190 tasks: Array.isArray(c.tasks) ? c.tasks.filter((x): x is string => typeof x === 'string') : undefined,
191 bg: Array.isArray(c.bg)
192 ? c.bg.filter(
193 (x): x is CardTask =>
194 !!x && typeof x.id === 'string' && typeof x.label === 'string' && typeof x.what === 'string' &&
195 typeof x.since === 'number' && typeof x.isExact === 'boolean' && (KIND_ORDER as string[]).includes(x.kind),
196 )
197 : undefined,
198 progress:
199 c.progress && typeof c.progress.done === 'number' && typeof c.progress.total === 'number' && c.progress.total > 0
200 ? { done: c.progress.done, total: c.progress.total }
201 : undefined,
202 } as BoardEntry
203}
204
205/** What a tool call is doing, in plain words, from its own arguments. */
206function describe(tool: string, args: Record<string, unknown>): string {
207 const s = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
208 const file = (k: string) => (s(k) ? baseName(s(k)) : 'a file')
209 switch (tool) {
210 case 'Read':
211 return `Reading ${file('file_path')}`
212 case 'Edit':
213 case 'MultiEdit':
214 return `Editing ${file('file_path')}`
215 case 'Write':
216 return `Writing ${file('file_path')}`
217 case 'NotebookEdit':
218 return `Editing ${file('notebook_path')}`
219 case 'Bash':
220 case 'PowerShell':
221 if (s('description')) return short(s('description'), 50)
222 return s('command') ? `Running ${short(s('command'), 40)}` : 'Running a command'
223 case 'Grep':
224 case 'Glob':
225 return 'Searching files'
226 case 'WebSearch':
227 return s('query') ? `Searching the web: ${short(s('query'), 40)}` : 'Searching the web'
228 case 'WebFetch':
229 return 'Reading a web page'
230 case 'Agent':
231 case 'Task':
232 return s('description') ? `Subagent: ${short(s('description'), 40)}` : 'Running a subagent'
233 case 'Skill':
234 return s('skill') ? `Using skill ${s('skill')}` : 'Using a skill'
235 case 'TodoWrite':
236 case 'TaskCreate':
237 case 'TaskUpdate':
238 return 'Updating its task list'
239 case 'AskUserQuestion': {
240 const qs = Array.isArray(args.questions) ? args.questions : []
241 const q = qs[0] as { question?: unknown } | undefined
242 return typeof q?.question === 'string' ? `Answer its question: ${short(q.question, 60)}` : 'Answer its question'
243 }
244 case 'ExitPlanMode':
245 return 'Review its plan'
246 case 'Monitor':
247 return s('description') ? `Monitor: ${short(s('description'), 40)}` : 'Starting a monitor'
248 default: {
249 const mcp = tool.match(/^mcp__(.+?)__(.+)$/)
250 if (mcp) return `Using ${mcp[1]}: ${mcp[2]}`
251 return `Using ${tool}`
252 }
253 }
254}
255
256/** The keys tool.call carries beside the tool's own arguments. */
257const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent', 'requestMeta'])
258function toolArgs(e: object): Record<string, unknown> {
259 const out: Record<string, unknown> = {}
260 for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) out[k] = v
261 return out
262}
263function asRecord(raw: unknown): Record<string, unknown> {
264 if (typeof raw === 'string') {
265 try {
266 const v = JSON.parse(raw) as unknown
267 return v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
268 } catch {
269 return {}
270 }
271 }
272 return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
273}
274
275/** What one background task summary (classic Stop's `background_tasks`) is doing, in a few words. */
276function taskWhat(t: { description: string; command?: string; agent_type?: string; name?: string }): string {
277 return short(t.description || t.name || t.command || t.agent_type || 'task', 50)
278}
279
280/** A background task's kind from its type (classic Stop's `background_tasks[].type`): a background shell is a job.
281 * A type this version does not know counts as a plain task, never as one of the others. */
282function kindOf(type: string): TaskKind {
283 if (type === 'shell' || type === 'bash' || type === 'local_bash') return 'job'
284 if (type === 'monitor') return 'monitor'
285 if (type === 'subagent' || type === 'agent') return 'agent'
286 return 'task'
287}
288
289/** A tool call in flight, and the dialog it waits on, if any. */
290type Call = { tool: string; agentId?: string; args: Record<string, unknown>; what: string; asking?: string; at: number }
291/** One piece of background work: its label ("shell: Render on the GPU"), what it does, and when it started. `isExact`
292 * is false where the start is only the first time a Stop listed it (it may have started earlier: "≥"). */
293type Tracked = { label: string; what: string; kind: TaskKind; agentId?: string; since: number; isExact: boolean }
294/** A Tracked entry from its label's two parts. */
295const task = (prefix: string, what: string, kind: TaskKind, since: number, isExact: boolean, agentId?: string): Tracked => ({
296 label: `${prefix}: ${what}`, what, kind, since, isExact, ...(agentId !== undefined ? { agentId } : {}),
297})
298
299// Module state: a hot reload starts it over, and session.start (which fires
300// again for a reloaded module) restores the card from its own file.
301let dir = ''
302let me: BoardEntry | undefined
303/** The session phase apart from any dialog: what the card shows once no dialog is open. */
304let phase: Exclude<BoardStatus, 'needs-you' | 'gone'> = 'open'
305let mainActivity: string | undefined
306let isTurnRunning = false
307/** The background work the last classic Stop reported, undefined where it reported none. */
308let stopTasks: Map<string, Tracked> | undefined
309/** When that Stop came: work this session's own calls started after it is not in its list yet. */
310let stopAt = 0
311let stopSeenThisTurn = false
312/** Background work seen at our own tool calls: all of it where Stop carries no `background_tasks`, else what started
313 * since the last Stop (see bgList). */
314const tracked = new Map<string, Tracked>()
315/** Agents whose ended status was seen at one refresh; dropped when seen again at the next. */
316const endedOnce = new Set<string>()
317const calls = new Map<string, Call>()
318let callSeq = 0
319/** After /clear or an in-process resume: the old id, until the new one is read. */
320let retiredId: string | undefined
321let isDirty = false
322let writing: Promise<void> = Promise.resolve()
323
324/** Rebuilds the card from the phase and the dialogs in flight; marks it dirty when it changed. */
325function derive(t: number): void {
326 if (!me) return
327 const asking = [...calls.values()].filter(c => c.asking !== undefined).sort((a, b) => a.at - b.at)[0]
328 let status: BoardStatus = phase
329 let activity: string | undefined
330 let tasks: string[] | undefined
331 if (asking) {
332 status = 'needs-you'
333 activity = asking.asking
334 } else if (phase === 'working') {
335 activity = mainActivity
336 } else if (phase === 'background') {
337 tasks = currentTasks()
338 activity = tasks.join(' · ')
339 } else if (phase === 'stopped') {
340 activity = mainActivity
341 }
342 const p = progressOf()
343 const progress = p ? { done: p.done, total: p.total } : undefined
344 // Every phase, not only "background": a reload mid-turn restores the jobs, with their real ids and start times, from it.
345 const list = [...bgList()].map(([id, x]) => ({ id, label: x.label, what: x.what, kind: x.kind, since: x.since, isExact: x.isExact }))
346 const bg = list.length > 0 ? list : undefined
347 if (status === me.status && activity === me.activity && same(tasks, me.tasks) && same(progress, me.progress) && same(bg, me.bg)) return
348 // A working card keeps the time its turn started; a new tool is no change of state.
349 const changedAt = status === me.status ? me.changedAt : t
350 me = { ...me, v: CARD_VERSION, status, activity, tasks, progress, bg, changedAt, seenAt: t }
351 isDirty = true
352}
353
354/** The background work in flight now: the last Stop's list, plus what this session's own calls started since that Stop
355 * (a job started mid-turn is not in any Stop list yet). Without a Stop list, everything its calls started. */
356function bgList(): Map<string, Tracked> {
357 if (!stopTasks) return tracked
358 const out = new Map(stopTasks)
359 for (const [id, x] of tracked) if (!out.has(id) && x.since >= stopAt) out.set(id, x)
360 return out
361}
362
363/** A task that finished (its report came, or its agent stopped): off every list at once. */
364function dropTask(id: string): void {
365 tracked.delete(id)
366 stopTasks?.delete(id)
367 // Nothing left to wait on and no turn running: your turn.
368 if (phase === 'background' && bgList().size === 0) phase = 'replied'
369}
370
371function currentTasks(): string[] {
372 return [...bgList().values()].map(x => x.label)
373}
374
375/** How many of the current background tasks are of each kind, for the ◈ chip. */
376function currentKinds(): Partial<Record<TaskKind, number>> {
377 const n: Partial<Record<TaskKind, number>> = {}
378 for (const x of bgList().values()) n[x.kind] = (n[x.kind] ?? 0) + 1
379 return n
380}
381
382/** How long something has run: "45s", "12m", "1h 05m". */
383function elapsed(ms: number): string {
384 const s = Math.max(0, Math.floor(ms / 1000))
385 if (s < 60) return `${s}s`
386 const m = Math.floor(s / 60)
387 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${pad2(m % 60)}m`
388}
389
390/** A task's running time, "≥" first where it may have started before the board first saw it. */
391const ranFor = (x: { since: number; isExact: boolean }, t: number) => `${x.isExact ? '' : '≥'}${elapsed(t - x.since)}`
392
393const KIND_ORDER: TaskKind[] = ['job', 'agent', 'monitor', 'wake-up', 'task']
394/** Each kind's word, its plural and its initial for a narrow pane. */
395const KIND_WORD: Record<TaskKind, [string, string, string]> = {
396 job: ['job', 'jobs', 'j'], agent: ['agent', 'agents', 'a'], monitor: ['monitor', 'monitors', 'm'],
397 'wake-up': ['wake-up', 'wake-ups', 'w'], task: ['task', 'tasks', 't'],
398}
399
400/** The ◈ chip's text: each kind counted apart ("◈ 1 job · 1 monitor"), initials when narrow ("◈1j 1m"). Running
401 * background agents the last Stop did not list count as agents. With no kinds at all (state from before this
402 * version), the plain total, as before. On a wide pane the oldest task's running time sits in brackets beside its
403 * kind once it reaches a minute ("◈ 2 jobs (12m) · 1 monitor"); a narrow pane has no room, and there "1m" already
404 * means one monitor. */
405function bgChipText(kinds: Partial<Record<TaskKind, number>> | undefined, total: number, isNarrow: boolean, oldest?: { kind: TaskKind; time: string }): string {
406 const k: Partial<Record<TaskKind, number>> = { ...(kinds ?? {}) }
407 const known = KIND_ORDER.reduce((sum, x) => sum + (k[x] ?? 0), 0)
408 if (known === 0) return isNarrow ? `◈${total}` : `◈ ${total}`
409 if (total > known) k.agent = (k.agent ?? 0) + total - known
410 const parts = KIND_ORDER.filter(x => (k[x] ?? 0) > 0).map(x => {
411 const n = k[x] ?? 0
412 if (isNarrow) return `${n}${KIND_WORD[x][2]}`
413 return `${n} ${n === 1 ? KIND_WORD[x][0] : KIND_WORD[x][1]}${oldest?.kind === x ? ` (${oldest.time})` : ''}`
414 })
415 return isNarrow ? `◈${parts.join(' ')}` : `◈ ${parts.join(' · ')}`
416}
417
418/** The oldest task's kind and running time, for the chip: only once it reaches a minute. A wake-up waits, it does not
419 * run, so it is never the one timed. */
420function oldestRun(rows: readonly BgRow[], t: number): { kind: TaskKind; time: string } | undefined {
421 const first = rows.filter(r => r.kind !== 'wake-up').sort((a, b) => a.since - b.since)[0]
422 return first && t - first.since >= 60_000 ? { kind: first.kind, time: ranFor(first, t) } : undefined
423}
424
425/** Writes the latest card, one write at a time, in order. Never rejects. */
426function flush($: EngineInterface): Promise<void> {
427 if (!me || retiredId !== undefined || dir === '') return writing
428 isDirty = false
429 const path = `${dir}/${me.id}.json`
430 const text = JSON.stringify(me)
431 // This session's own state for the strip: redrawn at once, not on the 5 s refresh.
432 const mine = { status: me.status as string, text: me.status === 'replied' ? 'Done' : label(me) }
433 void update($, self, v => (v && v.status === mine.status && v.text === mine.text ? v : mine)).catch(() => undefined)
434 writing = writing.then(() => $.fs.write(path, text)).catch(() => undefined)
435 return writing
436}
437
438/** The turn ended: the phase follows from how it ended and what is still in flight. */
439function settleTurnEnd(): void {
440 phase = currentTasks().length > 0 ? 'background' : 'replied'
441}
442
443/** Adopt the session's id when it changed (/clear, /resume): retire the old card, start a fresh one. */
444async function adoptId($: EngineInterface, t: number): Promise<void> {
445 if (!me) return
446 const id = await $.session.id()
447 if (id === me.id && retiredId === undefined) return
448 if (id === retiredId) return // the new id is not there yet
449 if (retiredId === undefined && me.status !== 'closed') {
450 const old = { ...me, status: 'closed' as const, activity: undefined, tasks: undefined, bg: undefined, changedAt: t, seenAt: t }
451 writing = writing.then(() => $.fs.write(`${dir}/${old.id}.json`, JSON.stringify(old))).catch(() => undefined)
452 }
453 retiredId = undefined
454 phase = 'open'
455 mainActivity = undefined
456 stopTasks = undefined
457 stopAt = 0
458 tracked.clear()
459 calls.clear()
460 restoredProgress = undefined
461 resetLive($)
462 sessionStartedAt = t
463 pushLive($)
464 me = { ...me, v: CARD_VERSION, id, asked: '', status: 'open', activity: undefined, tasks: undefined, progress: undefined, bg: undefined, changedAt: t, seenAt: t }
465 isDirty = true
466}
467
468/** Drops tracked/Stop-reported subagents that $.agent.list() shows ended at two refreshes running. */
469async function pruneEndedAgents($: EngineInterface): Promise<void> {
470 const list = stopTasks ?? tracked
471 if (phase !== 'background' || ![...list.values()].some(x => x.agentId !== undefined)) return
472 const agents = await $.agent.list()
473 const ended = new Set(agents.filter(a => a.status === 'completed' || a.status === 'failed' || a.status === 'killed').map(a => a.id))
474 for (const [key, x] of list) {
475 if (x.agentId === undefined || !ended.has(x.agentId)) {
476 endedOnce.delete(key)
477 continue
478 }
479 if (endedOnce.has(key)) {
480 list.delete(key)
481 endedOnce.delete(key)
482 } else endedOnce.add(key)
483 }
484 // Everything it waited on has ended and no turn followed: your turn.
485 if (bgList().size === 0) phase = 'replied'
486}
487
488// ─── This session's live detail: model, task list, subagents, files changed ───
489// All of it from events the session raises anyway (its steps, tool calls and
490// their results) and from git status run on this computer: no tokens.
491
492let sessionRoot = ''
493/** The main loop's model, as its latest step named it. */
494let ownModel: string | undefined
495
496type Todo = { id: string; subject: string; status: 'pending' | 'in_progress' | 'completed'; active?: string }
497/** Claude's own task list (TodoWrite, or TaskCreate/TaskUpdate/TaskList), main loop only. */
498const todos = new Map<string, Todo>()
499let todoSeq = 0
500/** The task list changed in the turn running (or the last one). */
501let todoTouched = false
502/** After a reload: the card's last counts, until the list is seen again. */
503let restoredProgress: { done: number; total: number } | undefined
504
505const agents = new Map<string, AgentRow>()
506
507/** A changed file with Claude's own diff lines, where Claude made the change. */
508type Change = FileRow & { hunks: string[] }
509const MAX_HUNK_LINES = 400
510const DIFF_SHOWN = 120
511let turnChanges = new Map<string, Change>()
512let sessionChanges = new Map<string, Change>()
513/** Absolute path by key, for asking git about a file. */
514const absByKey = new Map<string, string>()
515
516type Sig = { code: string; stat: string; from?: string }
517type Snap = { top: string; head: string; sigs: Map<string, Sig> }
518/** The repository's top folder; null where the session's folder is not in one (or git is missing). */
519let gitTop: string | null | undefined
520let sessionBase: Promise<Snap | undefined> | undefined
521let turnBase: Promise<Snap | undefined> | undefined
522let gitTurn: Change[] = []
523let gitSession: Change[] = []
524let gitCheckedAt: number | undefined
525let refreshing: Promise<void> | undefined
526
527const TODO_STATUS = new Set(['pending', 'in_progress', 'completed'])
528const str = (v: unknown) => (typeof v === 'string' ? v : '')
529
530/** "claude-opus-4-1-20250805" → "opus-4-1". */
531function shortModel(m: string | undefined): string | undefined {
532 if (!m) return undefined
533 return m
534 .replace(/^claude-/, '')
535 .replace(/-\d{8}$/, '')
536 .replace(/\[1m\]$/i, ' 1M')
537}
538
539function progressOf(): { done: number; total: number; now?: string } | null {
540 if (todos.size === 0) return restoredProgress ? { ...restoredProgress } : null
541 const all = [...todos.values()]
542 const doing = all.find(t => t.status === 'in_progress')
543 return {
544 done: all.filter(t => t.status === 'completed').length,
545 total: all.length,
546 now: doing ? doing.active || doing.subject : undefined,
547 }
548}
549
550/** The task list, from the main loop's own task tools and their results. True when it changed. */
551function noteTask(tool: string, args: Record<string, unknown>, rec: Record<string, unknown>): boolean {
552 const status = (v: unknown): Todo['status'] => (typeof v === 'string' && TODO_STATUS.has(v) ? (v as Todo['status']) : 'pending')
553 switch (tool) {
554 case 'TodoWrite': {
555 if (!Array.isArray(args.todos)) return false
556 todos.clear()
557 args.todos.forEach((raw, i) => {
558 const t = asRecord(raw)
559 if (str(t.content) !== '') todos.set(`todo-${i}`, { id: `todo-${i}`, subject: str(t.content), status: status(t.status), active: str(t.activeForm) || undefined })
560 })
561 break
562 }
563 case 'TaskCreate': {
564 const task = asRecord(rec.task)
565 const id = str(task.id) || `new-${++todoSeq}`
566 todos.set(id, { id, subject: str(args.subject) || str(task.subject), status: 'pending', active: str(args.activeForm) || undefined })
567 break
568 }
569 case 'TaskUpdate': {
570 const id = str(args.taskId)
571 if (id === '') return false
572 if (args.status === 'deleted') {
573 todos.delete(id)
574 break
575 }
576 const was = todos.get(id) ?? { id, subject: `Task ${id}`, status: 'pending' as const }
577 todos.set(id, {
578 ...was,
579 subject: str(args.subject) || was.subject,
580 active: str(args.activeForm) || was.active,
581 status: typeof args.status === 'string' ? status(args.status) : was.status,
582 })
583 break
584 }
585 case 'TaskList': {
586 if (!Array.isArray(rec.tasks)) return false
587 todos.clear()
588 for (const raw of rec.tasks) {
589 const t = asRecord(raw)
590 if (str(t.id) !== '') todos.set(str(t.id), { id: str(t.id), subject: str(t.subject), status: status(t.status) })
591 }
592 break
593 }
594 default:
595 return false
596 }
597 restoredProgress = undefined
598 todoTouched = true
599 return true
600}
601
602/** A path as the files view shows it: relative to the session's folder where inside it. */
603function relPath(abs: string): string {
604 const p = abs.replace(/\\/g, '/')
605 const r = sessionRoot.replace(/\\/g, '/').replace(/\/+$/, '')
606 if (r !== '' && p.toLowerCase().startsWith(`${r.toLowerCase()}/`)) return p.slice(r.length + 1)
607 return p.split('/').filter(x => x !== '').slice(-2).join('/')
608}
609
610function patchLines(raw: unknown): string[] {
611 const out: string[] = []
612 if (!Array.isArray(raw)) return out
613 for (const x of raw) {
614 const h = asRecord(x)
615 const n = (k: string) => (typeof h[k] === 'number' ? (h[k] as number) : 0)
616 out.push(`@@ -${n('oldStart')},${n('oldLines')} +${n('newStart')},${n('newLines')} @@`)
617 if (Array.isArray(h.lines)) for (const l of h.lines) if (typeof l === 'string') out.push(l)
618 }
619 return out
620}
621
622function merge(into: Map<string, Change>, c: Change): void {
623 const was = into.get(c.key)
624 if (!was) {
625 into.set(c.key, { ...c, hunks: c.hunks.slice(0, MAX_HUNK_LINES) })
626 return
627 }
628 const sum = (a?: number, b?: number) => (a === undefined || b === undefined ? undefined : a + b)
629 into.set(c.key, {
630 ...was,
631 kind: was.kind === 'added' && c.kind !== 'deleted' ? 'added' : c.kind,
632 plus: sum(was.plus, c.plus),
633 minus: sum(was.minus, c.minus),
634 hunks: [...was.hunks, ...c.hunks].slice(0, MAX_HUNK_LINES),
635 })
636}
637
638const FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
639
640/** A file Claude (or a subagent) wrote or edited, from the tool's own result. */
641function noteFile(tool: string, args: Record<string, unknown>, rec: Record<string, unknown>): boolean {
642 const path = str(rec.filePath) || str(args.file_path) || str(args.notebook_path)
643 if (path === '' || rec.staged === true) return false
644 let kind: FileKind = 'modified'
645 let hunks = patchLines(rec.structuredPatch)
646 if (tool === 'Write' && rec.type === 'create') {
647 kind = 'added'
648 if (hunks.length === 0 && typeof rec.content === 'string') {
649 const body = rec.content.replace(/\n$/, '')
650 hunks = body === '' ? [] : body.split('\n').map(l => `+${l}`)
651 }
652 }
653 const body = hunks.filter(l => !l.startsWith('@@'))
654 const isKnown = tool !== 'NotebookEdit' && (hunks.length > 0 || kind === 'added')
655 const key = folderKey(path)
656 absByKey.set(key, path)
657 const c: Change = {
658 key,
659 path: relPath(path),
660 kind,
661 plus: isKnown ? body.filter(l => l.startsWith('+')).length : undefined,
662 minus: isKnown ? body.filter(l => l.startsWith('-')).length : undefined,
663 by: 'claude',
664 hunks,
665 }
666 merge(turnChanges, c)
667 merge(sessionChanges, c)
668 return true
669}
670
671/** Runs git in the session's folder; its output, or undefined when it failed. */
672async function git($: EngineInterface, args: string[], cwd?: string): Promise<string | undefined> {
673 const r = await $.process
674 .run(['git', '-c', 'core.quotepath=off', ...args], { cwd: cwd ?? sessionRoot, timeoutMs: 15_000 })
675 .catch(() => undefined)
676 return r && (r.exitCode === 0 || r.exitCode === 1) ? r.stdout : undefined
677}
678
679/** What git status says now: each changed path's status code and line counts. */
680async function snap($: EngineInterface): Promise<Snap | undefined> {
681 if (gitTop === null || sessionRoot === '') return undefined
682 if (gitTop === undefined) {
683 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: sessionRoot, timeoutMs: 15_000 }).catch(() => undefined)
684 gitTop = r && r.exitCode === 0 && r.stdout.trim() !== '' ? r.stdout.trim() : null
685 if (gitTop === null) return undefined
686 }
687 const top = gitTop
688 const [st, head] = await Promise.all([
689 git($, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], top),
690 git($, ['rev-parse', '--verify', '-q', 'HEAD'], top),
691 ])
692 if (st === undefined) return undefined
693 const h = (head ?? '').trim()
694 const ns = h !== '' ? await git($, ['diff', 'HEAD', '--numstat', '-z', '--no-renames'], top) : ''
695 const stats = new Map<string, string>()
696 for (const item of (ns ?? '').split('\0')) {
697 const m = item.match(/^(\S+)\t(\S+)\t([\s\S]+)$/)
698 if (m) stats.set(m[3]!, `${m[1]}\t${m[2]}`)
699 }
700 const sigs = new Map<string, Sig>()
701 const parts = st.split('\0')
702 for (let i = 0; i < parts.length; i++) {
703 const t = parts[i]!
704 if (t.length < 4) continue
705 const code = t.slice(0, 2)
706 const rel = t.slice(3)
707 const from = /[RC]/.test(code) ? parts[++i] : undefined
708 sigs.set(rel, { code, stat: stats.get(rel) ?? '', from })
709 }
710 return { top, head: h, sigs }
711}
712
713/** The paths whose git status changed between two snapshots. */
714function compare(base: Snap, cur: Snap): Change[] {
715 const out: Change[] = []
716 const hasCommitted = base.head !== cur.head
717 for (const rel of new Set([...base.sigs.keys(), ...cur.sigs.keys()])) {
718 const a = base.sigs.get(rel)
719 const b = cur.sigs.get(rel)
720 if (a && b && a.code === b.code && a.stat === b.stat) continue
721 // Clean again: committed (not a change of the files) or changed back.
722 if (!b && hasCommitted) continue
723 const code = b?.code ?? ' '
724 const kind: FileKind = !b
725 ? 'modified'
726 : code === '??' || code[0] === 'A'
727 ? 'added'
728 : code.includes('D')
729 ? 'deleted'
730 : code.includes('R')
731 ? 'renamed'
732 : 'modified'
733 // Line counts only where the file was clean before: then git's count is this change's.
734 const counts = !a && b?.stat ? b.stat.split('\t').map(Number) : []
735 const abs = `${cur.top}/${rel}`
736 const key = folderKey(abs)
737 absByKey.set(key, abs)
738 out.push({
739 key,
740 path: relPath(abs),
741 kind,
742 plus: Number.isFinite(counts[0]) ? counts[0] : undefined,
743 minus: Number.isFinite(counts[1]) ? counts[1] : undefined,
744 from: b?.from ? relPath(`${cur.top}/${b.from}`) : undefined,
745 by: 'git',
746 hunks: [],
747 })
748 }
749 return out
750}
751
752const KIND_RANK: Record<FileKind, number> = { added: 0, modified: 1, renamed: 2, deleted: 3 }
753
754/** Claude's own edits, then what only git saw (shell edits, deletes, renames). */
755function viewList(scope: 'turn' | 'session'): FileRow[] {
756 const mine = scope === 'turn' ? turnChanges : sessionChanges
757 const seen = scope === 'turn' ? gitTurn : gitSession
758 const out = new Map<string, FileRow>()
759 const row = ({ hunks: _h, ...r }: Change): FileRow => r
760 for (const c of mine.values()) out.set(c.key, row(c))
761 for (const g of seen) {
762 const c = out.get(g.key)
763 if (!c) out.set(g.key, row(g))
764 else if (g.kind === 'deleted' || g.kind === 'renamed') out.set(g.key, { ...c, kind: g.kind, from: g.from })
765 }
766 return [...out.values()].sort((a, b) => KIND_RANK[a.kind] - KIND_RANK[b.kind] || a.path.localeCompare(b.path))
767}
768
769const gitState = () => (gitTop === null ? null : gitTop === undefined ? undefined : true)
770
771async function publishFiles($: EngineInterface): Promise<void> {
772 await update($, filesView, v => {
773 const list = viewList(v.scope)
774 const isGit = gitState()
775 if (same(list, v.list) && v.isGit === isGit && v.checkedAt === gitCheckedAt) return v
776 return { ...v, list, isGit, checkedAt: gitCheckedAt }
777 })
778}
779
780/** This session's live detail for the strip and the panel; redrawn only when it changed. */
781function pushLive($: EngineInterface): void {
782 const p = progressOf()
783 const order = { running: 0, failed: 1, stopped: 2, done: 3 }
784 const bg = bgList()
785 const fresh = {
786 isWorking: isTurnRunning,
787 model: ownModel,
788 progress: p,
789 showProgress: p !== null && (p.done < p.total || todoTouched),
790 agents: [...agents.values()].sort((a, b) => order[a.status] - order[b.status] || a.startedAt - b.startedAt),
791 turnFiles: viewList('turn').length,
792 // The strip's count: the background work in flight, while a turn runs too (the "Waiting on …" label names the same).
793 bg: bg.size,
794 // The same, counted by kind for the ◈ chip (jobs, agents, monitors, wake-ups).
795 bgKinds: currentKinds(),
796 // One row each, oldest first, for the panel's BACKGROUND section and the chip's time.
797 bgRows: [...bg].map(([id, x]) => ({ id, kind: x.kind, what: x.what, since: x.since, isExact: x.isExact })).sort((a, b) => a.since - b.since),
798 }
799 void update($, liveAtom, v => (same(v, fresh) ? v : fresh)).catch(() => undefined)
800 publishTurns($)
801}
802
803/** git status now against the snapshot at the session's start, and at the running turn's start. */
804function refreshGit($: EngineInterface): Promise<void> {
805 if (refreshing) return refreshing
806 refreshing = (async () => {
807 await update($, filesView, v => ({ ...v, isLoading: true }))
808 const cur = await snap($).catch(() => undefined)
809 if (cur) {
810 lastSnap = cur
811 const sb = await sessionBase
812 if (sb) gitSession = compare(sb, cur)
813 // A turn that ended keeps what it changed: later edits (yours, another session's) are not its.
814 const tb = isTurnRunning ? await turnBase : undefined
815 if (tb) gitTurn = compare(tb, cur)
816 gitCheckedAt = await $.clock.now()
817 }
818 await update($, filesView, v => ({ ...v, isLoading: false }))
819 await publishFiles($)
820 pushLive($)
821 })()
822 .catch(() => undefined)
823 .finally(() => {
824 refreshing = undefined
825 })
826 return refreshing
827}
828
829/** The turn ended: what git says changed since it started, kept as that turn's. */
830function settleTurnFiles($: EngineInterface): void {
831 const tb = turnBase
832 if (!tb) return
833 void (async () => {
834 const base = await tb
835 if (!base) return
836 const cur = await snap($)
837 if (!cur || turnBase !== tb) return
838 gitTurn = compare(base, cur)
839 if (sessionBase) {
840 const sb = await sessionBase
841 if (sb) gitSession = compare(sb, cur)
842 }
843 gitCheckedAt = await $.clock.now()
844 await publishFiles($)
845 pushLive($)
846 })().catch(() => undefined)
847}
848
849/** Opens the panel on the files view. */
850async function openFiles($: EngineInterface): Promise<void> {
851 // Open first, inside the press (see the recap's turn button): an open after an await is "unasked".
852 const opening = $.ui.open({ id: PANE, title: 'Sessions', closeOnEscape: true })
853 await update($, filesView, v => ({ ...v, view: 'files' as const, open: null }))
854 await opening
855 await publishFiles($)
856 await refreshGit($)
857}
858
859/** Shows (or hides) one file's diff: Claude's own edit lines, or git's diff against the last commit. */
860async function toggleDiff($: EngineInterface, key: string): Promise<void> {
861 const cur = await read($, filesView)
862 if (cur.open?.key === key) {
863 await update($, filesView, v => ({ ...v, open: null }))
864 return
865 }
866 const row = cur.list.find(r => r.key === key)
867 const mine = (cur.scope === 'turn' ? turnChanges : sessionChanges).get(key)
868 let lines: string[] = []
869 let note: string | undefined
870 if (mine && mine.hunks.length > 0) {
871 lines = mine.hunks
872 note = "Claude's edits"
873 } else if (row?.kind === 'deleted' && !gitTop) {
874 note = 'Deleted'
875 } else if (gitTop) {
876 const abs = (absByKey.get(key) ?? '').replace(/\\/g, '/')
877 const top = gitTop.replace(/\\/g, '/')
878 const rel = abs.toLowerCase().startsWith(`${top.toLowerCase()}/`) ? abs.slice(top.length + 1) : ''
879 if (rel !== '') {
880 let out = await git($, ['diff', 'HEAD', '--', rel], gitTop)
881 if (!out || out.trim() === '') out = await git($, ['diff', '--no-index', '--', '/dev/null', rel], gitTop)
882 const all = (out ?? '').split('\n')
883 const first = all.findIndex(l => l.startsWith('@@'))
884 lines = first >= 0 ? all.slice(first).filter((l, i, xs) => !(i === xs.length - 1 && l === '')) : []
885 note = lines.length > 0 ? 'git diff against the last commit' : row?.kind === 'deleted' ? 'Deleted' : 'No text diff (binary, or back as committed)'
886 }
887 } else {
888 note = 'No diff recorded'
889 }
890 await update($, filesView, v => ({
891 ...v,
892 open: { key, lines: lines.slice(0, DIFF_SHOWN), more: Math.max(0, lines.length - DIFF_SHOWN), note },
893 }))
894}
895
896/** Starts the session's file and task tracking over (a new session, /clear, a reload). */
897function resetLive($: EngineInterface): void {
898 todos.clear()
899 todoTouched = false
900 agents.clear()
901 turnChanges = new Map()
902 sessionChanges = new Map()
903 gitTurn = []
904 gitSession = []
905 turnBase = undefined
906 gitTop = undefined
907 gitCheckedAt = undefined
908 lastSnap = undefined
909 sessionBase = snap($).catch(() => undefined)
910 turnRecs = []
911 void update($, turnsAtom, () => []).catch(() => undefined)
912 void update($, recapAtom, () => null).catch(() => undefined)
913}
914
915/** Agents the session lists: their status where it ended, and any this module missed (a reload). */
916async function syncAgents($: EngineInterface): Promise<boolean> {
917 const hasRunning = [...agents.values()].some(a => a.status === 'running')
918 if (!hasRunning && !isTurnRunning) return false
919 const list = await $.agent.list()
920 const t = await $.clock.now()
921 let changed = false
922 for (const info of list) {
923 const was = agents.get(info.id)
924 const ended = info.status === 'completed' ? 'done' : info.status === 'failed' ? 'failed' : info.status === 'killed' ? 'stopped' : undefined
925 if (!was) {
926 if (ended) continue
927 agents.set(info.id, { id: info.id, type: info.type || 'agent', description: info.description, status: 'running', startedAt: t })
928 changed = true
929 } else if (ended && was.status === 'running') {
930 agents.set(info.id, { ...was, status: ended, endedAt: t, activity: undefined })
931 changed = true
932 }
933 }
934 return changed
935}
936
937// ─── Turns: one record per turn of this session ───
938// Facts from the session's own events, plus Claude's one-line "Turn: …"
939// summary, which the guidance below asks for (the only part that costs tokens).
940
941const TURN_GUIDANCE = `## Turn summary
942When you used tools in a turn, end your final reply with one line in exactly this form:
943Turn: <what you did, under 15 words>
944If there is one thing the user should verify, add it to that line: "Turn: <what you did> · check: <what to verify, under 10 words>". Leave the line out of replies that used no tools.`
945
946const MAX_TURNS = 20
947let sessionStartedAt = 0
948/** Newest first; the first is the running turn, or the last one. */
949let turnRecs: TurnRecord[] = []
950
951function toolKind(tool: string, args: Record<string, unknown>): ToolKind | undefined {
952 if (tool === 'Agent' || tool === 'Task' || /log_assumption$/.test(tool)) return undefined
953 if (['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'NotebookRead'].includes(tool)) return 'read'
954 if (FILE_TOOLS.has(tool)) return 'edit'
955 if (tool === 'Bash' || tool === 'PowerShell') {
956 const cmd = `${str(args.command)} ${str(args.description)}`
957 return /\b(test|tests|pytest|jest|vitest|mocha|rspec|go test|cargo test|npm t)\b/i.test(cmd) ? 'test' : 'shell'
958 }
959 return 'other'
960}
961
962/** Claude's "Turn: … · check: …" line, the last one in its answer. */
963function parseSummary(answer: string): { summary?: string; check?: string } {
964 const lines = answer.split('\n').map(l => l.trim()).filter(l => /^[*_]*turn[*_]*\s*:/i.test(l))
965 const line = lines[lines.length - 1]
966 if (!line) return {}
967 const body = line.replace(/^[*_]*turn[*_]*\s*:[*_]*\s*/i, '').replace(/[*_]+$/, '').trim()
968 const [what, check] = body.split(/\s*[·|•]\s*check\s*:\s*/i)
969 return { summary: what?.trim() || undefined, check: check?.replace(/[*_.]+$/, '').trim() || undefined }
970}
971
972/** The running (or last) turn, refreshed from what the session tracks now. */
973function refreshTurn(): void {
974 const cur = turnRecs[0]
975 if (!cur) return
976 const list = viewList('turn')
977 const n = (k: FileKind) => list.filter(r => r.kind === k).length
978 turnRecs[0] = {
979 ...cur,
980 files: { added: n('added'), modified: n('modified'), deleted: n('deleted'), renamed: n('renamed'), top: list.slice(0, 4) },
981 agents: [...agents.values()].map(a => ({ id: a.id, type: a.type, model: a.model, status: a.status, startedAt: a.startedAt, endedAt: a.endedAt })),
982 }
983}
984
985function publishTurns($: EngineInterface): void {
986 refreshTurn()
987 const fresh = turnRecs.slice()
988 void update($, turnsAtom, v => (same(v, fresh) ? v : fresh)).catch(() => undefined)
989}
990
991function noteTurnTool(tool: string, args: Record<string, unknown>): void {
992 const cur = turnRecs[0]
993 if (!cur || cur.status !== 'running') return
994 if (/log_assumption$/.test(tool) && str(args.assumption) !== '') {
995 const impact = args.impact === 'high' || args.impact === 'low' ? args.impact : undefined
996 turnRecs[0] = { ...cur, assumptions: [...cur.assumptions, { text: str(args.assumption), impact }] }
997 return
998 }
999 const kind = toolKind(tool, args)
1000 if (!kind) return
1001 turnRecs[0] = { ...cur, tools: { ...cur.tools, [kind]: (cur.tools[kind] ?? 0) + 1 }, toolTotal: cur.toolTotal + 1 }
1002}
1003
1004/** The `journal` setting: off by default, for anyone without a journal skill to read the log. */
1005let isJournal = false
1006const LOG_KEEP_DAYS = 30
1007
1008/**
1009 * Removes day folders of the journal log older than LOG_KEEP_DAYS. Only
1010 * folders directly under `<board>/log/` named as a date are ever touched.
1011 * The file API cannot delete, so the host's own remove command does.
1012 */
1013async function pruneLogs($: EngineInterface): Promise<void> {
1014 if (!isJournal || dir === '') return
1015 const root = `${dir}/log`
1016 const entries = await $.fs.list(root).catch(() => [])
1017 const cutoff = (await $.clock.now()) - LOG_KEEP_DAYS * 86_400_000
1018 const isWindows = /^[A-Za-z]:/.test(dir)
1019 for (const f of entries) {
1020 if (f.kind !== 'dir' || !/^\d{4}-\d{2}-\d{2}$/.test(f.name)) continue
1021 const at = Date.parse(`${f.name}T00:00:00`)
1022 if (!Number.isFinite(at) || at >= cutoff) continue
1023 const path = `${root}/${f.name}`
1024 const argv = isWindows ? ['cmd', '/c', 'rd', '/s', '/q', path.replace(/\//g, '\\')] : ['rm', '-rf', '--', path]
1025 await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => undefined)
1026 }
1027}
1028
1029/**
1030 * The daily journal log: one line per turn that used tools, for the journal's
1031 * wrap-up to read. Outside the vault; no prompt text and no file names. One
1032 * file per session per day, so sessions never write the same file.
1033 */
1034function logTurn($: EngineInterface, r: TurnRecord): void {
1035 if (!isJournal || !me || dir === '' || r.toolTotal === 0) return
1036 const d = new Date(r.startedAt)
1037 const day = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
1038 const path = `${dir}/log/${day}/${me.id}.jsonl`
1039 const f = r.files
1040 const line = JSON.stringify({
1041 at: clockTime(r.startedAt),
1042 project: me.project,
1043 did: r.summary ?? null,
1044 check: r.check,
1045 mins: Math.max(1, Math.round(((r.endedAt ?? r.startedAt) - r.startedAt) / 60_000)),
1046 files: f.added + f.modified + f.deleted + f.renamed,
1047 assumed: r.assumptions.length,
1048 status: r.status,
1049 })
1050 writing = writing
1051 .then(async () => {
1052 const old = await $.fs.read(path).catch(() => '')
1053 await $.fs.write(path, `${old}${line}\n`)
1054 const i = turnRecs.findIndex(x => x.id === r.id)
1055 if (i >= 0) turnRecs[i] = { ...turnRecs[i]!, logged: true }
1056 publishTurns($)
1057 await countToday($)
1058 })
1059 .catch(() => undefined)
1060}
1061
1062/** Lines in today's journal log, every session's: the Turns tab's 📓 count. */
1063async function countToday($: EngineInterface): Promise<void> {
1064 if (!isJournal || dir === '') return
1065 const d = new Date(await $.clock.now())
1066 const day = `${dir}/log/${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
1067 let n = 0
1068 for (const f of await $.fs.list(day).catch(() => [])) {
1069 if (f.kind !== 'file' || !f.name.endsWith('.jsonl')) continue
1070 const text = await $.fs.read(`${day}/${f.name}`).catch(() => '')
1071 n += text.split('\n').filter(l => l.trim() !== '').length
1072 }
1073 await update($, journalAtom, v => (v?.today === n ? v : { today: n }))
1074}
1075function duration(ms: number): string {
1076 const s = Math.max(0, Math.round(ms / 1000))
1077 if (s < 60) return `${s}s`
1078 const m = Math.floor(s / 60)
1079 if (m < 60) return s % 60 === 0 || m >= 10 ? `${m}m` : `${m}m ${s % 60}s`
1080 return `${Math.floor(m / 60)}h ${m % 60}m`
1081}
1082
1083/** "opus", "sonnet", "haiku", or the short model name. */
1084function modelFamily(m: string | undefined): string {
1085 const s = shortModel(m) ?? '?'
1086 return s.match(/opus|sonnet|haiku/)?.[0] ?? s
1087}
1088function modelColor(m: string | undefined): string {
1089 const f = modelFamily(m)
1090 return f === 'opus' ? 'magenta' : f === 'sonnet' ? 'blue' : f === 'haiku' ? 'green' : 'yellow'
1091}
1092
1093const TOOL_COLOR: Record<ToolKind, string> = { read: 'cyan', edit: 'yellow', shell: 'magenta', test: 'green', other: 'gray' }
1094const TOOL_ORDER: ToolKind[] = ['read', 'edit', 'shell', 'test', 'other']
1095
1096/** A coloured badge: a model, a kind, a state. */
1097function badge(ui: Kit, key: string, color: string, text: string): RenderElement {
1098 const { Text } = ui
1099 return (
1100 <Text key={key} bold color="black" backgroundColor={color}>
1101 {` ${text} `}
1102 </Text>
1103 )
1104}
1105
1106/** The tab bar on top of the panel: Sessions · Turns · Files · Context. */
1107function tabBar($: EngineInterface, view: 'board' | 'turns' | 'files' | 'context', turns: number, files: number, ui: Kit): RenderElement {
1108 const { Box, Text, Button } = ui
1109 const tab = (id: 'board' | 'turns' | 'files' | 'context', text: string) => (
1110 <Button
1111 key={`sb-tab-${id}`}
1112 plain
1113 onPress={() =>
1114 void (async () => {
1115 await update($, filesView, v => ({ ...v, view: id, open: null }))
1116 if (id === 'files') await refreshGit($)
1117 if (id === 'context') await refreshDay(guardIo($)).catch(() => undefined)
1118 })()
1119 }
1120 >
1121 {view === id ? (
1122 <Text bold color="black" backgroundColor="cyan">{` ${text} `}</Text>
1123 ) : (
1124 <Text dimColor>{` ${text} `}</Text>
1125 )}
1126 </Button>
1127 )
1128 return (
1129 <Box key="sb-tabs" marginBottom={1}>
1130 {tab('board', 'Sessions')}
1131 <Text> </Text>
1132 {tab('turns', turns > 0 ? `Turns ${turns}` : 'Turns')}
1133 <Text> </Text>
1134 {tab('files', files > 0 ? `Files ${files}` : 'Files')}
1135 <Text> </Text>
1136 {tab('context', 'Context')}
1137 </Box>
1138 )
1139}
1140
1141/** One turn as a full card: Claude's line, the check, files, tools, subagents, assumptions. */
1142function turnCard($: EngineInterface, r: TurnRecord, t: number, width: number, isLatest: boolean, ui: Kit): RenderElement {
1143 const { Box, Text, Button } = ui
1144 const inner = Math.max(20, width - 4)
1145 const hasHigh = r.assumptions.some(a => a.impact === 'high')
1146 const ended = r.endedAt ?? t
1147 const statusText =
1148 r.status === 'running' ? <Text color="cyan">◔ working</Text>
1149 : r.status === 'answered' ? <Text bold color="green">✓ answered</Text>
1150 : r.status === 'stopped' ? <Text bold color="red">× stopped</Text>
1151 : <Text bold color="red">✗ error</Text>
1152 const fileTotal = r.files.added + r.files.modified + r.files.deleted + r.files.renamed
1153 const segs = TOOL_ORDER.filter(k => (r.tools[k] ?? 0) > 0)
1154 const BAR = Math.min(20, Math.max(8, inner - 26))
1155 let left = BAR
1156 const cells = segs.map((k, i) => {
1157 const n = i === segs.length - 1 ? left : Math.max(1, Math.round(((r.tools[k] ?? 0) / Math.max(1, r.toolTotal)) * BAR))
1158 const take = Math.min(left, n)
1159 left -= take
1160 return [k, take] as const
1161 })
1162 return (
1163 <Box key={`sb-turn-card-${r.id}`} flexDirection="column">
1164 <Text>
1165 {isLatest && badge(ui, `sb-turn-tag-${r.id}`, 'cyan', r.status === 'running' ? '◔ RUNNING' : '● LATEST')}
1166 <Text bold>{`${isLatest ? ' ' : ''}${clockTime(r.startedAt)}`}</Text>
1167 <Text dimColor>{` · ${duration(ended - r.startedAt)} `}</Text>
1168 {r.model && badge(ui, `sb-turn-model-${r.id}`, modelColor(r.model), modelFamily(r.model))}
1169 <Text>{' '}</Text>
1170 {statusText}
1171 {r.logged && <Text color="green">{' 📓'}</Text>}
1172 </Text>
1173 <Box flexDirection="column" borderStyle="round" borderColor={hasHigh ? 'yellow' : 'cyan'} paddingX={1}>
1174 {r.summary ? (
1175 <Text bold>{short(r.summary, inner * 2)}</Text>
1176 ) : (
1177 <Text dimColor italic>{r.status === 'running' ? 'Working…' : 'No summary line from Claude'}</Text>
1178 )}
1179 {r.check && (
1180 <Text>
1181 {badge(ui, `sb-turn-check-${r.id}`, 'yellow', '⚑ CHECK')}
1182 <Text color="yellow">{` ${short(r.check, inner - 11)}`}</Text>
1183 </Text>
1184 )}
1185 {isJournal && r.summary && r.status !== 'running' && (
1186 <Button
1187 key={`sb-turn-journal-${r.id}`}
1188 plain
1189 onPress={() => void $.prompt.fill({ text: `done ${r.summary}`, mode: 'replace' }).catch(() => undefined)}
1190 >
1191 <Text color={LINK}>📓 Journal ›</Text>
1192 </Button>
1193 )}
1194 {fileTotal > 0 && (
1195 <Box flexDirection="column" marginTop={1}>
1196 <Text dimColor>{`FILES · ${fileTotal}`}</Text>
1197 {r.files.top.map(f => {
1198 const m = KIND_MARK[f.kind]
1199 const stat = f.kind === 'deleted' ? 'deleted' : f.plus !== undefined ? `+${f.plus ?? 0} −${f.minus ?? 0}` : ''
1200 return (hooks/guard-glue.tsx 783 lines1// The context guard's glue: the facts it needs from the board, the picker, the pilot log, the chip above the
2// prompt and the Context tab. What it decides is in guard.ts (tested alone).
3// Design: 90 System/Generated HTMLs/vault/context-guard-design.html and .../compact-picker-design.html
4// Plan: 90 System/Plans/2026-10-14-session-board-compact-picker.md
5// THE RULE (0.19.0): the guard types, you press Enter. It never runs compact or clear. Every button here only fills
6// the prompt box (`io.fill`); the one compaction call anywhere is the engine's own, after you press Enter.
7// It holds no `$`: the engine refuses a hooks module whose `$` crosses an import. It is handed a GuardIo instead.
8import type { RenderElement } from 'claude-code'
9
10import type { GuardCompaction, GuardView } from '../types'
11import {
12 AFTER_TOKENS,
13 DEFAULTS,
14 DEFAULT_INSTRUCTIONS,
15 DEFAULT_PICKS,
16 PICK_ORDER,
17 afterCompact,
18 boxIsFree,
19 buildCompactCommand,
20 compactInstructions,
21 countRequest,
22 decide,
23 expiredDays,
24 fmtK,
25 formatDay,
26 formatDecision,
27 initialState,
28 isCommitCall,
29 logDir,
30 logPath,
31 parseConfig,
32 parseMode,
33 pickCount,
34 recall,
35 savingPerRequest,
36 shouldLogDecision,
37 sparkWithMarks,
38 summarizeDay,
39} from './guard'
40import type { DaySummary, Decision, DecisionLog, GuardConfig, GuardState, InstructionFacts, PickFacts, PickId, Picks, TurnFacts } from './guard'
41import { short, same, pad2, clockTime, FIRST_COL, LABEL_BG, LINK } from './shared'
42import type { Kit } from './shared'
43
44/** What the guard asks of the engine and of the board. register.tsx builds it from its own `$` (the engine refuses a
45 * hooks module whose `$` crosses an import), so the guard holds no `$` and every engine call stays in register.tsx. */
46export type GuardIo = {
47 // the engine
48 now: () => Promise<number>
49 toast: (text: string, o?: { timeoutMs?: number }) => void
50 /** Opens the Sessions panel. */
51 openPane: () => Promise<unknown>
52 storeGet: (key: string) => Promise<unknown>
53 storeSet: (key: string, value: unknown) => Promise<unknown>
54 readFile: (path: string) => Promise<string>
55 writeFile: (path: string, text: string) => Promise<unknown>
56 listDir: (path: string) => Promise<readonly { name: string; kind: string }[]>
57 run: (argv: string[], o: { timeoutMs: number }) => Promise<unknown>
58 /** Types text into the prompt box (replace mode). It answers `isFilled`; nothing is submitted. */
59 fill: (text: string) => Promise<{ isFilled?: boolean } | undefined>
60 // the panel's state
61 setGuard: (fn: (v: GuardView) => GuardView) => Promise<unknown>
62 /** Switches the panel to its Context tab. */
63 showContextTab: () => Promise<unknown>
64 /** Open assumptions of this session, as the assumptions mod reports them. */
65 review: () => Promise<{ open: number; high: number }>
66 // the board
67 dir: () => string
68 me: () => { id: string; project: string } | undefined
69 isTurnRunning: () => boolean
70 /** Background shells, monitors and subagents still running. */
71 jobs: () => number
72 /** Files this session changed that git still shows as uncommitted. */
73 uncommitted: () => { count: number; paths: string[] }
74 tasks: () => string[]
75 /** The open items (pending or in progress) of this session's task list. */
76 todoLines: () => string[]
77 /** The jobs still running, by name: background shells and monitors, and subagents. Its length is the count the chip calls `jobs`. */
78 jobLines: () => string[]
79 /** Running subagents, as "type · model". */
80 agents: () => string[]
81 refreshGit: () => Promise<void>
82 git: (args: string[]) => Promise<string | undefined>
83}
84
85let gCfg: GuardConfig = DEFAULTS
86let gState: GuardState = initialState()
87let gCtx = 0
88let gWindow = 1_000_000
89let gSeries: number[] = []
90/** The turn that just ended ran a successful git commit or push; cleared when the next turn starts. */
91let gCommitted = false
92let gHistory: GuardCompaction[] = []
93let gPhase: GuardView['phase'] = 'idle'
94let gDone: GuardView['done']
95let gFailed: string | undefined
96let gHasFile = false
97/** What the prompt box holds now (from the edit events), and the last text a button typed into it. */
98let gBox = ''
99let gFilled: string | undefined
100/** The picker in the Context tab: open or not, which rows are ticked, and what the board offers to keep. */
101let gPickOpen = false
102/** The Actions menu under the chip: open or not. Closed by a pick, by words in the box and by a prompt you send. */
103let gMenuOpen = false
104let gPicks: Picks = { ...DEFAULT_PICKS }
105const NO_FACTS: PickFacts = { tasks: [], files: [], jobs: [], assumptions: 0, high: 0 }
106let gFacts: PickFacts = NO_FACTS
107
108const INSTRUCTIONS_FILE = 'compact-instructions.md'
109const GUARD_LOG_KEEP_DAYS = 30
110/** The pilot log's writes, one at a time and in order; the context size of the last line, so one size is one line. */
111let gLogChain: Promise<void> = Promise.resolve()
112let gLastLoggedCtx: number | undefined
113/** What today's log holds (every session's files), as the Context tab's line shows it; undefined until read. */
114let gDay: DaySummary | undefined
115
116/** A new session, or /clear: the guard starts over. */
117function resetGuardSession(): void {
118 gState = initialState()
119 gCtx = 0
120 gSeries = []
121 gCommitted = false
122 gHistory = []
123 gPhase = 'idle'
124 gDone = undefined
125 gFailed = undefined
126 gLastLoggedCtx = undefined
127 gBox = ''
128 gFilled = undefined
129 gPickOpen = false
130 gMenuOpen = false
131 gPicks = { ...DEFAULT_PICKS }
132 gFacts = NO_FACTS
133}
134
135/** A shell call that failed: its result says so in one of the ways the tools report it. */
136function isFailedResult(rec: Record<string, unknown>, raw: unknown): boolean {
137 if (rec.is_error === true || rec.isError === true || rec.interrupted === true) return true
138 if (typeof rec.exitCode === 'number' && rec.exitCode !== 0) return true
139 return typeof raw === 'string' && /^Exit code [1-9]/.test(raw)
140}
141
142async function factsNow(io: GuardIo): Promise<TurnFacts> {
143 return {
144 now: await io.now(),
145 ctx: gCtx,
146 isWorking: io.isTurnRunning(),
147 jobs: io.jobs(),
148 lastTurnCommitted: gCommitted,
149 uncommittedMine: io.uncommitted().count,
150 }
151}
152
153/** What the board offers to keep: this session's open task items, its uncommitted files, running jobs and the open assumptions. */
154async function pickFacts(io: GuardIo): Promise<PickFacts> {
155 const rv = await io.review().catch(() => ({ open: 0, high: 0 }))
156 return { tasks: io.todoLines(), files: io.uncommitted().paths, jobs: io.jobLines(), assumptions: rv.open, high: rv.high }
157}
158
159/** Decides again from the facts as they are, and draws. Nothing here starts a compaction. */
160export async function refreshGuard(io: GuardIo, o: { git?: boolean } = {}): Promise<{ d: Decision; f: TurnFacts }> {
161 if (o.git) await io.refreshGit().catch(() => undefined)
162 const f = await factsNow(io)
163 const d = decide(gCfg, gState, f)
164 gFacts = await pickFacts(io)
165 // The menu belongs to a chip that offers it, and to a box you have not typed in.
166 if (d.chip === 'none' || gPhase === 'compacting' || gPhase === 'done' || !boxIsFree(gBox, gFilled)) gMenuOpen = false
167 const view: GuardView = {
168 chip: d.chip,
169 grade: d.grade,
170 reason: d.reason,
171 saving: d.saving,
172 ctx: gCtx,
173 window: gWindow,
174 mode: gCfg.mode,
175 phase: gPhase,
176 done: gDone,
177 failed: gFailed,
178 jobs: f.jobs,
179 series: gSeries,
180 history: gHistory,
181 adviseAt: gCfg.adviseAt,
182 floor: gState.floor,
183 hasFile: gHasFile,
184 today: gDay,
185 pickOpen: gPickOpen,
186 picks: gPicks,
187 facts: gFacts,
188 command: buildCompactCommand(gPicks, gFacts),
189 boxHolds: !boxIsFree(gBox, gFilled),
190 menuOpen: gMenuOpen,
191 }
192 await io.setGuard(v => (same(v, view) ? v : view))
193 return { d, f }
194}
195
196/** Reads today's pilot log, every session's files, for the Context tab's line. Only reads: the guard is not asked to decide again. */
197export async function refreshDay(io: GuardIo): Promise<void> {
198 const dir = io.dir()
199 if (dir === '') return
200 const folder = logDir(dir, await io.now())
201 const texts: string[] = []
202 for (const f of await io.listDir(folder).catch(() => [])) {
203 if (f.kind !== 'file' || !f.name.endsWith('.jsonl')) continue
204 texts.push(await io.readFile(`${folder}/${f.name}`).catch(() => ''))
205 }
206 gDay = summarizeDay(texts)
207 await io.setGuard(v => {
208 const next = { ...v, today: gDay }
209 return same(v, next) ? v : next
210 })
211}
212
213/**
214 * The pilot log: one line per decision, at the end of a turn that moved the context, from the dim chip up.
215 * Beside the journal's log/ folder and never in it: guard/<day>/<session>.jsonl. Numbers and the verdict only:
216 * no prompt text, no file names. One file per session per day, so sessions never write the same file; a write
217 * that fails is dropped, and never reaches the guard.
218 */
219function logDecision(io: GuardIo, d: Decision, f: TurnFacts): void {
220 const dir = io.dir()
221 const me = io.me()
222 if (dir === '' || !me) return
223 const t = new Date(f.now)
224 const entry: DecisionLog = {
225 ts: f.now,
226 at: `${pad2(t.getHours())}:${pad2(t.getMinutes())}:${pad2(t.getSeconds())}`,
227 project: me.project,
228 mode: gCfg.mode,
229 ctx: f.ctx,
230 window: gWindow,
231 chip: d.chip,
232 grade: d.grade,
233 reason: d.reason,
234 advised: d.chip === 'advise',
235 saving: d.saving,
236 floor: gState.floor,
237 jobs: f.jobs,
238 mine: f.uncommittedMine,
239 committed: f.lastTurnCommitted,
240 }
241 const path = logPath(dir, f.now, me.id)
242 const line = formatDecision(entry)
243 gLogChain = gLogChain
244 .then(async () => {
245 const old = await io.readFile(path).catch(() => '')
246 await io.writeFile(path, `${typeof old === 'string' ? old : ''}${line}\n`)
247 await refreshDay(io)
248 })
249 .catch(() => undefined)
250}
251
252/** Removes the guard log's day folders older than 30 days. Only date-named folders under guard/ are touched; the journal's log/ never is. */
253export async function pruneGuardLogs(io: GuardIo): Promise<void> {
254 const dir = io.dir()
255 if (dir === '') return
256 const root = `${dir}/guard`
257 const entries = await io.listDir(root).catch(() => [])
258 const isWindows = /^[A-Za-z]:/.test(dir)
259 const old = expiredDays(entries.filter(f => f.kind === 'dir').map(f => f.name), await io.now(), GUARD_LOG_KEEP_DAYS)
260 for (const name of old) {
261 const path = `${root}/${name}`
262 const argv = isWindows ? ['cmd', '/c', 'rd', '/s', '/q', path.replace(/\//g, '\\')] : ['rm', '-rf', '--', path]
263 await io.run(argv, { timeoutMs: 10_000 }).catch(() => undefined)
264 }
265}
266
267/** Your own instructions file, or the built-in text. */
268export async function loadInstructions(io: GuardIo): Promise<string> {
269 const dir = io.dir()
270 const raw = dir === '' ? undefined : await io.readFile(`${dir}/${INSTRUCTIONS_FILE}`).catch(() => undefined)
271 const own = typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : undefined
272 gHasFile = own !== undefined
273 return own ?? DEFAULT_INSTRUCTIONS
274}
275
276/** What the board knows, for the notes the summary is told to write. */
277async function instructionFacts(io: GuardIo): Promise<InstructionFacts> {
278 const [branch, last] = await Promise.all([io.git(['rev-parse', '--abbrev-ref', 'HEAD']), io.git(['log', '-1', '--format=%h'])])
279 const rv = await io.review()
280 return {
281 ctx: gCtx,
282 branch: branch?.trim() || undefined,
283 lastCommit: last?.trim() || undefined,
284 uncommitted: io.uncommitted().paths,
285 jobs: io.tasks(),
286 agents: io.agents(),
287 assumptions: rv.open > 0 ? [`${rv.open} open (${rv.high} high impact)`] : [],
288 }
289}
290
291const textOf = (msgs: unknown): string =>
292 Array.isArray(msgs) ? msgs.slice(-100).map(m => (m && typeof m === 'object' && 'text' in m ? String((m as { text: unknown }).text) : '')).join('\n') : ''
293
294/** A compaction happened (yours, after you pressed Enter, or the engine's own): the floor moves, the cooldown starts, the history grows. */
295async function noteCompacted(io: GuardIo, r: { messages?: readonly { text?: string }[]; tokensBefore?: number; tokensAfter?: number }, via: string, beforeText: string): Promise<GuardCompaction> {
296 const t = await io.now()
297 const before = r.tokensBefore ?? gCtx
298 const after = r.tokensAfter
299 gState = afterCompact(gCfg, gState, { before, after, now: t })
300 let kept: GuardCompaction['recall']
301 try {
302 const summary = (r.messages ?? []).map(m => m.text ?? '').join('\n')
303 const rc = recall(beforeText, summary)
304 if (rc.total > 0) kept = rc
305 } catch {
306 // no recall figure: the compaction still counts
307 }
308 const entry: GuardCompaction = { at: t, before, after, via, recall: kept }
309 gHistory = [entry, ...gHistory].slice(0, 5)
310 if (after !== undefined) {
311 gCtx = after
312 gSeries = [...gSeries, after].slice(-60)
313 }
314 return entry
315}
316
317/** A compaction has begun (you pressed Enter on a /compact, or the engine started its own): the chip says so. */
318export async function guardCompacting(io: GuardIo): Promise<void> {
319 gPhase = 'compacting'
320 gDone = undefined
321 gFailed = undefined
322 await refreshGuard(io)
323}
324
325/** The engine refused the compaction, or it failed: the chip says why. */
326export async function guardCompactFailed(io: GuardIo, why: string): Promise<void> {
327 gPhase = 'failed'
328 gFailed = why.slice(0, 80)
329 await refreshGuard(io)
330}
331
332async function setGuardMode(io: GuardIo, m: GuardConfig['mode']): Promise<void> {
333 gCfg = { ...gCfg, mode: m }
334 gState = { ...gState, shrinkFailures: 0 }
335 await io.storeSet('guard.mode', m).catch(() => undefined)
336 await refreshGuard(io)
337}
338
339// ── the buttons: they type, you press Enter ───────────────────────────────────
340
341/**
342 * Types a command into the prompt box. Returns true when the box took it. A button that finds your own text in the
343 * box does nothing (the buttons are drawn off then); a refused fill says so and shows where to read the command.
344 */
345async function typeCommand(io: GuardIo, text: string): Promise<boolean> {
346 if (!boxIsFree(gBox, gFilled)) return false
347 // The engine may report the edit our own fill makes before fill() returns: the text is ours from the first moment.
348 const before = gFilled
349 gFilled = text
350 const r = await io.fill(text).catch(() => undefined)
351 if (!r?.isFilled) {
352 gFilled = before
353 io.toast(text.length <= 120 ? `Could not type into the prompt box. Type this by hand: ${text}` : 'Could not type into the prompt box. The command is shown in the Context tab of /sessions.', { timeoutMs: 8000 })
354 return false
355 }
356 gBox = text
357 await refreshGuard(io)
358 return true
359}
360
361/** Opens or closes the Actions menu under the chip. */
362async function toggleMenu(io: GuardIo): Promise<void> {
363 gMenuOpen = !gMenuOpen
364 await refreshGuard(io)
365}
366
367/** A menu pick that types: the menu closes, the command goes into the box (the box may refuse), and you press Enter. */
368async function chooseTyped(io: GuardIo, text: string): Promise<void> {
369 gMenuOpen = false
370 await typeCommand(io, text)
371 await refreshGuard(io)
372}
373
374/** Opens the picker in the Context tab. */
375async function openPicker(io: GuardIo): Promise<void> {
376 // Open the panel first, inside the press. After an await the engine counts the open as unasked, and a window
377 // under 144 columns then leaves the panel undrawn (found with the strip's turn button, 2026-10-15).
378 const opening = io.openPane().catch(() => undefined)
379 gMenuOpen = false
380 gPickOpen = true
381 await refreshGuard(io)
382 await io.showContextTab()
383 void refreshDay(io).catch(() => undefined)
384 await opening
385}
386
387async function closePicker(io: GuardIo): Promise<void> {
388 gPickOpen = false
389 await refreshGuard(io)
390}
391
392async function togglePick(io: GuardIo, id: PickId): Promise<void> {
393 gPicks = { ...gPicks, [id]: !gPicks[id] }
394 await refreshGuard(io)
395}
396
397/** The picker's "Type it": the previewed command goes into the box, and the picker closes when the box took it. */
398async function typePicked(io: GuardIo): Promise<void> {
399 if (await typeCommand(io, buildCompactCommand(gPicks, gFacts))) await closePicker(io)
400}
401
402async function createInstructionsFile(io: GuardIo): Promise<void> {
403 const dir = io.dir()
404 if (dir === '' || gHasFile) return
405 await io.writeFile(`${dir}/${INSTRUCTIONS_FILE}`, DEFAULT_INSTRUCTIONS).catch(() => undefined)
406 await loadInstructions(io)
407 await refreshGuard(io)
408}
409
410/** Which state of the context row is drawn, tested in the order `guardRow` tests them. */
411export type GuardLook = 'compacting' | 'done' | 'failed' | 'dim' | 'advise' | 'stopped' | 'none'
412export function guardLook(g: GuardView): GuardLook {
413 if (g.phase === 'compacting') return 'compacting'
414 if (g.phase === 'done' && g.done) return 'done'
415 if (g.phase === 'failed') return 'failed'
416 if (g.chip === 'dim') return 'dim'
417 if (g.chip === 'advise') return 'advise'
418 if (g.chip === 'stopped') return 'stopped'
419 return 'none'
420}
421
422/**
423 * True when the row starts with a black status chip (compacting, compacted, failed, stopped). Such a row must not sit
424 * directly under the strip, whose chips are black too: they would read as one slab, so an empty row goes between. The
425 * quiet and advice rows start with the plain Actions button, so they sit right under the strip.
426 */
427export function guardOpensWithChip(g: GuardView): boolean {
428 const look = guardLook(g)
429 return look !== 'none' && look !== 'dim' && look !== 'advise'
430}
431
432/**
433 * The row above the prompt, one row, and the Actions menu under it while that is open. Section 4 of the design page
434 * lists every state. One Actions button replaces the separate Compact, Plain, Later and /clear buttons: it opens a
435 * menu of three picks, and each pick only TYPES into the prompt box (you press Enter), as the buttons did.
436 * The context size is not here: it is in the usage gauge's context meter, under the prompt. The quiet and advice rows
437 * start with the Actions button; the status rows start with their black chip.
438 */
439export function guardRow(io: GuardIo, columns: number, g: GuardView, ui: Kit): RenderElement {
440 const { Box, Text, Button } = ui
441 const sep = (k: string) => (
442 <Text key={k} dimColor>
443 {' │ '}
444 </Text>
445 )
446 // A status is the status colour as text on the same true black as the strip's chips (the CHECK flag and the review
447 // chip in the strip above are not statuses and keep their yellow block). The advice's number is neutral (white): it
448 // is an optional hint, so it must not be louder than the CHECK flag, and yellow already means "needs you".
449 const statusChip = (k: string, color: string, text: string) => (
450 <Text key={k} bold color={color} backgroundColor={LABEL_BG}>
451 {` ${text} `}
452 </Text>
453 )
454 // The button sits right after the chip (first in the row when there is none), and the menu opens beneath it: each
455 // row's marker is under the button's first letter. It is drawn off, as dim text, while the box holds words of yours;
456 // the row then says why, at its end.
457 const actions = (): RenderElement => (
458 g.boxHolds ? (
459 <Text key="sb-guard-menu" dimColor>{'Actions ▾'}</Text>
460 ) : (
461 <Button key="sb-guard-menu" plain onPress={() => void toggleMenu(io).catch(() => undefined)}>
462 <Text color={LINK}>{g.menuOpen ? 'Actions ▴' : 'Actions ▾'}</Text>
463 </Button>
464 )
465 )
466 const boxNote = g.boxHolds ? <Text key="sb-g-boxnote" dimColor>{' the box has your text'}</Text> : null
467 // The cells before "Actions": the chip's text, its one cell of padding either side, and one space. The chip is padded
468 // to the same width as the strip's "current" chip, so the button starts in the same cell as the status above it.
469 let anchor = 0
470 const chipped = (color: string, text: string): RenderElement[] => {
471 const padded = text.padEnd(FIRST_COL)
472 anchor = padded.length + 3
473 return [statusChip('sb-g-p', color, padded), <Text key="sb-g-sp"> </Text>, actions()]
474 }
475 const MENU: [string, string, string, () => void][] = [
476 ['sb-guard-pick', 'Compact…', 'choose what to keep', () => void openPicker(io).catch(() => undefined)],
477 ['sb-guard-plain', 'Plain compact', 'types /compact', () => void chooseTyped(io, '/compact').catch(() => undefined)],
478 ['sb-guard-clear', 'Clear', 'types /clear', () => void chooseTyped(io, '/clear').catch(() => undefined)],
479 ]
480 // One label column for all rows: the longest label and three spaces, so every hint starts in the same cell.
481 const labelWidth = Math.max(...MENU.map(m => m[1].length)) + 3
482 const menu = () => (
483 <Box key="sb-guard-rows" flexDirection="column">
484 {MENU.map(([key, label, hint, act]) => (
485 <Button key={key} plain onPress={act}>
486 <Text>
487 <Text color={LINK}>{`${' '.repeat(anchor)}▸ ${label.padEnd(labelWidth)}`}</Text>
488 <Text dimColor>{hint}</Text>
489 </Text>
490 </Button>
491 ))}
492 </Box>
493 )
494 const openTab = () =>
495 void (async () => {
496 await io.showContextTab()
497 void refreshDay(io).catch(() => undefined)
498 await io.openPane()
499 })().catch(() => undefined)
500 const btn = (key: string, label: string, act: () => void) => (
501 <Button key={key} plain onPress={act}>
502 <Text color={LINK}>{`[ ${label} ]`}</Text>
503 </Button>
504 )
505 const ctx = fmtK(g.ctx)
506 const look = guardLook(g)
507 const parts: RenderElement[] = []
508 // The states that offer the menu: the quiet row, the advice, a failed compaction and the stop.
509 let hasMenu = false
510 if (look === 'compacting') {
511 parts.push(statusChip('sb-g-p', 'magenta', '⟳ compacting'), <Text key="sb-g-t" dimColor>{` the summary is being written · ctx ${ctx}`}</Text>)
512 } else if (look === 'done' && g.done) {
513 parts.push(
514 statusChip('sb-g-p', 'green', '✓ compacted'),
515 <Text key="sb-g-t">{` ${fmtK(g.done.before)} → ${fmtK(g.done.after)}`}</Text>,
516 sep('sb-g-s1'),
517 <Text key="sb-g-save" color="green">{`−$${g.done.saving.toFixed(2)} per request`}</Text>,
518 sep('sb-g-s2'),
519 btn('sb-guard-see', 'see summary', openTab),
520 ...(g.done.recall !== undefined ? [<Text key="sb-g-rc" dimColor>{` · recall ${g.done.recall}%`}</Text>] : []),
521 )
522 } else if (look === 'failed') {
523 hasMenu = true
524 parts.push(...chipped('red', '✗ compact failed'), <Text key="sb-g-t" dimColor>{` ${g.failed ?? ''}`}</Text>)
525 } else if (look === 'dim') {
526 // Quiet: nothing but the button. The size is in the gauge's context meter.
527 hasMenu = true
528 parts.push(actions())
529 } else if (look === 'advise') {
530 hasMenu = true
531 parts.push(actions())
532 if (columns >= 60) {
533 parts.push(
534 <Text key="sb-g-gap">{' '}</Text>,
535 g.grade === 'good' ? <Text key="sb-g-gr" color="green">good moment: just committed</Text> : <Text key="sb-g-gr" dimColor>ok moment</Text>,
536 // Background work does not hold the advice back; the line only says it is there.
537 ...(g.jobs > 0 ? [<Text key="sb-g-jobs" dimColor>{` · ${g.jobs} ${g.jobs === 1 ? 'job' : 'jobs'} running`}</Text>] : []),
538 sep('sb-g-s1'),
539 <Text key="sb-g-sv" dimColor>{`compacting saves about $${g.saving.toFixed(2)} per request`}</Text>,
540 )
541 }
542 } else if (look === 'stopped') {
543 hasMenu = true
544 parts.push(
545 ...chipped('red', '■ stopped'),
546 <Text key="sb-g-t" dimColor>{' two compactions in a row did not shrink the context · pick a mode in the Context tab to start again'}</Text>,
547 )
548 }
549 return (
550 <Box key="sb-guard" flexDirection="column">
551 <Box key="sb-guard-line" flexWrap="wrap">
552 {parts}
553 {hasMenu && boxNote}
554 </Box>
555 {hasMenu && g.menuOpen && !g.boxHolds && menu()}
556 </Box>
557 )
558}
559
560/** The Context tab: mode, thresholds, the sparkline, the last compactions, the notes the summary is told. */
561export function contextPane(io: GuardIo, g: GuardView, t: number, width: number, ui: Kit): RenderElement {
562 const { Box, Text, Button } = ui
563 const modeBtn = (m: GuardConfig['mode'], label: string) => (
564 <Button key={`sb-guard-mode-${m}`} plain onPress={() => void setGuardMode(io, m).catch(() => undefined)}>
565 {g.mode === m ? (
566 <Text bold color="black" backgroundColor="cyan">{` ${label} `}</Text>
567 ) : (
568 <Text dimColor>{` ${label} `}</Text>
569 )}
570 </Button>
571 )
572 const payback = g.saving > 0 ? Math.ceil(0.35 / g.saving) : undefined
573 // The buttons that type are drawn off, as dim text, while the box holds words of yours.
574 const typing = (key: string, label: string, act: () => void) =>
575 g.boxHolds ? (
576 <Text key={key} dimColor>{`[ ${label} ] `}</Text>
577 ) : (
578 <Button key={key} plain onPress={act}>
579 <Text color={LINK}>{`[ ${label} ] `}</Text>
580 </Button>
581 )
582 const pickLabel: Record<PickId, string> = {
583 notes: 'Six-part notes',
584 lean: 'Leave out tool output and logs',
585 tasks: "Open items of this session's task list",
586 files: 'Uncommitted files of this session',
587 jobs: 'Running jobs',
588 assumptions: 'Open assumptions',
589 }
590 const pickHint: Partial<Record<PickId, string>> = { notes: 'rules, state, identifiers, decisions, questions, what to verify' }
591 const pickRow = (id: PickId) => {
592 const n = pickCount(id, g.facts)
593 const counted = id === 'notes' || id === 'lean' ? '' : ` (${n})`
594 if (n === 0) {
595 return (
596 <Text key={`sb-guard-pk-${id}`} dimColor>{` [ ] ${pickLabel[id]}${counted} nothing to keep`}</Text>
597 )
598 }
599 return (
600 <Button key={`sb-guard-pk-${id}`} plain onPress={() => void togglePick(io, id).catch(() => undefined)}>
601 <Text>
602 <Text color={g.picks[id] ? 'green' : undefined}>{` [${g.picks[id] ? 'x' : ' '}] `}</Text>
603 {`${pickLabel[id]}${counted}`}
604 {pickHint[id] !== undefined && <Text dimColor>{` ${pickHint[id]}`}</Text>}
605 </Text>
606 </Button>
607 )
608 }
609 return (
610 <Box flexDirection="column">
611 <Box flexWrap="wrap">
612 <Text>
613 <Text bold>{`Context ${fmtK(g.ctx)}`}</Text>
614 <Text dimColor>{` of ${fmtK(g.window)}`}</Text>
615 </Text>
616 <Text>{' mode '}</Text>
617 {modeBtn('advise', 'Advise')}
618 {modeBtn('off', 'Off')}
619 </Box>
620 <Text dimColor>{`advise from ${fmtK(g.adviseAt)} · the buttons type the command; you press Enter`}</Text>
621 {g.series.length > 0 && (
622 <Text>
623 <Text color="cyan">{sparkWithMarks(g.series, g.window)}</Text>
624 <Text dimColor>{' one bar per turn · │ = a compaction'}</Text>
625 </Text>
626 )}
627 <Text>
628 <Text dimColor>{'now → after: '}</Text>
629 {`${fmtK(g.ctx)} → about ${fmtK(AFTER_TOKENS)}`}
630 <Text color="green">{` saves about $${g.saving.toFixed(2)} per request`}</Text>
631 {payback !== undefined && <Text dimColor>{` pays back in about ${payback} requests`}</Text>}
632 </Text>
633 {g.today && (
634 <Box key="sb-guard-day" flexWrap="wrap">
635 <Text dimColor>{'pilot log today '}</Text>
636 <Text>{formatDay(g.today)}</Text>
637 </Box>
638 )}
639 <Box marginTop={1} flexWrap="wrap">
640 {typing('sb-guard-tab-pick', 'Compact…', () => void openPicker(io).catch(() => undefined))}
641 {typing('sb-guard-tab-plain', 'Plain', () => void typeCommand(io, '/compact').catch(() => undefined))}
642 {typing('sb-guard-tab-clear', '/clear', () => void typeCommand(io, '/clear').catch(() => undefined))}
643 {g.boxHolds && <Text dimColor>{'the box has your text; send or clear it first'}</Text>}
644 </Box>
645 {g.pickOpen && (
646 <Box key="sb-guard-picker" flexDirection="column" marginTop={1}>
647 <Text bold>Compact: choose what to keep</Text>
648 {PICK_ORDER.map(pickRow)}
649 <Text dimColor>{'The command the mod will type. Add your own words at the end of the box, then press Enter:'}</Text>
650 <Text key="sb-guard-command">{g.command}</Text>
651 <Box flexWrap="wrap">
652 {typing('sb-guard-type', 'Type it in the prompt box', () => void typePicked(io).catch(() => undefined))}
653 <Button key="sb-guard-pick-cancel" plain onPress={() => void closePicker(io).catch(() => undefined)}>
654 <Text color={LINK}>[ Cancel ]</Text>
655 </Button>
656 </Box>
657 </Box>
658 )}
659 <Box marginTop={1}>
660 <Text bold>Last compactions</Text>
661 </Box>
662 {g.history.length === 0 && <Text dimColor>none yet</Text>}
663 {g.history.map((item, i) => (
664 <Box key={`sb-guard-h-${i}`} flexWrap="wrap">
665 <Text>{` ${clockTime(item.at)} ${fmtK(item.before)} → ${item.after !== undefined ? fmtK(item.after) : '?'} `}</Text>
666 {item.recall && (
667 <Text color={item.recall.found / item.recall.total >= 0.9 ? 'green' : 'yellow'}>{`recall ${Math.round((item.recall.found / item.recall.total) * 100)}%`}</Text>
668 )}
669 {item.recall && item.recall.missing.length > 0 && <Text dimColor>{` missing: ${item.recall.missing.slice(0, 3).join(', ')}`}</Text>}
670 <Text dimColor>{` ${item.via}`}</Text>
671 </Box>
672 ))}
673 <Box marginTop={1} flexWrap="wrap">
674 <Text bold>The engine's own late compaction is told </Text>
675 <Text dimColor>{`~/.claude/session-board/${INSTRUCTIONS_FILE}${g.hasFile ? ' (yours)' : ' (built-in text)'}`}</Text>
676 {!g.hasFile && (
677 <Button key="sb-guard-file" plain onPress={() => void createInstructionsFile(io)}>
678 <Text color={LINK}>{' [ create file to edit ]'}</Text>
679 </Button>
680 )}
681 </Box>
682 <Text dimColor>{short('1 Rules still in force · 2 State · 3 Identifiers, copied exactly · 4 Decisions · 5 Open questions · 6 Verify first', width)}</Text>
683 <Text dimColor>{short('+ facts from the board: branch, last commit, uncommitted files, jobs, subagents, open assumptions', width)}</Text>
684 <Text dimColor>{short('A /compact you press Enter on is never changed: a bare one is plain, and the words after it are yours.', width)}</Text>
685 {void t}
686 </Box>
687 )
688}
689
690
691// ── what register.tsx calls ───────────────────────────────────────────────────
692// One function per place where the board's hooks meet the guard. The state above is private to this file.
693
694/**
695 * A new session: the options, then the mode you last chose in the Context tab. A stored `auto` (the mode went in
696 * 0.19.0) reads as `advise`; a stored countdown length (`guard.veto`) is never read.
697 */
698export async function configureGuard(io: GuardIo, options: Parameters<typeof parseConfig>[0]): Promise<void> {
699 gCfg = parseConfig(options)
700 const saved = await io.storeGet('guard.mode').catch(() => undefined)
701 if (saved === 'off' || saved === 'advise' || saved === 'auto') gCfg = { ...gCfg, mode: parseMode(saved) }
702 // Whether you keep your own notes file, for the Context tab and for the engine's own late compaction.
703 await loadInstructions(io).catch(() => undefined)
704 resetGuardSession()
705}
706
707/** You sent a prompt: the box is empty again, and the last compaction's result row ends. */
708export async function guardPromptSent(io: GuardIo): Promise<void> {
709 gMenuOpen = false
710 gBox = ''
711 gFilled = undefined
712 if (gPhase === 'done' || gPhase === 'failed') gPhase = 'idle'
713 await refreshGuard(io).catch(() => undefined)
714}
715
716/** A turn began: whatever was committed before it no longer counts as "just committed". */
717export async function guardTurnStarted(io: GuardIo): Promise<void> {
718 gCommitted = false
719 await refreshGuard(io).catch(() => undefined)
720}
721
722/** One more model request of the main loop (the cooldown counts requests). */
723export function guardRequestCounted(): void {
724 gState = countRequest(gState)
725}
726
727/** A tool call finished: a successful git commit or push by the main loop makes this a good moment. */
728export function noteGuardCall(c: { isMainLoop: boolean; tool: string; args: Record<string, unknown>; rec: Record<string, unknown>; raw: unknown }): void {
729 if (c.isMainLoop && (c.tool === 'Bash' || c.tool === 'PowerShell') && typeof c.args.command === 'string' && isCommitCall(c.args.command) && !isFailedResult(c.rec, c.raw)) gCommitted = true
730}
731
732/** The session ended: /clear starts the guard over. */
733export function guardSessionEnded(reason: string): void {
734 if (reason === 'clear') resetGuardSession()
735}
736
737/** The engine pushed the context size after a turn. Git is read here, once per turn. */
738export async function guardMeasured(io: GuardIo, m: { tokens: number | undefined; window: number; changed: readonly string[] }): Promise<void> {
739 const tokens = m.tokens
740 if (typeof tokens === 'number') {
741 gCtx = tokens
742 gWindow = m.window
743 if (m.changed.includes('context')) gSeries = [...gSeries, tokens].slice(-60)
744 }
745 const r = await refreshGuard(io, { git: tokens !== undefined && tokens >= gCfg.dimAt })
746 if (typeof tokens === 'number' && shouldLogDecision({ changedContext: m.changed.includes('context'), chip: r.d.chip, ctx: gCtx, lastCtx: gLastLoggedCtx })) {
747 gLastLoggedCtx = gCtx
748 logDecision(io, r.d, r.f)
749 }
750}
751
752/**
753 * The prompt box was edited; `text` is what it holds now. Words you type switch the Actions menu off (and close it).
754 * Text the menu typed does not, and an emptied box switches it on again.
755 */
756export async function guardTyped(io: GuardIo, text: string): Promise<void> {
757 const wasFree = boxIsFree(gBox, gFilled)
758 gBox = text
759 if (text.trim() === '') gFilled = undefined
760 if (boxIsFree(gBox, gFilled) !== wasFree) await refreshGuard(io)
761}
762
763/**
764 * The notes a compaction of this conversation is told to keep, or undefined to leave it as it was asked. A `/compact`
765 * you press Enter on is never changed (a bare one is plain, the words after it are yours); only the engine's own
766 * late compaction and a precompute get the recipe.
767 */
768export async function compactionNotes(io: GuardIo, c: { trigger: Parameters<typeof compactInstructions>[0]['trigger'] }): Promise<ReturnType<typeof compactInstructions>> {
769 if (c.trigger === 'manual') return undefined
770 return compactInstructions({ trigger: c.trigger, base: await loadInstructions(io), facts: await instructionFacts(io) })
771}
772
773/** A compaction you pressed Enter on, or the engine's own, has finished: the floor moves and the chip shows the result. */
774export async function guardCompacted(io: GuardIo, r: Parameters<typeof noteCompacted>[1], trigger: string, messages: unknown): Promise<void> {
775 const entry = await noteCompacted(io, r, trigger, textOf(messages))
776 const after = entry.after ?? entry.before
777 const pct = entry.recall ? Math.round((entry.recall.found / entry.recall.total) * 100) : undefined
778 gDone = { before: entry.before, after, saving: savingPerRequest(entry.before) - savingPerRequest(after), recall: pct }
779 gPhase = 'done'
780 gFailed = undefined
781 await refreshGuard(io)
782}
783hooks/shared.ts 50 lines1// Small pure helpers that the board (register.tsx) and the context guard (guard-glue.tsx) both use.
2// Nothing here reads module state.
3import type { RenderElement } from 'claude-code'
4
5export function short(text: string, n: number): string {
6 const one = text.replace(/\s+/g, ' ').trim()
7 return one.length > n ? `${one.slice(0, Math.max(1, n - 1))}…` : one
8}
9
10export const same = (a: unknown, b: unknown) => {
11 try {
12 return JSON.stringify(a) === JSON.stringify(b)
13 } catch {
14 return false
15 }
16}
17
18/** A row of `width` cells: filled ones for done. */
19export function barCells(done: number, total: number, width: number): [number, number] {
20 const filled = total > 0 ? Math.min(width, Math.round((done / total) * width)) : 0
21 return [filled, width - filled]
22}
23
24export const pad2 = (n: number) => String(n).padStart(2, '0')
25
26export function clockTime(ms: number): string {
27 const d = new Date(ms)
28 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
29}
30
31/** Every chip of the strip and of the context row sits on this true black (his choice, 2026-10-15): the labels are white text
32 * on it, a status is the status colour as text on it. A hex, not the name "black": the name is the theme's own black,
33 * which can be the same as the background, and the chip would vanish. */
34export const LABEL_BG = '#000000'
35
36/** Links and buttons. Not the name "blue", which is the theme's own (#3E63DD on a #282A36 background: 2.7:1, hard to read):
37 * this one is 5.6:1 on that background. */
38export const LINK = '#7AA2F7'
39
40/** The text of the two label chips ("current", "others"): dim and plain, so the value beside each label is what stands out
41 * (6.8:1 on the chips' black). */
42export const LABEL_FG = '#8B949E'
43
44/** The first chip of the strip ("current") and the status chip of the context row (when it has one) are padded to this many
45 * characters, so both are the same width and the value beside each (the status, the Actions button) starts in the same
46 * cell. The quiet and advice rows have no chip: their Actions button starts at the first cell. */
47export const FIRST_COL = 8
48
49export type Kit = { Box: (p: any) => RenderElement; Text: (p: any) => RenderElement; Button: (p: any) => RenderElement }
50hooks/guard.ts 443 lines1// The context guard's logic, kept apart from the drawing so it can be tested alone. Design and numbers:
2// 90 System/Generated HTMLs/vault/context-guard-design.html (decided 2026-10-13). Pure functions only:
3// nothing here reads the engine, the clock or a file.
4
5export type Mode = 'off' | 'advise'
6export type Chip = 'none' | 'dim' | 'advise' | 'stopped'
7export type Grade = 'good' | 'ok'
8
9export type GuardConfig = {
10 mode: Mode
11 /** Tokens: the dim chip shows from here. */
12 dimAt: number
13 /** Tokens: the advice and its buttons start here. */
14 adviseAt: number
15 /** The advice also needs the context this far above the floor, so a big attachment cannot cause an instant second compaction. */
16 minGrowth: number
17 cooldownRequests: number
18 cooldownMs: number
19}
20
21export const DEFAULTS: GuardConfig = {
22 mode: 'advise',
23 dimAt: 200_000,
24 adviseAt: 300_000,
25 minGrowth: 150_000,
26 cooldownRequests: 8,
27 cooldownMs: 20 * 60_000,
28}
29
30/** About what a session holds right after a compaction: the system part (about 107K) and a short summary. */
31export const AFTER_TOKENS = 120_000
32/** Dollars per cached token read, fitted to the user's own cost records (about $0.21 per million). */
33const READ_PRICE = 0.21e-6
34/** A fresh session's floor, until a compaction says what it really is. */
35const DEFAULT_FLOOR = 120_000
36const NO_COOLDOWN = 1_000_000
37
38export type GuardState = {
39 /** The context's size right after the last compaction (or a fresh session's baseline). */
40 floor: number
41 lastCompactAt?: number
42 requestsSinceCompact: number
43 /** Compactions in a row that did not shrink the context by a fifth. */
44 shrinkFailures: number
45}
46
47export type TurnFacts = {
48 now: number
49 ctx: number
50 /** A turn is running: nothing can be offered, and the engine rejects a compaction. */
51 isWorking: boolean
52 /** Background shells, monitors and subagents still running. The chip says so; it does not hold the advice back. */
53 jobs: number
54 /** The turn that just ended ran a successful git commit or push. */
55 lastTurnCommitted: boolean
56 /** Files this session changed that git still shows as uncommitted. */
57 uncommittedMine: number
58}
59
60export type Decision = {
61 chip: Chip
62 grade?: Grade
63 /** Why a chip is dim or stopped: growth, cooldown, no-shrink. */
64 reason?: string
65 /** Dollars saved per later request if it compacted now. */
66 saving: number
67}
68
69export function initialState(): GuardState {
70 return { floor: DEFAULT_FLOOR, requestsSinceCompact: NO_COOLDOWN, shrinkFailures: 0 }
71}
72
73export function savingPerRequest(ctx: number): number {
74 return Math.max(0, ctx - AFTER_TOKENS) * READ_PRICE
75}
76
77export function decide(cfg: GuardConfig, st: GuardState, f: TurnFacts): Decision {
78 const saving = savingPerRequest(f.ctx)
79 const quiet = (chip: Chip, reason?: string): Decision => ({ chip, reason, saving })
80 if (cfg.mode === 'off' || f.isWorking || f.ctx < cfg.dimAt) return quiet('none')
81 const floorLine = st.floor + cfg.minGrowth
82 const adviseLine = Math.max(cfg.adviseAt, floorLine)
83 if (f.ctx < adviseLine) return quiet('dim', f.ctx >= cfg.adviseAt ? 'growth' : undefined)
84 if (st.shrinkFailures >= 2) return quiet('stopped', 'no-shrink')
85 const isCooling = st.requestsSinceCompact < cfg.cooldownRequests || (st.lastCompactAt !== undefined && f.now - st.lastCompactAt < cfg.cooldownMs)
86 if (isCooling) return quiet('dim', 'cooldown')
87 const grade: Grade = f.lastTurnCommitted && f.uncommittedMine === 0 ? 'good' : 'ok'
88 return { chip: 'advise', grade, saving }
89}
90
91export function afterCompact(_cfg: GuardConfig, st: GuardState, r: { before: number; after?: number; now: number }): GuardState {
92 const next: GuardState = { ...st, lastCompactAt: r.now, requestsSinceCompact: 0 }
93 if (r.after === undefined) return next
94 next.floor = r.after
95 next.shrinkFailures = r.after > r.before * 0.8 ? st.shrinkFailures + 1 : 0
96 return next
97}
98
99export function countRequest(st: GuardState): GuardState {
100 return { ...st, requestsSinceCompact: st.requestsSinceCompact + 1 }
101}
102
103/** True when a shell command runs a git commit or push (not a dry run, not a mention in a string). */
104export function isCommitCall(command: string): boolean {
105 for (const seg of command.split(/&&|\|\||[;\n|]/)) {
106 const s = seg.trim()
107 if (/^git(?:\s+-C\s+\S+)?\s+(?:commit|push)(?:\s|$)/.test(s) && !/--dry-run\b/.test(s)) return true
108 }
109 return false
110}
111
112// ── the pilot log: one line per decision ──────────────────────────────────────
113
114export type DecisionLog = {
115 ts: number
116 /** Local time of day, HH:MM:SS. */
117 at: string
118 project: string
119 mode: Mode
120 ctx: number
121 window: number
122 chip: Chip
123 grade?: Grade
124 reason?: string
125 /** The compact buttons were on offer. */
126 advised: boolean
127 /** Dollars per later request that compacting now would have saved. */
128 saving: number
129 floor: number
130 jobs: number
131 mine: number
132 committed: boolean
133}
134
135/** A turn end that moved the context and left the guard something to say, and not the size already logged. */
136export function shouldLogDecision(o: { changedContext: boolean; chip: Chip; ctx: number; lastCtx?: number }): boolean {
137 return o.changedContext && o.chip !== 'none' && o.ctx !== o.lastCtx
138}
139
140const pad2 = (n: number) => String(n).padStart(2, '0')
141
142/** The local date, as the journal's day folders name it. */
143export function dayKey(ms: number): string {
144 const d = new Date(ms)
145 return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
146}
147
148/** guard/<day>/<session>.jsonl beside the board's own files: a sibling of the journal's log/, never inside it. */
149export function logPath(dir: string, ms: number, session: string): string {
150 return `${logDir(dir, ms)}/${session.replace(/[^A-Za-z0-9_-]/g, '_')}.jsonl`
151}
152
153/** The folder one day's files sit in: guard/<day>, every session's file of that day. */
154export function logDir(dir: string, ms: number): string {
155 return `${dir.replace(/[\\/]+$/, '')}/guard/${dayKey(ms)}`
156}
157
158/** One line, no newline at its end. The saving is rounded to four places; an empty grade or reason is left out. */
159export function formatDecision(d: DecisionLog): string {
160 return JSON.stringify({
161 ts: d.ts,
162 at: d.at,
163 project: d.project,
164 mode: d.mode,
165 ctx: d.ctx,
166 window: d.window,
167 chip: d.chip,
168 grade: d.grade,
169 reason: d.reason,
170 advised: d.advised,
171 saving: Math.round(d.saving * 10_000) / 10_000,
172 floor: d.floor,
173 jobs: d.jobs,
174 mine: d.mine,
175 committed: d.committed,
176 })
177}
178
179/** Day folders older than `keepDays`, from names that are dates and nothing else. */
180export function expiredDays(names: string[], now: number, keepDays: number): string[] {
181 const cutoff = now - keepDays * 86_400_000
182 return names.filter(n => {
183 if (!/^\d{4}-\d{2}-\d{2}$/.test(n)) return false
184 const at = Date.parse(`${n}T00:00:00`)
185 return Number.isFinite(at) && at < cutoff
186 })
187}
188
189/** What one day's log holds, for the Context tab's line. */
190export type DaySummary = { logged: number; advised: number; saving: number }
191
192/**
193 * Reads the text of the day's files, every session's. A decision is a line that parses to an object with a number
194 * for ctx; a blank line, a half-written one or one from another tool is skipped. The saving is summed over the
195 * advised decisions only, and rounded to four places. A `would_compact` field in an older line is ignored.
196 */
197export function summarizeDay(texts: string[]): DaySummary {
198 let logged = 0
199 let advised = 0
200 let saving = 0
201 for (const text of texts) {
202 for (const raw of text.split('\n')) {
203 const l = raw.trim()
204 if (l === '') continue
205 let rec: unknown
206 try {
207 rec = JSON.parse(l)
208 } catch {
209 continue
210 }
211 if (typeof rec !== 'object' || rec === null) continue
212 const d = rec as { ctx?: unknown; advised?: unknown; saving?: unknown }
213 if (typeof d.ctx !== 'number') continue
214 logged += 1
215 if (d.advised !== true) continue
216 advised += 1
217 if (typeof d.saving === 'number' && Number.isFinite(d.saving)) saving += d.saving
218 }
219 }
220 return { logged, advised, saving: Math.round(saving * 10_000) / 10_000 }
221}
222
223/** The line itself: decisions, how many had the buttons on offer, the saving per request summed over those. */
224export function formatDay(s: DaySummary): string {
225 if (s.logged === 0) return 'no decisions logged yet'
226 return `${s.logged} ${s.logged === 1 ? 'decision' : 'decisions'} · ${s.advised} advised · $${s.saving.toFixed(2)} per request, summed`
227}
228
229// ── what the summary is told ──────────────────────────────────────────────────
230
231export const DEFAULT_INSTRUCTIONS = `You are writing the notes the same assistant will use to carry on this work after its memory is cut. Write them for yourself, under 2,500 words, as these sections in this order.
232
2331. Rules still in force. Quote the user's own words for every "do not", "don't commit", "wait for my yes" and "Approved:" gate, and for every correction they gave in this session. If there are none, write "none".
234
2352. State. What is done, what is in flight, what is next, in order. For each job in flight: the command, its log file, and how to tell it has finished. Say which uncommitted files belong to this session and which to other sessions.
236
2373. Identifiers, copied exactly and never reworded: full file paths; script names with the flags last used; study and learning numbers (for example 0095); Civitai post and image ids; 6-character picture ids; task ids (T-…); commit hashes; ports and PIDs; model, checkpoint and LoRA file names; URLs the user pasted.
238
2394. Decisions and rejections, each with its reason in one line (for example "RedCraft 4.0 dropped: terrible output").
240
2415. Open questions the user has not answered, and anything promised but not yet shown.
242
2436. Verify first. Three to five facts to re-check before acting: a file exists, a job is alive, the branch is clean.
244
245Leave out: raw tool output, command logs, file contents (give the path instead), descriptions of images already acted on, abandoned drafts (keep only the latest), and your own reasoning.`
246
247export type InstructionFacts = {
248 ctx: number
249 branch?: string
250 lastCommit?: string
251 uncommitted: string[]
252 jobs: string[]
253 agents: string[]
254 assumptions: string[]
255}
256
257const LIST_CAP = 20
258
259/**
260 * The text for `session.compact`, or undefined to leave the compaction exactly as it was asked.
261 * A `/compact` you press Enter on (typed by hand, or filled by a button) is never changed: a bare one is a plain
262 * compact and the words after it are yours. Only the engine's own late compaction (and a precompute) gets the
263 * recipe, because nobody types those.
264 */
265export function compactInstructions(o: {
266 trigger: 'manual' | 'auto' | 'plugin' | 'precompute'
267 base: string
268 facts: InstructionFacts
269}): string | undefined {
270 if (o.trigger === 'manual') return undefined
271 const f = o.facts
272 const lines: string[] = ['Facts from the session board (trust these over memory):']
273 lines.push(`• Context before compaction: ${fmtK(f.ctx)}${f.branch ? ` · branch ${f.branch}` : ''}${f.lastCommit ? ` · last commit ${f.lastCommit}` : ''}`)
274 const list = (title: string, items: string[]) => {
275 if (items.length === 0) {
276 lines.push(`• ${title}: none`)
277 return
278 }
279 lines.push(`• ${title}:`)
280 for (const x of items.slice(0, LIST_CAP)) lines.push(` - ${x}`)
281 if (items.length > LIST_CAP) lines.push(` … and ${items.length - LIST_CAP} more`)
282 }
283 list('Uncommitted files from this session', f.uncommitted)
284 list('Background jobs', f.jobs)
285 lines.push(`• Subagents running: ${f.agents.length > 0 ? f.agents.join(', ') : 'none'}`)
286 lines.push(`• Assumptions still open: ${f.assumptions.length > 0 ? f.assumptions.join(', ') : 'none'}`)
287 return `${o.base}\n\n${lines.join('\n')}`
288}
289
290// ── the picker: the /compact command the mod types for you ─────────────────────
291// The mod only types this into the prompt box. You read it, change it if you like, and press Enter.
292
293export type PickId = 'notes' | 'lean' | 'tasks' | 'files' | 'jobs' | 'assumptions'
294export type Picks = Record<PickId, boolean>
295
296/** What the board offers to keep. The numbers of assumptions come from the assumptions mod, which shares counts only. */
297export type PickFacts = { tasks: string[]; files: string[]; jobs: string[]; assumptions: number; high: number }
298
299/** The rows of the picker, in the order their sentences appear in the command. */
300export const PICK_ORDER: PickId[] = ['notes', 'lean', 'tasks', 'files', 'jobs', 'assumptions']
301
302/** The first two start ticked; the rest start off. */
303export const DEFAULT_PICKS: Picks = { notes: true, lean: true, tasks: false, files: false, jobs: false, assumptions: false }
304
305export const NOTES_SENTENCE =
306 'Write the notes in six parts: rules I gave (quote my words), state, identifiers copied exactly, decisions with reasons, open questions, facts to verify first.'
307export const LEAN_SENTENCE = 'Leave out raw tool output and logs.'
308
309const TASK_CAP = 8
310const LIST_CAP_PICK = 20
311const TASK_CHARS = 80
312const ITEM_CHARS = 120
313
314/** One line: control characters and line breaks become a space, runs of spaces fold, and a long item is cut. */
315export function cleanItem(s: string, max: number): string {
316 // eslint-disable-next-line no-control-regex
317 const one = s.replace(/[\u0000-\u001f\u007f\u0085\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()
318 return one.length <= max ? one : `${one.slice(0, max - 1).trimEnd()}…`
319}
320
321/** "a, b and 3 more": the first `cap` items, then how many were left out. */
322function listText(items: string[], cap: number, max: number, sep: string): string {
323 const shown = items.slice(0, cap).map(x => cleanItem(x, max)).filter(x => x !== '')
324 const more = items.length - cap
325 return `${shown.join(sep)}${more > 0 ? ` and ${more} more` : ''}`
326}
327
328/** How many things a row would keep; a row with none cannot be ticked. */
329export function pickCount(id: PickId, facts: PickFacts): number {
330 switch (id) {
331 case 'tasks':
332 return facts.tasks.length
333 case 'files':
334 return facts.files.length
335 case 'jobs':
336 return facts.jobs.length
337 case 'assumptions':
338 return facts.assumptions
339 default:
340 return 1
341 }
342}
343
344function sentenceFor(id: PickId, facts: PickFacts): string {
345 if (pickCount(id, facts) === 0) return ''
346 // A list whose items all came out empty (a line break and nothing else) says nothing, so it adds no sentence.
347 const withList = (lead: string, text: string): string => (text === '' ? '' : `${lead}${text}.`)
348 switch (id) {
349 case 'notes':
350 return NOTES_SENTENCE
351 case 'lean':
352 return LEAN_SENTENCE
353 case 'tasks':
354 return withList('Keep in full detail: the open items of the task list: ', listText(facts.tasks, TASK_CAP, TASK_CHARS, '; '))
355 case 'files':
356 return withList('Keep these uncommitted files by path: ', listText(facts.files, LIST_CAP_PICK, ITEM_CHARS, ', '))
357 case 'jobs':
358 return withList('Jobs still running: ', listText(facts.jobs, LIST_CAP_PICK, ITEM_CHARS, '; '))
359 case 'assumptions':
360 return `Assumptions still open: ${facts.assumptions}${facts.high > 0 ? ` (${facts.high} high impact)` : ''}.`
361 }
362}
363
364/**
365 * The command the picker types. Nothing ticked (or only rows with nothing to keep) is a bare `/compact`.
366 * It is one line: no line breaks or control characters, however odd a path or a task subject is.
367 */
368export function buildCompactCommand(picks: Picks, facts: PickFacts): string {
369 const sentences = PICK_ORDER.filter(id => picks[id]).map(id => sentenceFor(id, facts)).filter(s => s !== '')
370 return sentences.length === 0 ? '/compact' : `/compact ${sentences.join(' ')}`
371}
372
373/** The buttons that type may replace the box only when it is empty or still holds what they typed last. */
374export function boxIsFree(box: string, lastFilled: string | undefined): boolean {
375 return box.trim() === '' || (lastFilled !== undefined && box === lastFilled)
376}
377
378// ── recall: how much of what mattered the new summary kept ────────────────────
379
380const FILE_RX = /[\w.\-]+\.(?:py|md|json|html|mjs|js|ts|tsx|sh|ps1|yaml|yml|png|jpg|safetensors|log|txt|csv)\b/gi
381const PORT_RX = /:(\d{4,5})\b/g
382const POST_RX = /\b\d{8,9}\b/g
383const STUDY_RX = /\b0\d{3}\b/g
384const HASH_RX = /\b[0-9a-f]{8}\b/g
385
386function identifiers(text: string): Set<string> {
387 const out = new Set<string>()
388 for (const m of text.matchAll(FILE_RX)) out.add(m[0].toLowerCase())
389 for (const m of text.matchAll(PORT_RX)) out.add(m[1]!)
390 for (const m of text.matchAll(POST_RX)) out.add(m[0])
391 for (const m of text.matchAll(STUDY_RX)) out.add(m[0])
392 for (const m of text.toLowerCase().matchAll(HASH_RX)) if (/\d/.test(m[0]) && /[a-f]/.test(m[0])) out.add(m[0])
393 return out
394}
395
396/** Of the file names, ports, post ids, study numbers and hashes in `before`, how many appear in `summary`. */
397export function recall(before: string, summary: string): { found: number; total: number; missing: string[] } {
398 const ids = identifiers(before)
399 const s = summary.toLowerCase()
400 const missing = [...ids].filter(id => !s.includes(id))
401 return { found: ids.size - missing.length, total: ids.size, missing }
402}
403
404// ── small formatters ──────────────────────────────────────────────────────────
405
406export function fmtK(tokens: number): string {
407 if (tokens >= 999_500) return `${(tokens / 1_000_000).toFixed(1)}M`
408 return `${Math.round(tokens / 1000)}K`
409}
410
411const GLYPHS = '▁▂▃▄▅▆▇█'
412
413/** One glyph per turn by size; a bar before a turn whose context fell to under 60% of the one before (a compaction). */
414export function sparkWithMarks(values: number[], max: number): string {
415 let out = ''
416 values.forEach((v, i) => {
417 if (i > 0 && v < values[i - 1]! * 0.6) out += '│'
418 const idx = max > 0 ? Math.min(7, Math.max(0, Math.round((v / max) * 7))) : 0
419 out += GLYPHS[idx]
420 })
421 return out
422}
423
424// ── config ────────────────────────────────────────────────────────────────────
425
426function num(v: unknown): number {
427 if (typeof v === 'number') return v
428 if (typeof v === 'string' && v.trim() !== '') return Number(v)
429 return Number.NaN
430}
431
432/** The mode a stored or configured value stands for. The old `auto` (removed in 0.19.0) reads as `advise`. */
433export function parseMode(v: unknown): Mode {
434 return v === 'off' ? 'off' : 'advise'
435}
436
437/** The plugin's userConfig values, checked; thresholds arrive in thousands of tokens. Settings that no longer exist are ignored. */
438export function parseConfig(o: Record<string, unknown>): GuardConfig {
439 const adviseK = num(o.adviseAtK)
440 const adviseAt = Number.isFinite(adviseK) && adviseK >= 100 ? Math.round(adviseK * 1000) : DEFAULTS.adviseAt
441 return { ...DEFAULTS, mode: parseMode(o.mode), adviseAt }
442}
443types/index.d.ts 218 lines1/**
2 * open: started (or resumed), nothing asked in it since (never counted)
3 * working: a main-loop turn is running
4 * needs-you: a permission dialog or a question box is open (a fact from classic.PermissionRequest
5 * with no hook deciding it, or an AskUserQuestion call in flight)
6 * background: the turn ended, but the session holds background work in flight (shells, subagents,
7 * monitors, a one-shot wake-up) that will start its next turn by itself. Not your turn.
8 * replied: the turn ended with an answer and nothing is in flight, your turn
9 * stopped: interrupted or an error
10 * gone: no heartbeat (closed, crashed or asleep) · closed: the session ended
11 */
12export type BoardStatus = 'open' | 'working' | 'needs-you' | 'background' | 'replied' | 'stopped' | 'gone' | 'closed'
13
14/** What kind of background work a task is: a background shell is a job. */
15export type TaskKind = 'job' | 'agent' | 'monitor' | 'wake-up' | 'task'
16
17/** One piece of background work on a card: the engine's own id (its report names it), and when it started. `isExact`
18 * is false where the start is only when a Stop first listed it, so it may have started earlier ("≥"). */
19export type CardTask = { id: string; label: string; what: string; kind: TaskKind; since: number; isExact: boolean }
20
21/** One row of the panel's BACKGROUND section, oldest first. */
22export type BgRow = { id: string; kind: TaskKind; what: string; since: number; isExact: boolean }
23
24/** One session's card, as each session writes it to the shared folder. */
25export type BoardEntry = {
26 /** Card format; 2 adds `background` and `tasks`, 3 adds `progress`, 4 adds `bg`. */
27 v?: number
28 id: string
29 /** The project folder, full path. */
30 folder: string
31 /** The project folder's name. */
32 project: string
33 /** Your latest prompt in that session, shortened. */
34 asked: string
35 status: BoardStatus
36 /** What it is doing now, or what it needs from you. Facts from tool calls only. */
37 activity?: string
38 /** The background work in flight when the last turn ended, one short label each. */
39 tasks?: string[]
40 /** The background work in flight now, in any state, with ids and start times: the panel shows how long each has run,
41 * and a reload restores them from it. Older versions ignore it. */
42 bg?: CardTask[]
43 /** The session's task list: items done of all items (Claude's own task list). */
44 progress?: { done: number; total: number }
45 /** Epoch ms of the last status change. */
46 changedAt: number
47 /** Epoch ms of the last heartbeat. */
48 seenAt: number
49}
50
51/** One subagent of this session, from its spawn, its steps and its tool calls. */
52export type AgentRow = {
53 id: string
54 /** The agent type (`Explore`, `general-purpose`, a plugin's agent). */
55 type: string
56 /** The Agent call's short description of the task. */
57 description: string
58 /** The model it runs on: resolved at spawn, then as each of its steps names it. */
59 model?: string
60 status: 'running' | 'done' | 'failed' | 'stopped'
61 /** Spawned to run in the background (the turn does not wait for it). */
62 background?: boolean
63 /** What it is doing now, from its own latest tool call. */
64 activity?: string
65 startedAt: number
66 endedAt?: number
67}
68
69export type FileKind = 'added' | 'modified' | 'deleted' | 'renamed'
70
71/** One changed file, as the files view lists it. */
72export type FileRow = {
73 key: string
74 /** Relative to the session's folder where inside it. */
75 path: string
76 kind: FileKind
77 /** Lines added and removed, where known. */
78 plus?: number
79 minus?: number
80 /** A rename's old path. */
81 from?: string
82 /** Where the fact comes from: Claude's own Write/Edit calls, or git status. */
83 by: 'claude' | 'git'
84}
85
86export type ToolKind = 'read' | 'edit' | 'shell' | 'test' | 'other'
87
88/** One turn of this session, as the Turns tab shows it. */
89export type TurnRecord = {
90 id: string
91 startedAt: number
92 endedAt?: number
93 status: 'running' | 'answered' | 'stopped' | 'error'
94 model?: string
95 /** Claude's own one-line summary ("Turn: …"), and what it asks you to verify ("· check: …"). */
96 summary?: string
97 check?: string
98 files: { added: number; modified: number; deleted: number; renamed: number; top: FileRow[] }
99 tools: Partial<Record<ToolKind, number>>
100 toolTotal: number
101 agents: { id: string; type: string; model?: string; status: AgentRow['status']; startedAt: number; endedAt?: number }[]
102 assumptions: { text: string; impact?: 'high' | 'low' }[]
103 /** True once its line is in the journal log (journal setting on). */
104 logged?: boolean
105}
106
107/** One compaction the guard knows of, newest first in the Context tab. */
108export type GuardCompaction = {
109 at: number
110 before: number
111 after?: number
112 /** Who compacted, as the engine names the trigger: manual (you pressed Enter on a /compact), auto (the engine at its limit) or plugin. */
113 via: string
114 /** How many of the file names, ports, post ids, study numbers and hashes the old context held the new summary kept. */
115 recall?: { found: number; total: number; missing: string[] }
116}
117
118/** The context guard, as the strip's chip and the Context tab draw it. Chip and decision rules: hooks/guard.ts. */
119export type GuardView = {
120 chip: 'none' | 'dim' | 'advise' | 'stopped'
121 grade?: 'good' | 'ok'
122 /** Why the chip is dim or stopped: growth, cooldown, no-shrink. */
123 reason?: string
124 /** Dollars saved per later request if it compacted now. */
125 saving: number
126 /** Tokens in the context at the last measure. */
127 ctx: number
128 window: number
129 mode: 'off' | 'advise'
130 phase: 'idle' | 'compacting' | 'done' | 'failed'
131 done?: { before: number; after: number; saving: number; recall?: number }
132 failed?: string
133 jobs: number
134 /** The context at each turn end (the last 60), for the sparkline. */
135 series: number[]
136 history: GuardCompaction[]
137 adviseAt: number
138 floor: number
139 /** True when ~/.claude/session-board/compact-instructions.md holds your own text. */
140 hasFile: boolean
141 /** Today's pilot log, every session's files: decisions logged, how many had the buttons on offer, the saving per request summed over those. Undefined until read. */
142 today?: { logged: number; advised: number; saving: number }
143 /** The picker in the Context tab is open. */
144 pickOpen: boolean
145 /** Which picker rows are ticked. */
146 picks: Record<'notes' | 'lean' | 'tasks' | 'files' | 'jobs' | 'assumptions', boolean>
147 /** What the board offers to keep: open items of the session's task list, its uncommitted files, running jobs, open assumptions (a count). */
148 facts: { tasks: string[]; files: string[]; jobs: string[]; assumptions: number; high: number }
149 /** The command the picker would type into the prompt box. */
150 command: string
151 /** The prompt box holds words of yours, so the Actions menu is drawn off. */
152 boxHolds: boolean
153 /** The Actions menu under the chip is open: Compact…, Plain compact and Clear, each of which only types. */
154 menuOpen: boolean
155}
156
157declare module 'claude-code' {
158 interface PluginState {
159 'session-board': {
160 board: BoardEntry[]
161 now: number
162 /** The strip above the prompt: other sessions that need you or replied, one entry per folder. */
163 alerts: {
164 needs: { name: string; n: number }[]
165 replied: { name: string; n: number }[]
166 /** Other live sessions that are not alerts: counts only, for the strip's tail. */
167 quiet?: { working: number; waiting: number; idle: number }
168 }
169 /** This session's own state, for the strip's pill. */
170 self: { status: string; text: string } | null
171 /** Whether the panel lists ended sessions too. */
172 showEnded: boolean
173 /** This session's live detail: model, task progress, subagents, files changed this turn. */
174 live: {
175 isWorking: boolean
176 model?: string
177 progress: { done: number; total: number; now?: string } | null
178 /** Whether the bar is worth showing: items left, or the list changed this turn. */
179 showProgress: boolean
180 agents: AgentRow[]
181 turnFiles: number
182 /** This session's background work still running, while a turn runs too: the count the "Waiting on …" label names. */
183 bg: number
184 /** The same, by kind, for the ◈ chip: background jobs (shells), agents, monitors, wake-ups, other tasks. */
185 bgKinds: Partial<Record<TaskKind, number>>
186 /** The same, one row each with its start, oldest first: the panel's BACKGROUND section and the chip's time. */
187 bgRows: BgRow[]
188 }
189 /** Turns logged today across all sessions; null when the journal setting is off. */
190 journal: { today: number } | null
191 /** This session's turns, newest first (the last 20). */
192 turns: TurnRecord[]
193 /** The turn whose full card is open in the Turns tab's history. */
194 expanded: string | null
195 /** Assumptions still open in this session, as the assumptions mod reports them. */
196 review: { open: number; high: number }
197 /** The recap row after a turn: shown when it flagged a check or assumptions wait for review. */
198 recap: { turnId: string } | null
199 /** The context guard: the chip above the prompt and the Context tab. */
200 guard: GuardView
201 /** The panel's tab, and the files view. */
202 files: {
203 view: 'board' | 'turns' | 'files' | 'context'
204 scope: 'turn' | 'session'
205 list: FileRow[]
206 /** null: not a git folder (only Claude's own edits are listed); undefined: not checked yet. */
207 isGit?: boolean | null
208 checkedAt?: number
209 isLoading: boolean
210 /** Folders whose fold state the person flipped from its default. */
211 toggled?: string[]
212 /** The file whose diff is shown, and its lines. */
213 open: { key: string; lines: string[]; more: number; note?: string } | null
214 }
215 }
216 }
217}
218