SLOPSHOPPER

session-board

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…

newpanebandguardcommandtoast
v0.29.1MITupdated 2026-10-19richardmuucyi/rmahwj-mods/session-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-board
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ sb-tab-board sb-tab-turns sb-tab-files sb-ta │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ THIS SESSION ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ sb-files-open ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ THIS FOLDER app · 0 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ OTHER FOLDERS │ ┃ No sessions in other folders. ✻ Worked for 42s · done 4:20 PM │ │ › /sessions │ ⎿ session-board: Sessions panel opened. Esc closes it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
sb-tab-board sb-tab-turns sb-tab-files sb-tab-context THIS SESSION sb-files-open THIS FOLDER app · 0 OTHER FOLDERS No sessions in other folders.
README

session-board

A strip above the prompt that shows what needs you, across every Claude Code session on the machine.

  • Other sessions that need you or are done.
  • This session: its task bar, the models its subagents use, and the files it touches while it works.
  • A recap when Claude flags something for you to check, or when assumptions are waiting (see 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.

Context guard

The guard watches the size of the context.

  • From the advise line (default 300K tokens), a chip shows the context size, some advice and a Compact now button. It also needs the context to be 150K above its size after the last compaction.
  • In 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.
  • Compaction notes tell every compaction to keep paths, ids and rules.
  • Log: from 200K, each turn end adds one line (context, grade, whether it would have compacted, the saving) to ~/.claude/session-board/guard/. The Context tab sums up today's lines.

A compaction costs what any compaction costs.

Settings

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.

SettingChoicesDefault
Context guard modeadvise (chip and button only) · auto (also counts down and compacts) · offadvise
Advise from (thousand tokens)200 · 250 · 300 · 400300
Auto from (thousand tokens)400 · 500 · 600 · 700500
Countdown seconds5 · 10 · 15 · 30 · 60 · 120 · 300 · 60010
Journal logon · offoff

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.

Install

/plugin marketplace add richardmuucyi/rmahwj-mods
/plugin install session-board@rmahwj-mods
/reload-plugins

Tests

claude plugin test session-board
Source 5 files
hooks/register.tsx 2823 lines
1import { 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 lines
1// 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}
783
hooks/shared.ts 50 lines
1// 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 }
50
hooks/guard.ts 443 lines
1// 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}
443
types/index.d.ts 218 lines
1/**
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