A live agent dashboard built on Flightdeck: main model vitals (with Fable weekly usage), the architect, every permission check, subagent cards and swimlanes…

English · 繁體中文
A Claude Code mod that draws a live mission-control pane beside the transcript (or above the prompt). It shows only real session events and readings: no made-up numbers.
Built on Flightdeck (Stephen Casella, MIT). The layouts, panels, interactions and English labels come from Flightdeck. cc-dashboard adds:
↻.codex exec runs shown as subagent cards (no Codex session id).| Panel | Shows | From | | :- | :- | :- | | Waiting on you (red line under the header) | Only when another running Claude Code session is waiting on you: ! waiting on you: <session name> <permission/question> <time, e.g. 3m ago>, with · +<n> more when several (longest wait first). Absent, and taking no row, when nobody waits | See "Waiting on you" below | | Main | Model, effort, permission mode, request count. Claude's first two usage windows (5h, 7d) and Fable weekly usage get a row each: a stretched ━─ bar, percent used, and the time to reset (⏱). Past its reset a window counts as restarted and reads ~0% and stale until a new reading arrives; a failed Fable refresh labels it Fable~. Bars in one box share a width and line up. Right under the box is an orange flow line as wide as the box: packets run while the main loop works, a still dim line otherwise; two boxes to a row put it under the main box's side only; motion: off keeps it still. The Context meter (█░ bar, percent used, 116k/200k, compactions ⟲) is not in the box: it is a frameless line of its own under the main box (or the pair) and the orange line, about half the column wide and left-aligned (the left column in the wide layout, the whole pane otherwise), with a blank row below it. The filled bar and the percent share one colour: green under 50%, yellow from 50%, red from 80% (following the chosen palette); the track is faint. Not drawn when panels has no main, nor while no context reading exists | turn.step, session.measure, session.compact, $.session.usage(); Fable below | | Codex | Codex · <model> · <effort>, then 5h and weekly windows, a row each: a percent-used bar and the time to reset. A window past its reset counts as restarted (Codex only opens a new window at its first request after the reset) and reads ~0% with stale until a new reading arrives. When no figures can be read, the reason shows (e.g. no usage recorded yet). The ↻ button at the right of the title (hotkey r) asks the server for fresh figures only when pressed, and says how long ago it last did (↻ 3m ago) | See "Codex usage" below | | Branch | 🔀 <branch>, ↔ <upstream> ↑ahead ↓behind (no upstream without one), and unreviewed N files · changed M files. All reviewed reads ✓ unreviewed 0; when it cannot be known it reads unreviewed ?, never 0. With unreviewed files, u unfolds the list (up to 8 rows, the rest counted as … N more) | See "Branch" below | | Architect | A timeline of consults, whether one is running, how long the last took; the inferred moment of each (before a plan / error repeats / before done, marked (inferred)); the first line of a subagent architect's advice | An agent type that matches the pattern, or a matching server tool in the assistant's rows | | Gate | One cell per permission check: green allowed by a rule, blue decided by the classifier or by you and then run, amber pending, red ✗ denied; checks made inside a subagent are dimmer. Totals, and a drill-down per tool family (f file / s shell / o other) of the last 5 checks, credentials masked | tool.check, settled by the tool.call around it | | Agents | Cards side by side while they fit: task, type, model (left out when it matches the main loop's) and effort (high; a numeric effort as the number; none, none shown), ctx and output tokens, steps, a running clock, max_tokens in red. Beyond that, swimlanes on one time axis. 1–6 expand: the full task, model and effort, the last 3 tool calls, the start of the answer. A codex exec is a card too: type Codex, the model and effort the Codex panel read from its config, the task its purpose (Codex plan draft / Codex review / Codex resume / Codex, with the call's description), counted as running; no Codex session id is shown | agent.spawn, turn.step, tool.call, turn.complete; Codex below | | Other loops | Model loops that match no card: workflow agents, compactions, memory forks | turn.step ids no card claims | | Receipt | The running turn, or the last one: duration, agents, edits, errors | turn.start, turn.complete | | Log | Prompts, spawns, completions, consults, edits, errors and denials; filtered to one agent while you view its transcript | All of the above |
A panel with nothing to show takes no room: no subagents, no agents panel; outside a git repository, no branch panel; no readable Codex, no Codex panel.
Layouts.
auto (a pane under 110 columns): from 70 columns (about a 180-column terminal at a 40% dock) two pairs share a row: main | Codex, then branch | gate (the gate moves above the architect). After them come architect, agents, other loops, receipt, log. The two boxes of a pair are the same height; the shorter one stretches. If one of the two has nothing to show, or panels leaves them apart, the other fills its row as before. compact never pairs, and below 70 columns the stacked layout is unchanged. Inside a pair the wording narrows: the main box's effort, mode and request count fold into ▮▮▮▯ high · auto · 12 req; the Codex box drops quota · used; the branch box puts the sync state and each count on a row of their own; the gate's summary and buttons wrap, so ✗ N denied is never cut.5h 2% · 7d 3% (from the API response, available after the session's first reply); the red line is on the second; Fable, Codex and the branch share one row: Fable 31% Codex 5h 13% · weekly 40% 🔀 main unreviewed 2 files · changed 5 files. While those rows show, fewer agent rows are listed so the total stays within 8.After /tui fullscreen (or "tui": "fullscreen" in settings.json, effective at the next start) the pane docks beside the transcript at dockPercent% of the terminal's width (default 40%, less the frame, at least 40 columns) and follows the terminal within about 2 seconds of a resize. The terminal's width is known only from a drawing of the terminal's own pane or from running /dashboard, so at first the dock keeps the engine's default width and takes its share after the first drawing; a Desktop or phone attached to the session does not affect it. A width you dragged the dock to wins (an engine rule). The fullscreen layout docks from 110 columns; opening on start (when nobody asked) needs 144, and below that /dashboard opens it.
claude --plugin-dir N:\projects\github\cc-dashboard
| Command | Does | | :- | :- | | /dashboard | open the pane | | /dashboard close | close it | | /dashboard reset | clear agents, checks, consults, log and the turn (usage, compactions, Codex and branch stay) | | /dashboard refresh | ask the server for Codex usage (same as ↻), and read the branch and the other sessions' waits again now (whether or not the pane shows; Fable keeps its own schedule) | | /dashboard layout auto\|compact\|wide\|mini | override the layout for this session |
With the pane focused (ctrl+x tab): 1–6 expand an agent card or lane, f s o open the gate's drill-down, u unfolds the unreviewed files, r asks the server for Codex usage. /clear resets the pane along with the conversation (Codex and branch are untouched).
Read-only, display-only. It needs USERPROFILE and SystemRoot (Windows); without either nothing is read and the Codex panel does not appear.
codex key of ~/.claude/model-choice.json (the rules' default, gpt-6-sol, without one); the effort is model_reasoning_effort in ~/.codex/config.toml.↻: pressing ↻ on the Codex box's title (hotkey r with the pane focused, or /dashboard refresh), if ~/.codex/auth.json holds a ChatGPT login (tokens.access_token, and tokens.account_id), sends GET https://chatgpt.com/backend-api/wham/usage. That is the call Codex's own /status makes, with the headers Codex sends (Authorization: Bearer …, ChatGPT-Account-Id, User-Agent: codex-cli). It answers the account's server-side 5-hour and weekly percent used and reset times and spends none of the quota; usage from another computer or Codex on the web counts, so the figures are current even if Codex was not called. The token is used for that one request only: never kept in state, never drawn, never written back to auth.json (when it has expired it waits for Codex's next run to refresh it, and until then the call is refused and the rollouts are read instead). Only the characters a JWT or an id holds are accepted, so nothing extra can be smuggled into a header. This is an undocumented private API: an answer of another shape, a refusal, no network, or an API-key login (no ChatGPT token) all fall back to the rollouts below.↻ and the server does not answer): from the newest 14 day folders of ~/.codex/sessions/YYYY/MM/DD (no older folder once 200 files are listed; a folder that cannot be listed counts as empty), each candidate is ranked by its own last-modified time (asked of the file system one by one, not the listing's time, which can be stale; where that fails the listing's time stands). The 2 most recently modified rollouts are read (a long-lived older session keeps writing to its original file; the next 2, up to 4 in all, only when neither holds a codex reading), and the reading whose event timestamp is latest wins (a tie goes to the more recently modified; a reading with no parseable time counts as oldest). One command per rollout (usually 2 per refresh, at most 4), through %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe (a full path, not PATH), takes the lines containing "limit_id":"codex" from the last 2000 lines of the file. The rollout's path rides in the environment variable ROLLOUT, never in the command text. The 5-hour and weekly windows are told apart by their length.↻ an older rollout does not overwrite it, and once you use Codex here the newer rollout takes over. The ↻ button says how long ago the server last answered (↻ 3m ago). A new reading that lacks a window keeps the old one. The rollouts are read every 5 minutes, only while the pane shows.GET https://api.anthropic.com/api/oauth/usage, authorised by the engine's own handle (the credential never reaches this plugin; only a bearer login can use the endpoint, and without one nothing is asked or shown). It takes the limits[] entry whose kind is weekly_scoped and whose model name contains Fable. Every 90 seconds; after a failure the wait doubles up to 10 minutes (or follows a longer retry-after), and the last figure stays with the label Fable~. Once the window's reset time has passed it reads ~0%.
~/.claude/sessions/<pid>.json (only the newest 32 plain files with numeric names, 64 KB or smaller), plus the process table, %SystemRoot%\System32\tasklist.exe /FO CSV /NH (a full path, 15-second timeout); only sessions whose pid still runs as claude or node count. Then their mark files, ~/.claude/mods-state/status-sidebar/waiting/<pid>.json, whose sessionId must equal the registry's (a stale file from a reused pid does not count). Marks are read about every 5 seconds, the registry and tasklist about every 30. Whenever a step cannot be read, nothing is shown: nothing is guessed.{"sessionId":…,"reason":"permission"|"question"|null,"sinceMs":…}, so sessions running either mod see each other. classic.PermissionRequest writes permission; AskUserQuestion / ExitPlanMode write question when they start. It clears when that call returns, when you type a new prompt, when the main turn ends, and when the session ends. Driven by events, so it is written whether or not the pane shows.codex exec / codex exec resume that the main conversation runs through Bash or PowerShell (a subagent's calls do not count). It counts only in command position: a codex exec inside quotes, a comment, a heredoc body or a PowerShell here-string is not a run (masking and matching are a single pass over the first 64 KB). The purpose comes from the command: resume is Codex resume, --sandbox read-only is Codex plan draft, workspace-write is Codex review, anything else is Codex; the card's model is the one the Codex panel read.
isError or a refusal is a failure.backgroundTaskId) stays running until its task-notification ends it (only the task-id, tool-use-id and status before <summary> are read; failed / killed or a non-zero exit code is a failure). A notification never makes this mod open a file.classic.Stop it is checked against the engine's own list: a foreground run still open is inferred failed, a background run the list no longer holds is inferred done.Read-only git, through $.process.run (15-second timeout). Every call carries --no-optional-locks -c core.quotePath=false -c core.fsmonitor=false -c protocol.ext.allow=never and the environment variable GIT_NO_LAZY_FETCH=1: rev-parse --show-toplevel; config (lists the filter drivers, then switches every one off for status and hash-object; a partial clone is not read at all; if they cannot be listed nothing further is read); status --ignore-submodules=all --porcelain=v2 --branch -z -uall; and hash-object --no-filters --stdin-paths (no -w, so no object is written). A symbolic link, or a path that lands outside the repository, is not hashed and always counts as unreviewed. Output the engine cut short is a failure. Every 30 seconds, and once right after a main turn ends.
$.store, key git-reviewed:<repository root>, value "path → content hash"; only the 8 most recently written repositories are kept, 2000 paths each (past that it stores too-many-paths and the panel reads unreviewed ?). It is written only when: (1) a Skill call whose skill name contains review; (2) an Agent call whose subagent_type contains review (for both, the tree is read as the call begins and kept only if the call is not refused and does not fail); (3) a /…review slash command that you type yourself (composer / bridge / sdk origin), the tree read and recorded as it is sent.Fable, Codex, git and the other sessions' waits are polled only while the pane shows (a pump looks every 2 seconds at which is due). A closed pane, or one in a background tab, sends no request and runs no process. The exception: to learn the file name of its own mark, the session reads the registry and tasklist once at start (or when a mark is to be written and the pid is not known yet).
before a plan = no edit yet this turn, error repeats = 2 or more main-loop errors in a row, before done = edits were made. Marked (inferred); moments: false turns them off.advisor) is encrypted: it can be counted and timed, not read.In /config, or under pluginConfigs["cc-dashboard"].options in settings.json:
| Option | Default | Meaning | | :- | :- | :- | | architectPattern | advisor\|architect | case-insensitive regex for agent types and server tools that count as the architect | | matchDescriptions | false | also match agent descriptions | | architectLabel | ARCHITECT | the architect's name in the pane | | gateLabel | GATE | the permission panel's name | | panels | main,codex,git,architect,gate,agents,loops,receipt,log | which panels show, in order | | layout | auto | mini, compact, wide, or auto | | dockPercent | 40 | share of the terminal's width the docked pane takes in fullscreen (20–80) | | maxCards | 3 | agent cards side by side before swimlanes (1–6) | | motion | while-active | off keeps the connectors and the main line still | | moments | true | show the inferred consult moments | | palette | theme | pastel uses fixed colours tuned for dark terminals | | openOnStart | true | open the pane when a session starts | | statusLine | true | context, agents, consults and denials in the status line |
| File | Holds | | :- | :- | | hooks/register.tsx | the event hooks, state access, the Codex and git I/O and polling, and the drawing of every panel (everything that touches $ is here) | | hooks/core.ts | every reducer, formatter and layout rule as pure functions, including the Codex windows and the branch text | | hooks/rail.tsx, hooks/elapsed.tsx | surface modules: animated connectors (the subagents', and the main model's orange line) and live clocks | | hooks/lib/codex-usage.ts | the ChatGPT login in Codex's auth.json, the usage call's headers and its answer's parsing | | hooks/lib/codex-quota.ts | parsing and walking Codex's rollouts, config and model choice (takes its reads as functions, never touches $) | | hooks/lib/git.ts, git-tree.ts, git-filters.ts | git status parsing, the read-only git commands, switching filters off, the unreviewed count | | hooks/lib/review-store.ts | reading and writing the review record, and its caps | | hooks/lib/fable.ts | parsing Fable's answer and the back-off | | hooks/lib/sessions.ts, waiting.ts, serial.ts | registry / tasklist parsing, the wait mark's format and the banner's text, a queue that runs writes one after another | | hooks/lib/codex.ts, shell-mask.ts, notify.ts | recognising codex exec and its purpose, masking what is not code in a shell command, background-task notifications | | hooks/lib/plain.ts, system.ts | making outside strings plain and "how long ago" (3m ago), full paths of tasklist / PowerShell | | types/index.d.ts | the state's types | | tests/ | Drawing and hooks: dashboard.test.ts (Flightdeck's own panels, the main flow line), dashboard-extras.test.ts (Codex usage and ↻, branch, review, Fable, waiting on you, Codex cards, the summary rows, polling), pairs-pane.test.ts / pairs.test.ts (the paired layout), dock.test.ts (dock width), context-line.test.ts, palette.test.ts; the rest are unit tests of each lib (codex*, git*, review-store, fable, notify, sessions, waiting, shell-mask, serial, plain, system) |
claude plugin test
claude plugin validate .
npx -p typescript tsc -p . # after a first interactive load with --plugin-dir, which writes .claude-plugin/types/
MIT, see LICENSE. Flightdeck © Stephen Casella.
hooks/register.tsx 1813 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentCard, Architect, Bucket, Check, CodexQuota, Gate, GitSnap, Layout, LogLine, Loop, Main, Roster, Turn, Usage, View, WaitReason, WaitingRow } from '../types'
5import {
6 DEFAULT_ARCHITECT,
7 DEFAULT_GATE,
8 DEFAULT_MAIN,
9 DEFAULT_ROSTER,
10 DEFAULT_TURN,
11 DEFAULT_USAGE,
12 DEFAULT_VIEW,
13 EDIT_TOOLS,
14 SCHEMA_VERSION,
15 afterCall,
16 applyStep,
17 cardKind,
18 bucketOf,
19 cardTitle,
20 titleLines,
21 consultTimeline,
22 describeInput,
23 endConsult,
24 fitLegend,
25 fmtClock,
26 fmtDuration,
27 fmtTimer,
28 plural,
29 windowShort,
30 windowMeter,
31 meterPercent,
32 meterWidth,
33 lineMeterWidth,
34 padCells,
35 LINE_GLYPHS,
36 BLOCK_GLYPHS,
37 CTX_LINE_SHARE,
38 ctxTone,
39 limitWindow,
40 claudeWindows,
41 DOCK_MIN_COLUMNS,
42 dockColumns,
43 terminalWidth,
44 GIT_PATH_ROWS,
45 unreviewedLabel,
46 changedLabel,
47 syncLabel,
48 canUnfold,
49 cells,
50 pathLines,
51 pathRows,
52 codexCard,
53 codexCardFor,
54 endCard,
55 stopCodex,
56 gateSummary,
57 gauge,
58 isAdvising,
59 isLoopActive,
60 kTokens,
61 lanes,
62 limitLabel,
63 limitOf,
64 listOf,
65 logRows,
66 momentOf,
67 narrowEffort,
68 normalize,
69 normalizeCard,
70 normalizeGate,
71 normalizeLog,
72 noteTool,
73 PALETTES,
74 PANEL_FRAME,
75 pairRows,
76 pairWidths,
77 withCtx,
78 SVG_COLORS,
79 parseConfig,
80 prettyModel,
81 promptLine,
82 handbackOf,
83 adviceLine,
84 receiptOf,
85 recordCheck,
86 settleCheck,
87 shorten,
88 startConsult,
89 stepLoop,
90 wrapRows,
91} from './core'
92import type { ColumnRow, Config, Meter, Panel, PairRow } from './core'
93import { describeCodex } from './lib/codex'
94import { ROLLOUT_QUERY, mergeCodex, readCodexQuota } from './lib/codex-quota'
95import { FABLE_POLL_MS, fableFromUsage, nextFableDelay } from './lib/fable'
96import { backgroundIdOf, parseNotifications } from './lib/notify'
97import { serial } from './lib/serial'
98import { readLiveSessions } from './lib/sessions'
99import { bannerText, isQuestionTool, markPath, markText, readWaits, sameWaits } from './lib/waiting'
100import { isPersonPrompt, isReviewCall, isReviewPrompt, snapOf } from './lib/git'
101import { GIT_TIMEOUT_MS, readTree } from './lib/git-tree'
102import type { GitRun, Inspect, Tree } from './lib/git-tree'
103import { ago, plain } from './lib/plain'
104import { readReviewed, recordReviewed } from './lib/review-store'
105import type { ReviewStore } from './lib/review-store'
106import { powershellExe, tasklistExe } from './lib/system'
107
108const PANE = 'dashboard'
109const TITLE = 'Dashboard'
110
111
112// ---------------------------------------------------------------- state
113
114const meta = atom({ plugin: 'cc-dashboard', key: 'meta' } as const, { schemaVersion: SCHEMA_VERSION })
115const main = atom({ plugin: 'cc-dashboard', key: 'main' } as const, DEFAULT_MAIN)
116const usage = atom({ plugin: 'cc-dashboard', key: 'usage' } as const, DEFAULT_USAGE)
117const architect = atom({ plugin: 'cc-dashboard', key: 'architect' } as const, DEFAULT_ARCHITECT)
118const gate = atom({ plugin: 'cc-dashboard', key: 'gate' } as const, DEFAULT_GATE)
119const agents = atom({ plugin: 'cc-dashboard', key: 'agents' } as const, [])
120const loops = atom({ plugin: 'cc-dashboard', key: 'loops' } as const, [])
121const log = atom({ plugin: 'cc-dashboard', key: 'log' } as const, [])
122const turn = atom({ plugin: 'cc-dashboard', key: 'turn' } as const, DEFAULT_TURN)
123const receipt = atom({ plugin: 'cc-dashboard', key: 'receipt' } as const, null)
124const view = atom({ plugin: 'cc-dashboard', key: 'view' } as const, DEFAULT_VIEW)
125const roster = atom({ plugin: 'cc-dashboard', key: 'roster' } as const, DEFAULT_ROSTER)
126const codex = atom({ plugin: 'cc-dashboard', key: 'codex' } as const, null)
127const git = atom({ plugin: 'cc-dashboard', key: 'git' } as const, null)
128const fable = atom({ plugin: 'cc-dashboard', key: 'fable' } as const, null)
129const waiting = atom({ plugin: 'cc-dashboard', key: 'waiting' } as const, null)
130const sessions = atom({ plugin: 'cc-dashboard', key: 'sessions' } as const, [])
131const waitingOthers = atom({ plugin: 'cc-dashboard', key: 'waitingOthers' } as const, [])
132
133const swallow = (): void => undefined
134
135type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
136
137// Every read goes through these, so a value saved under an older shape still reads.
138async function getMain($: EngineInterface): Promise<Main> {
139 return normalize(DEFAULT_MAIN, await read($, main))
140}
141async function getUsage($: EngineInterface): Promise<Usage> {
142 return normalize(DEFAULT_USAGE, await read($, usage))
143}
144async function getArchitect($: EngineInterface): Promise<Architect> {
145 const a = normalize(DEFAULT_ARCHITECT, await read($, architect))
146 return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
147}
148async function getGate($: EngineInterface): Promise<Gate> {
149 return normalizeGate(await read($, gate))
150}
151async function getCards($: EngineInterface): Promise<AgentCard[]> {
152 return listOf<unknown>(await read($, agents)).map(normalizeCard)
153}
154async function getLoops($: EngineInterface): Promise<Loop[]> {
155 return listOf<Loop>(await read($, loops))
156}
157async function getLog($: EngineInterface): Promise<LogLine[]> {
158 return normalizeLog(await read($, log))
159}
160async function getTurn($: EngineInterface): Promise<Turn> {
161 return normalize(DEFAULT_TURN, await read($, turn))
162}
163async function getView($: EngineInterface): Promise<View> {
164 return normalize(DEFAULT_VIEW, await read($, view))
165}
166async function getRoster($: EngineInterface): Promise<Roster> {
167 const r = normalize(DEFAULT_ROSTER, await read($, roster))
168 return { architectTypes: listOf(r.architectTypes) }
169}
170
171/** A stored shape older than this build's: drop what cannot be read, keep the rest. */
172async function migrate($: EngineInterface) {
173 const m = await read($, meta)
174 if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
175 await update($, log, list => normalizeLog(list))
176 await update($, agents, list => listOf<unknown>(list).map(normalizeCard))
177 await update($, gate, g => normalizeGate(g))
178 await update($, meta, () => ({ schemaVersion: SCHEMA_VERSION }))
179}
180
181async function say($: EngineInterface, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null) {
182 const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId }
183 await update($, log, list => [...normalizeLog(list), line].slice(-60))
184}
185
186async function refreshStatus($: EngineInterface, cfg: Config) {
187 if (!cfg.statusLine) return $.ui.status(undefined)
188 const [u, a, g, cards] = await Promise.all([getUsage($), getArchitect($), getGate($), getCards($)])
189 const running = cards.filter(c => c.status === 'running').length
190 const s = gateSummary(g)
191 const parts = [
192 u.pct !== null ? `ctx ${Math.round(u.pct)}%` : null,
193 cards.length > 0 ? `agents ${running}/${cards.length}` : null,
194 a.consults.length > 0 || a.ids.length > 0 ? `${cfg.architectLabel.toLowerCase()} ${isAdvising(a) ? 'advising' : a.consults.length}` : null,
195 s.deny > 0 ? `denied ${s.deny}` : null,
196 ]
197 // Only fields with something to say; with none, no status entry at all.
198 const shown = parts.filter(Boolean)
199 $.ui.status(shown.length > 0 ? shown.join(' · ') : undefined)
200}
201
202async function whoIs($: EngineInterface, agentId: string | undefined) {
203 if (!agentId) return 'main'
204 const card = (await getCards($)).find(c => c.id === agentId)
205 return card ? shorten(cardTitle(card), 14) : 'agent'
206}
207
208async function consultStarted($: EngineInterface, cfg: Config, id: string, via: string) {
209 const t = await getTurn($)
210 const moment = momentOf(t)
211 const at = await $.clock.now()
212 await update($, architect, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
213 if (moment === 'before done') await update($, turn, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
214 await say($, cfg.architectLabel.toLowerCase(), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
215 await refreshStatus($, cfg)
216}
217
218async function consultEnded($: EngineInterface, cfg: Config, advice: string | null, id?: string) {
219 const at = await $.clock.now()
220 const first = advice?.split('\n').find(l => l.trim()) ?? null
221 const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
222 await update($, architect, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
223 await update($, turn, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
224 await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
225 await refreshStatus($, cfg)
226}
227
228async function noteAdvice($: EngineInterface, cfg: Config, advice: string) {
229 await update($, architect, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
230 await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
231}
232
233async function isArchitectType($: EngineInterface, cfg: Config, type: string) {
234 return cfg.architect.test(type) || (await getRoster($)).architectTypes.includes(type)
235}
236
237// The dock's width is a share of the terminal's, and the terminal's width is known only from a
238// drawing or a command's presentation. A docked drawing gives the transcript's columns, so the
239// dock's body and grip are added back. A drawing may not act, so it notes the width here and the
240// pump asks the dock again (see syncDock). What a person dragged the dock to still wins.
241let terminalColumns: number | undefined
242let lastPlacement: 'dock' | 'inline' | undefined
243let requestedColumns: number | undefined
244
245async function openPane($: EngineInterface, dockPercent: number) {
246 // columns apply when docked beside the transcript, rows when seated inline above the prompt.
247 // Before any width is known the dock keeps the engine's share; the pump asks once it is.
248 requestedColumns = terminalColumns === undefined ? undefined : dockColumns(terminalColumns, dockPercent)
249 try {
250 return await $.ui.open({ id: PANE, title: TITLE, rows: 8, ...(requestedColumns === undefined ? {} : { columns: requestedColumns }) })
251 } catch (error) {
252 requestedColumns = undefined // a refused open is asked again on the next tick
253 throw error
254 }
255}
256
257// A docked pane asks again when the terminal's width moved its share; inline, or hidden, it is left alone.
258async function syncDock($: EngineInterface, dockPercent: number) {
259 if (lastPlacement !== 'dock' || terminalColumns === undefined) return
260 if (dockColumns(terminalColumns, dockPercent) === requestedColumns) return
261 if (await isPaneShown($)) await openPane($, dockPercent)
262}
263
264async function resetAll($: EngineInterface) {
265 await update($, main, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
266 await update($, architect, () => DEFAULT_ARCHITECT)
267 await update($, gate, () => DEFAULT_GATE)
268 await update($, agents, () => [])
269 await update($, loops, () => [])
270 await update($, log, () => [])
271 await update($, turn, () => DEFAULT_TURN)
272 await update($, receipt, () => null)
273 await update($, view, () => DEFAULT_VIEW)
274 // The context gauge waits for the next measurement rather than showing the pre-clear fill.
275 await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
276}
277
278async function noteMode($: EngineInterface, mode: string | undefined) {
279 if (mode) await update($, main, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
280}
281
282// ---------------------------------------------------------------- Codex usage
283// Codex's figures are only displayed (see ./lib/codex-quota): its model from the choice file, its
284// effort from its config, and its 5-hour and weekly windows from the ChatGPT backend's usage call
285// (signed with Codex's own ChatGPT login; spends none of the quota), else from the most recently modified rollouts
286// (the latest event time among the top two wins; the next two are read only when those hold none),
287// read through Windows PowerShell by full path. The rollout's path rides in the environment, never in
288// the command text. Without USERPROFILE or SystemRoot nothing is read and the panel stays hidden.
289
290async function readCodex($: EngineInterface, askServer: boolean): Promise<CodexQuota | null> {
291 const home = await $.env.get('USERPROFILE')
292 const root = await $.env.get('SystemRoot')
293 if (home === undefined || root === undefined) return null
294 const nowMs = await $.clock.now()
295 return readCodexQuota(home, {
296 readText: path => $.fs.read(path).catch(() => ''),
297 list: dir => $.fs.list(dir),
298 stat: path => $.fs.stat(path).then(info => info.mtimeMs),
299 queryRollout: path =>
300 $.process
301 .run([powershellExe(root), '-NoProfile', '-NonInteractive', '-Command', ROLLOUT_QUERY], { env: { ROLLOUT: path }, timeoutMs: 20_000 })
302 .then(done => done.stdout, () => ''),
303 fetch: (url, headers) => $.http.fetch(url, { headers }),
304 }, { askServer, nowMs })
305}
306
307// The schedule reads the rollouts only, with no request; the server is asked only when the person
308// presses ↻ (or runs /dashboard refresh). The newer of the two readings is shown (see mergeCodex).
309async function refreshCodex($: EngineInterface, askServer = false) {
310 const reading = await readCodex($, askServer)
311 await update($, codex, old => (reading === null ? null : mergeCodex(old, reading)))
312}
313
314// ↻ on the Codex box: asks the server now, unless a read is already under way; the schedule starts over.
315async function askCodex($: EngineInterface) {
316 if (busy.has('codex')) return
317 busy.add('codex')
318 try {
319 await refreshCodex($, true).catch(swallow)
320 dueAt.codex = (await $.clock.now()) + 300_000
321 } finally {
322 busy.delete('codex')
323 }
324}
325
326// ---------------------------------------------------------------- branch (git)
327// The session's repository, read with read-only git (the commands are spelled in ./lib/git-tree).
328// A changed file is unreviewed when the last review did not see its contents as they are now. What
329// a review saw is the one thing this plugin writes: under `git-reviewed:<root>` in its own `$.store`.
330
331const gitRun = ($: EngineInterface): GitRun => (argv, init) => $.process.run(argv, { timeoutMs: GIT_TIMEOUT_MS, ...init })
332
333const reviewStore = ($: EngineInterface): ReviewStore => ({
334 get: key => $.store.get(key),
335 set: (key, value) => $.store.set(key, value),
336 delete: key => $.store.delete(key),
337 keys: () => $.store.keys(),
338})
339
340// Whether a path is a link, and where it lands (a junction lands elsewhere without being a link); one that cannot be checked counts as a link.
341const inspect = ($: EngineInterface): Inspect => path =>
342 $.fs.stat(path, { resolve: true }).then(
343 st => ({ isLink: st.isLink, ...(st.realPath === undefined ? {} : { realPath: st.realPath }) }),
344 () => ({ isLink: true }),
345 )
346
347const readGitTree = ($: EngineInterface): Promise<Tree | undefined> => readTree(gitRun($), inspect($))
348const readTreeQuietly = ($: EngineInterface): Promise<Tree | undefined> => readGitTree($).catch(() => undefined)
349
350// Outside a repository, or with a git that cannot run, the panel empties: old figures are not kept.
351async function refreshGit($: EngineInterface) {
352 const tree = await readGitTree($).catch(() => undefined)
353 const snap: GitSnap | null = tree === undefined ? null : snapOf(tree.status, tree.hashes, await readReviewed(reviewStore($), tree.root))
354 await update($, git, () => snap)
355}
356
357// The tree read as a review started is what that review saw: once it is under way (not refused,
358// not failed) those contents count as reviewed. This runs whether or not the pane is showing.
359async function recordReview($: EngineInterface, tree: Tree | undefined) {
360 if (tree?.hashes === undefined) return
361 const kept = await recordReviewed(reviewStore($), tree.root, tree.hashes)
362 await update($, git, () => snapOf(tree.status, tree.hashes, kept))
363}
364
365// ---------------------------------------------------------------- Fable
366// Fable's weekly window is not in the session's rate limits: it comes from /api/oauth/usage. The
367// request is authorised by the engine's own handle (the credential never reaches this plugin), and
368// only a bearer login can use that endpoint. A failed answer keeps the last figure and marks it
369// stale. Answers how long to wait before asking again: 90 seconds, doubled after a failure up to
370// ten minutes, or as long as the server asks in `retry-after`.
371
372const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
373let fableDelayMs = FABLE_POLL_MS
374
375async function refreshFable($: EngineInterface): Promise<number> {
376 const auth = await $.session.authorize().catch(() => null)
377 const response =
378 auth === null || auth.kind !== 'bearer'
379 ? undefined
380 : await $.http
381 .fetch(USAGE_URL, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20', 'Content-Type': 'application/json' } })
382 .catch(() => undefined)
383 const window = response?.ok === true ? fableFromUsage(response.text) : undefined
384 await update($, fable, old =>
385 window !== undefined ? { window, isStale: false } : old?.window === undefined ? { isStale: false } : { window: old.window, isStale: true },
386 )
387 fableDelayMs = nextFableDelay(fableDelayMs, response?.ok === true, response?.headers['retry-after'])
388 return fableDelayMs
389}
390
391// ---------------------------------------------------------------- waits on the person
392// A session that waits on the person (a permission dialog, a question, a plan to approve) says so
393// in a small mark file named by its pid, in the folder and format status-sidebar uses, so sessions
394// running either mod see each other. Writing this session's own mark follows its events, whether
395// or not the pane shows; reading the others' (and the registry and process table that say which
396// marks to trust) only serves the banner, so it waits for the pane to show.
397
398let herePid: number | undefined
399let pidLookup: Promise<void> | undefined
400let sessionsAt = 0
401// Calls whose permission is put to the decider, so a dialog's wait ends with its own call.
402const asked = new Set<string>()
403
404// The live sessions: the registry in ~/.claude/sessions, kept to the pids still running as Claude
405// Code. A registry or process table that cannot be read leaves no sessions, and so no banner.
406async function refreshSessions($: EngineInterface) {
407 try {
408 const home = await $.env.get('USERPROFILE')
409 if (home === undefined) throw new Error('USERPROFILE is not set')
410 const root = await $.env.get('SystemRoot')
411 if (root === undefined) throw new Error('SystemRoot is not set')
412 const rows = await readLiveSessions(home, await $.session.id(), {
413 list: dir => $.fs.list(dir),
414 read: path => $.fs.read(path).catch(() => ''),
415 tasklist: () => $.process.run([tasklistExe(root), '/FO', 'CSV', '/NH'], { timeoutMs: 15_000 }),
416 })
417 herePid = rows.find(row => row.isHere)?.pid ?? herePid
418 await update($, sessions, () => rows)
419 } catch (error) {
420 await update($, sessions, () => [])
421 throw error
422 }
423}
424
425function learnPid($: EngineInterface): Promise<void> {
426 pidLookup ??= refreshSessions($)
427 .catch(swallow)
428 .finally(() => {
429 pidLookup = undefined
430 })
431 return pidLookup
432}
433
434// What the mark says is the wait as it stands when the write begins. Until the pid is known nothing
435// waits for it, so a dialog is never held up on it.
436async function putMark($: EngineInterface) {
437 if (herePid === undefined) {
438 void learnPid($)
439 .then(() => (herePid === undefined ? undefined : writeMark($)))
440 .catch(swallow)
441 return
442 }
443 const home = await $.env.get('USERPROFILE')
444 if (home === undefined) return
445 const value = await read($, waiting)
446 await $.fs.write(markPath(home, herePid), markText(await $.session.id(), value?.reason ?? null, value?.sinceMs ?? 0))
447}
448
449// The writes go one after another, so a slow one never lands after a newer one.
450const inMarkOrder = serial()
451function writeMark($: EngineInterface): Promise<void> {
452 return inMarkOrder(() => putMark($))
453}
454
455async function setWaiting($: EngineInterface, reason: WaitReason | null) {
456 if (((await read($, waiting))?.reason ?? null) === reason) return
457 const value = reason === null ? null : { reason, sinceMs: await $.clock.now() }
458 await update($, waiting, () => value)
459 await writeMark($)
460}
461
462// A new prompt, the turn ending, or the session ending means whatever was asked is over.
463async function endWaiting($: EngineInterface) {
464 asked.clear()
465 await setWaiting($, null)
466}
467
468// A call that returned has had its question answered or its dialog closed. A permission wait ends
469// once no call put to the decider is still pending.
470async function settleWait($: EngineInterface, tool: string, toolUseId: string | undefined) {
471 if (toolUseId !== undefined) asked.delete(toolUseId)
472 const now = (await read($, waiting))?.reason
473 if ((now === 'question' && isQuestionTool(tool)) || (now === 'permission' && asked.size === 0)) await setWaiting($, null)
474}
475
476// The same waits as the last read change nothing: no write, no redraw.
477async function noteOthers($: EngineInterface, rows: WaitingRow[]) {
478 if (sameWaits(await read($, waitingOthers), rows)) return
479 await update($, waitingOthers, () => rows)
480}
481
482// The other live sessions that wait on the person: each one's mark, if the session id inside it is
483// the one the registry names. Whatever cannot be read leaves no banner: nothing is guessed.
484async function readOthers($: EngineInterface): Promise<number> {
485 try {
486 const now = await $.clock.now()
487 if (now - sessionsAt >= 30_000) {
488 sessionsAt = now
489 await refreshSessions($)
490 }
491 const home = await $.env.get('USERPROFILE')
492 await noteOthers($, home === undefined ? [] : await readWaits(home, await read($, sessions), path => $.fs.read(path).catch(() => '')))
493 } catch {
494 await noteOthers($, [])
495 }
496 return 5_000
497}
498
499// ---------------------------------------------------------------- Codex runs as subagents
500// A `codex exec` the main session starts through Bash or PowerShell is drawn as a subagent card:
501// running from the call's start, ended by its result, by a background task's notification, or
502// (inferred) by the turn's stop. No Codex session id is read.
503
504async function codexStarted($: EngineInterface, id: string, call: { purpose: string; note?: string }) {
505 const codexNow = await read($, codex)
506 const card = codexCard(id, call, codexNow?.model ?? '', await $.clock.now(), codexNow?.effort ?? '')
507 await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
508 await say($, shorten(cardTitle(card), 12), 'spawned · Codex', 'info', id)
509}
510
511async function codexEnded($: EngineInterface, id: string, ok: boolean) {
512 const card = (await getCards($)).find(c => c.id === id)
513 if (card?.codex === undefined || card.status !== 'running') return
514 const now = await $.clock.now()
515 await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? endCard(c, ok, now) : c)))
516 await say($, await whoIs($, id), ok ? `done · ${fmtDuration(now - card.spawnedAt)}` : 'failed', ok ? 'done' : 'error', id)
517}
518
519// The call's result: the run moved to the background (kept running, under its task id), or is over.
520async function codexSettled($: EngineInterface, id: string, ran: { deny?: string; isError?: boolean; result?: unknown }) {
521 const taskId = ran.deny === undefined ? backgroundIdOf(ran.result) : undefined
522 if (taskId === undefined) return codexEnded($, id, ran.deny === undefined && ran.isError !== true)
523 await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id && c.codex ? { ...c, codex: { taskId } } : c)))
524}
525
526// ---------------------------------------------------------------- polling
527// Fable, Codex, git and the other sessions' waits are read on their own schedules, and only while
528// the pane is showing: a pane nobody sees costs no request and no process. One pump looks every 2
529// seconds and starts whichever is due and not already running.
530
531type Source = 'fable' | 'codex' | 'git' | 'others'
532const SOURCES: readonly Source[] = ['fable', 'codex', 'git', 'others']
533const dueAt: Record<Source, number> = { fable: 0, codex: 0, git: 0, others: 0 }
534const busy = new Set<Source>()
535let pump: Timer | undefined
536
537async function isPaneShown($: EngineInterface) {
538 return (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
539}
540
541// Each source reads, then answers how long to wait before it is read again.
542async function readSource($: EngineInterface, source: Source): Promise<number> {
543 switch (source) {
544 case 'fable':
545 return refreshFable($)
546 case 'codex':
547 await refreshCodex($)
548 return 300_000
549 case 'git':
550 await refreshGit($)
551 return 30_000
552 case 'others':
553 return readOthers($)
554 }
555}
556
557// A read that throws is tried again after the usual wait, or for Fable after its next backoff.
558function failureWait(source: Source) {
559 if (source === 'fable') return (fableDelayMs = nextFableDelay(fableDelayMs, false, undefined))
560 return source === 'codex' ? 300_000 : source === 'git' ? 30_000 : 5_000
561}
562
563async function runSource($: EngineInterface, source: Source) {
564 busy.add(source)
565 try {
566 const wait = await readSource($, source).catch(() => failureWait(source))
567 dueAt[source] = (await $.clock.now()) + wait
568 } finally {
569 busy.delete(source)
570 }
571}
572
573async function pumpPolls($: EngineInterface) {
574 if (!(await isPaneShown($).catch(() => false))) return
575 const now = await $.clock.now()
576 for (const source of SOURCES) {
577 if (!busy.has(source) && dueAt[source] <= now) void runSource($, source).catch(swallow)
578 }
579}
580
581// One pump per plugin instance: a second start (a hot reload) replaces the first.
582function startPolling($: EngineInterface, dockPercent: number) {
583 pump?.cancel()
584 pump = $.clock.every(2_000, () => {
585 void pumpPolls($).catch(swallow)
586 void syncDock($, dockPercent).catch(swallow)
587 })
588}
589
590// ---------------------------------------------------------------- hooks
591
592export const register: Register = (on, options) => {
593 const cfg = parseConfig(options)
594 const C = PALETTES[cfg.palette]
595 // tool.check carries no loop id; the tool.call around it does, keyed by the call's id.
596 const callLoop = new Map<string, string | null>()
597
598 on('session.start', async ($, e, next) => {
599 await $.command.register({
600 name: 'dashboard',
601 description: 'CC Dashboard, the live agent dashboard: open, close, reset, refresh, or set the layout',
602 argumentHint: '[open|close|reset|refresh|layout auto|compact|wide|mini]',
603 })
604 await migrate($)
605 // A host without usage (headless, an SDK host, a session not yet bound) just starts without it.
606 const u = await $.session.usage().catch(() => null)
607 if (u) {
608 await update($, usage, x => ({
609 ...normalize(DEFAULT_USAGE, x),
610 pct: u.context.percent ?? null,
611 tokens: u.context.tokens ?? null,
612 window: u.context.window,
613 limits: u.rateLimits.map(limitOf),
614 }))
615 }
616 if (cfg.openOnStart) void openPane($, cfg.dockPercent).catch(() => undefined)
617 await refreshStatus($, cfg)
618 startPolling($, cfg.dockPercent)
619 void pumpPolls($).catch(swallow)
620 // A fresh load writes the wait as it stands: none on a start, the open one when a reload lands while a dialog is up.
621 void writeMark($).catch(swallow)
622 return next(e)
623 })
624
625 on('session.end', async ($, e, next) => {
626 if (e.reason === 'clear') {
627 await resetAll($)
628 await refreshStatus($, cfg)
629 }
630 await endWaiting($).catch(swallow)
631 return next(e)
632 })
633
634 on('command.run', { command: 'dashboard' }, async ($, e) => {
635 const [verb = 'open', arg = ''] = e.args.trim().split(/\s+/)
636 if (e.presentation.isFullscreen) terminalColumns = e.presentation.columns
637 if (verb === 'close') {
638 await $.ui.close({ id: PANE })
639 return { text: 'Dashboard closed.' }
640 }
641 if (verb === 'reset') {
642 await resetAll($)
643 await refreshStatus($, cfg)
644 return { text: 'Dashboard reset.' }
645 }
646 if (verb === 'refresh') {
647 // Read now, whether or not the pane shows; the schedules start over from here. Fable keeps its own.
648 sessionsAt = 0
649 await Promise.all([askCodex($), ...(['git', 'others'] as const).filter(src => !busy.has(src)).map(src => runSource($, src))])
650 return { text: 'Asked the server for Codex usage, and read the branch and the other sessions waiting on you again.' }
651 }
652 if (verb === 'layout') {
653 const layout: Layout | null = arg === 'compact' || arg === 'wide' || arg === 'auto' || arg === 'mini' ? arg : null
654 if (!layout) return { text: 'Usage: /dashboard layout auto|compact|wide|mini' }
655 await update($, view, v => ({ ...normalize(DEFAULT_VIEW, v), layout }))
656 const opened = await openPane($, cfg.dockPercent)
657 return { text: opened.isPlaced ? `Dashboard layout: ${layout}.` : `Layout set to ${layout}; the pane is not shown yet: ${opened.reason}` }
658 }
659 const opened = await openPane($, cfg.dockPercent)
660 if (!opened.isPlaced) return { text: `Dashboard is not shown yet: ${opened.reason}` }
661 void pumpPolls($).catch(swallow)
662 return { text: 'Dashboard opened. Focus it with ctrl+x tab; 1-6 expand cards, f/s/o open the gate rows, u lists the unreviewed files, r asks the server for Codex usage.' }
663 })
664
665 on('classic.UserPromptSubmit', async ($, e, next) => {
666 await noteMode($, e.permission_mode)
667 return next(e)
668 })
669
670 on('agent.offer', async ($, e, next) => {
671 const offered = await next(e)
672 if (cfg.architect.test(e.agent) || (cfg.matchDescriptions && cfg.architect.test(e.description))) {
673 await update($, roster, r => {
674 const x = normalize(DEFAULT_ROSTER, r)
675 return x.architectTypes.includes(e.agent) ? x : { architectTypes: [...listOf<string>(x.architectTypes), e.agent].slice(-20) }
676 })
677 }
678 return offered
679 })
680
681 on('turn.start', async ($, e, next) => {
682 const now = await $.clock.now()
683 await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now }))
684 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
685 // A background architect's report reaches the main loop as the text opening this turn. The
686 // SubagentHandback tool call (in tool.call) normally carries it first; this is the fallback.
687 const back = e.text ? handbackOf(e.text) : null
688 const a = back ? await getArchitect($) : null
689 if (back && a && a.ids.includes(back.from)) {
690 const advice = adviceLine(back.body)
691 if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
692 } else if (e.text) {
693 const p = promptLine(e.text)
694 await say($, p.who, p.text)
695 }
696 return next(e)
697 })
698
699 on('turn.step', async function* ($, e, next) {
700 // The main loop's model is known when its request starts; a long first request shouldn't read "—".
701 if (!e.agentId) {
702 await update($, main, m => {
703 const x = normalize(DEFAULT_MAIN, m)
704 return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
705 })
706 return yield* next(e)
707 }
708 const result = yield* next(e)
709 const id = e.agentId
710 const [cards, a] = await Promise.all([getCards($), getArchitect($)])
711 if (cards.some(c => c.id === id)) {
712 const step = { model: e.model, effort: e.effort, usage: result.usage, stopReason: result.stopReason }
713 await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? applyStep(c, step) : c)))
714 if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
715 } else if (!a.ids.includes(id)) {
716 const now = await $.clock.now()
717 await update($, loops, l => stepLoop(listOf<Loop>(l), id, now))
718 }
719 return result
720 })
721
722 on('session.measure', async ($, e, next) => {
723 await update($, usage, x => ({
724 ...normalize(DEFAULT_USAGE, x),
725 pct: e.context.percent ?? null,
726 tokens: e.context.tokens ?? null,
727 window: e.context.window,
728 limits: e.rateLimits.map(limitOf),
729 }))
730 if (e.changed.includes('context')) await refreshStatus($, cfg)
731 return next(e)
732 })
733
734 on('session.compact', async ($, e, next) => {
735 const done = await next(e)
736 if (!e.agentId && e.trigger !== 'precompute') {
737 const now = await $.clock.now()
738 await update($, usage, x => {
739 const u = normalize(DEFAULT_USAGE, x)
740 return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
741 })
742 await say($, 'main', `context compacted (${e.trigger})`)
743 }
744 return done
745 })
746
747 on('tool.check', async ($, e, next) => {
748 const verdict = await next(e)
749 if (verdict.decision === 'ask' && e.tool_use_id) asked.add(e.tool_use_id)
750 if (e.tool_use_id) {
751 const check: Check = {
752 id: e.tool_use_id,
753 tool: e.tool,
754 bucket: bucketOf(e.tool),
755 verdict: verdict.decision === 'allow' ? 'rule' : verdict.decision,
756 inSubagent: Boolean(callLoop.get(e.tool_use_id)),
757 detail: shorten(describeInput(e.tool, e.input), 90),
758 at: await $.clock.now(),
759 }
760 await update($, gate, g => recordCheck(normalizeGate(g), check))
761 // The status line updates when the call settles; only a refusal ends here.
762 if (verdict.decision === 'deny') {
763 await say($, 'gate', `denied by rule · ${check.detail}`, 'error')
764 await refreshStatus($, cfg)
765 }
766 }
767 return verdict
768 })
769
770 // A permission dialog opens: this session waits on the person until the call returns.
771 on('classic.PermissionRequest', async ($, e, next) => {
772 await setWaiting($, 'permission').catch(swallow)
773 return next(e)
774 }).catch(async (_$, e, next) => next(e))
775
776 // At the end of a turn the engine's own list of background tasks says which Codex runs still live.
777 on('classic.Stop', async ($, e, next) => {
778 const liveIds = e.background_tasks?.map(task => task.id)
779 for (const end of stopCodex(await getCards($), liveIds)) await codexEnded($, end.id, end.ok)
780 await refreshStatus($, cfg)
781 return next(e)
782 }).catch(async (_$, e, next) => next(e))
783
784 on('tool.call', async ($, e, next) => {
785 const tool = String(e.tool)
786 callLoop.set(e.tool_use_id, e.agentId ?? null)
787 // A review is noted whoever starts it: the tree is read as the call begins, and kept only if
788 // the call is not refused and does not fail.
789 const review = isReviewCall(tool, e) ? readTreeQuietly($) : undefined
790 // A question put to the person is a wait from the call's start to its return, whoever asks.
791 if (isQuestionTool(tool)) await setWaiting($, 'question').catch(swallow)
792 // A `codex exec` the main session runs is a subagent card from here to its end.
793 const shell = !e.agentId && (tool === 'Bash' || tool === 'PowerShell') ? (e as unknown as { command?: unknown; description?: unknown }) : null
794 const run =
795 shell && typeof shell.command === 'string'
796 ? describeCodex(shell.command, typeof shell.description === 'string' ? shell.description : undefined, tool === 'PowerShell' ? 'powershell' : 'bash')
797 : undefined
798 if (run) {
799 await codexStarted($, e.tool_use_id, run).catch(swallow)
800 await refreshStatus($, cfg)
801 }
802 const ran = await next(e)
803 .catch(async error => {
804 if (run) await codexEnded($, e.tool_use_id, false).catch(swallow)
805 throw error
806 })
807 .finally(() => {
808 callLoop.delete(e.tool_use_id)
809 void settleWait($, tool, e.tool_use_id).catch(swallow)
810 })
811 if (run) await codexSettled($, e.tool_use_id, ran).catch(swallow)
812 const didRun = ran.deny === undefined
813 if (review !== undefined && didRun && ran.isError !== true) void review.then(tree => recordReview($, tree)).catch(swallow)
814 // Settle this call's pending ask, if it had one; skip the write (and the redraw) otherwise.
815 const g0 = await getGate($)
816 const isSettled = settleCheck(g0, e.tool_use_id, didRun) !== g0
817 if (isSettled) await update($, gate, g => settleCheck(normalizeGate(g), e.tool_use_id, didRun))
818 // A background agent hands its report back through this tool; an architect's report is its advice.
819 if (String(e.tool) === 'SubagentHandback') {
820 const message = (e as unknown as { message?: unknown }).message
821 const a = e.agentId ? await getArchitect($) : null
822 if (a && e.agentId && a.ids.includes(e.agentId) && typeof message === 'string') {
823 const advice = adviceLine(message)
824 if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
825 }
826 return ran
827 }
828 if (e.tool === 'Agent') {
829 if (isSettled) await refreshStatus($, cfg)
830 return ran
831 }
832 const hasFailed = didRun && ran.isError === true
833 const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
834 const t0 = await getTurn($)
835 if (isEdit || hasFailed || (!e.agentId && t0.errorStreak > 0)) {
836 await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
837 }
838 const text = shorten(describeInput(e.tool, e), 64)
839 if (e.agentId) {
840 const id = e.agentId
841 await update($, agents, list =>
842 listOf<unknown>(list)
843 .map(normalizeCard)
844 .map(c => (c.id === id ? noteTool(c, { tool: e.tool, text, isError: hasFailed || ran.deny !== undefined }) : c)),
845 )
846 }
847 // The log keeps what is worth a glance: refusals, errors and edits; the rest is on the cards.
848 if (ran.deny !== undefined) await say($, await whoIs($, e.agentId), `${text} denied`, 'error', e.agentId ?? null)
849 else if (hasFailed) await say($, await whoIs($, e.agentId), `${text} ✗`, 'error', e.agentId ?? null)
850 else if (isEdit) await say($, await whoIs($, e.agentId), text, 'info', e.agentId ?? null)
851 if (isSettled || !didRun || run) await refreshStatus($, cfg)
852 return ran
853 })
854
855 // A typed /…review has no result to wait for: it counts as it is sent, if the person typed it.
856 // A background task's notification arrives as a prompt of its own origin: it ends a Codex run.
857 // What the person types answers whatever was asked.
858 on('prompt.submit', async ($, e, next) => {
859 if (e.origin.kind === 'task-notification') {
860 for (const n of parseNotifications(e.text)) {
861 const card = codexCardFor(await getCards($), n.id, n.toolUseId)
862 if (card) await codexEnded($, card.id, n.ok).catch(swallow)
863 }
864 await refreshStatus($, cfg)
865 }
866 const result = await next(e)
867 if (result.drop === undefined && isPersonPrompt(e.origin.kind)) {
868 if (isReviewPrompt(e.text)) {
869 void readTreeQuietly($)
870 .then(tree => recordReview($, tree))
871 .catch(swallow)
872 }
873 await endWaiting($).catch(swallow)
874 }
875 return result
876 }).catch(async (_$, e, next) => next(e))
877
878 // A server-side review tool never reaches tool.call: it shows only in the assistant's rows.
879 on('session.append', async ($, e, next) => {
880 if (!e.agentId && e.message.type === 'assistant') {
881 const a = await getArchitect($)
882 // Consults this row opened: their result may be in the same row, after the stale read above.
883 const opened = new Set<string>()
884 for (const block of e.message.content as unknown as readonly ServerBlock[]) {
885 if (block.type === 'server_tool_use' && block.name && block.id && cfg.architect.test(block.name)) {
886 if (a.seen.includes(block.id)) continue
887 const id = block.id
888 await update($, architect, x => {
889 const y = normalize(DEFAULT_ARCHITECT, x)
890 return { ...y, seen: [...listOf<string>(y.seen), id].slice(-60) }
891 })
892 opened.add(id)
893 await consultStarted($, cfg, id, `${block.name} tool`)
894 } else if (block.type.endsWith('_tool_result') && block.tool_use_id) {
895 const id = block.tool_use_id
896 const isOpen = opened.has(id) || (await getArchitect($)).consults.some(c => c.id === id && c.endAt === null)
897 if (isOpen) await consultEnded($, cfg, null, id)
898 }
899 }
900 }
901 return next(e)
902 })
903
904 on('agent.spawn', async ($, e, next) => {
905 const started = await next(e)
906 if (!e.parentAgentId) await noteMode($, e.permissionMode)
907 if (started.deny !== undefined || !started.agentId) return started
908 const id = started.agentId
909 if (await isArchitectType($, cfg, e.subagentType)) {
910 await update($, architect, a => {
911 const x = normalize(DEFAULT_ARCHITECT, a)
912 return { ...x, ids: [...listOf<string>(x.ids), id].slice(-40) }
913 })
914 await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
915 await consultStarted($, cfg, id, e.subagentType.split(':').pop() ?? 'agent')
916 return started
917 }
918 const card: AgentCard = {
919 ...normalizeCard({}),
920 id,
921 type: e.name ?? e.subagentType,
922 model: started.model,
923 description: e.description,
924 spawnedAt: await $.clock.now(),
925 }
926 await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
927 await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
928 await say($, shorten(cardTitle(card), 12), `spawned · ${card.type}`, 'info', id)
929 await refreshStatus($, cfg)
930 return started
931 })
932
933 on('turn.complete', async ($, e, next) => {
934 const done = await next(e)
935 const id = e.agentId
936 const now = await $.clock.now()
937 if (!id) {
938 const [t, cards] = await Promise.all([getTurn($), getCards($)])
939 const r = receiptOf(t, {
940 durationMs: e.durationMs,
941 agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length,
942 reason: e.reason,
943 })
944 await update($, receipt, () => r)
945 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
946 // The turn may have changed files: the next pump reads git without waiting out its 30 seconds.
947 dueAt.git = 0
948 await endWaiting($).catch(swallow)
949 await refreshStatus($, cfg)
950 return done
951 }
952 if ((await getArchitect($)).ids.includes(id)) {
953 await consultEnded($, cfg, e.answer, id)
954 return done
955 }
956 const cards = await getCards($)
957 if (cards.some(c => c.id === id)) {
958 const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
959 await update($, agents, list =>
960 listOf<unknown>(list)
961 .map(normalizeCard)
962 .map(c => (c.id === id ? { ...c, status, endedAt: now, answer: shorten(e.answer, 400) } : c)),
963 )
964 const card = cards.find(c => c.id === id)
965 const took = card ? fmtDuration(now - card.spawnedAt) : ''
966 await say($, await whoIs($, id), status === 'done' ? `done · ${took}` : status, status === 'done' ? 'done' : 'error', id)
967 } else {
968 await update($, loops, l => listOf<Loop>(l).map(x => (x.id === id ? { ...x, isDone: true, lastAt: now } : x)))
969 }
970 await refreshStatus($, cfg)
971 return done
972 })
973
974 // ---------------------------------------------------------------- drawing
975
976 const receiptText = (x: { durationMs: number; agents: number; edits: number; errors: number }) =>
977 `last turn ${fmtDuration(x.durationMs)} · ${plural(x.agents, 'agent')} · ${plural(x.edits, 'edit')} · ${plural(x.errors, 'error')}`
978
979 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
980 // Noted for the pump, which sizes the dock: a drawing itself only reads. Only the terminal's
981 // drawing counts; a desktop or phone attached to the session measures a pane of its own.
982 if (e.surface === 'terminal') {
983 lastPlacement = e.props.placement
984 // Docked, the viewport is the transcript beside the dock, so the dock is added back.
985 if (e.viewport?.isFullscreen === true) {
986 terminalColumns = e.props.placement === 'dock' ? terminalWidth(e.viewport.columns, e.props.bodyColumns) : e.viewport.columns
987 }
988 }
989 const els = $.ui.resolve(e)
990 const { Box, Text, Button } = els
991 const hasClient = 'Client' in els
992 const [m, u, a, g, cards, lp, lines, t, r, v, now, cx, gs, fb, others] = await Promise.all([
993 getMain($),
994 getUsage($),
995 getArchitect($),
996 getGate($),
997 getCards($),
998 getLoops($),
999 getLog($),
1000 getTurn($),
1001 read($, receipt),
1002 getView($),
1003 $.clock.now(),
1004 read($, codex),
1005 read($, git),
1006 read($, fable),
1007 read($, waitingOthers),
1008 ])
1009 // Another live session waiting on the person: one red line, absent while nobody waits.
1010 const banner = bannerText(others, now)
1011 const fableWindow = fb?.window
1012 const W = Math.max(DOCK_MIN_COLUMNS, e.props.bodyColumns)
1013 const layout = v.layout ?? cfg.layout
1014 const isWide = layout === 'wide' || (layout === 'auto' && W >= 110)
1015 const colW = isWide ? Math.floor((W - 2) / 2) : W
1016 const modelName = prettyModel(m.model)
1017 const viewed = e.props.view?.agentId ?? null
1018 const advising = isAdvising(a)
1019 const running = cards.filter(c => c.status === 'running')
1020 const showArchitect = a.consults.length > 0 || a.ids.length > 0
1021 const motion = cfg.motion && hasClient
1022 // A panel with nothing to show yet takes no room: most sessions never spawn an agent.
1023 const isEmpty: Record<Panel, boolean> = {
1024 main: false,
1025 codex: cx === null,
1026 git: gs === null,
1027 architect: !showArchitect,
1028 gate: g.recent.length === 0 && gateSummary(g).total === 0,
1029 agents: cards.length === 0,
1030 loops: lp.length === 0,
1031 receipt: !m.isRunning && !r,
1032 log: false,
1033 }
1034 const panels = cfg.panels.filter(p => !isEmpty[p])
1035 // The single column seats two panels to a row when the pane is wide enough and the person left the layout on auto.
1036 const pairing = layout === 'auto' && !isWide ? pairWidths(W) : null
1037 // The context is a line of its own under the main box (under the Codex box when that comes next), once it is read.
1038 const ctxMeter: Meter | null =
1039 u.pct !== null
1040 ? { label: 'Context', pct: Math.round(u.pct), isStale: false, detail: u.tokens !== null ? `${kTokens(u.tokens)}/${kTokens(u.window)}` : '' }
1041 : null
1042 const ctxNote = u.compactions > 0 ? ` ⟲${u.compactions}` : ''
1043 const withContext = (list: readonly PairRow[]): ColumnRow[] => (ctxMeter ? withCtx(list) : [...list])
1044 const rows = withContext(pairing ? pairRows(panels) : panels)
1045 const isPaired = (a: Panel, b: Panel) => rows.some(r => typeof r !== 'string' && r[0] === a && r[1] === b)
1046 const decider = m.mode === 'auto' ? 'classifier' : 'you'
1047
1048 // A connector between panels: animated while its flow is live, a dim line otherwise.
1049 const rail = (key: string, active: boolean, color: string, width: number, marks: number[] = [], isMerge = false) =>
1050 motion ? (
1051 <els.Client
1052 key={key}
1053 module="./rail.tsx"
1054 width={width}
1055 height={1}
1056 props={{ active, width, color, dim: C.faint, marks, isMerge }}
1057 />
1058 ) : (
1059 <Text color={C.faint}>{'─'.repeat(Math.max(1, width))}</Text>
1060 )
1061
1062 // A start time of 0 is unknown (state saved before it was recorded): no clock, not decades.
1063 const clock = (key: string, since: number, endAt: number | null, color: string) =>
1064 since <= 0 ? (
1065 <Text color={color}>—</Text>
1066 ) : hasClient ? (
1067 <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
1068 ) : (
1069 <Text color={color}>{fmtTimer((endAt ?? now) - since)}</Text>
1070 )
1071
1072 // A usage row as the first version drew its quota header: the label, a bar across the line, the
1073 // percent in a column of its own, then the time to the reset. The rows
1074 // of one panel share a label column and a bar width (see meterLayout), so their bars line up.
1075 // Past its reset a window reads 0%, marked `~` and `stale`; a window not read yet is a dash.
1076 const meterRow = (key: string, row: Meter, layout: { labelW: number; barW: number }, color: string) => {
1077 const label = padCells(row.label, layout.labelW)
1078 if (row.pct === null) return <Text key={key} dimColor wrap="truncate">{`${label} —`}</Text>
1079 const bar = gauge(row.pct, layout.barW, LINE_GLYPHS)
1080 const tone = row.isStale ? C.dim : row.pct >= 80 ? C.warn : color
1081 return (
1082 <Text key={key} wrap="truncate">
1083 <Text dimColor>{`${label} `}</Text>
1084 <Text color={tone}>{bar.on}</Text>
1085 <Text color={C.faint}>{bar.off}</Text>
1086 <Text color={tone} bold={!row.isStale}>{` ${meterPercent(row)}`}</Text>
1087 {row.detail ? <Text dimColor>{` ${row.detail}`}</Text> : null}
1088 </Text>
1089 )
1090 }
1091 // The label column and bar width a panel's rows share, the bar sized by the widest detail after a percent.
1092 const meterLayout = (rows: Meter[], w: number) => {
1093 const labelW = Math.max(0, ...rows.map(r => cells(r.label)))
1094 return { labelW, barW: meterWidth(w, labelW, rows.map(r => r.detail)) }
1095 }
1096
1097 // ---- main
1098 const effortN = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }[m.effort] ?? 0
1099 // Claude's windows and Fable, a row each.
1100 const usageMeters = [
1101 ...u.limits.slice(0, 2).map(l => windowMeter(limitLabel(l.kind), limitWindow(l), now)),
1102 ...(fableWindow ? [windowMeter(fb?.isStale ? 'Fable~' : 'Fable', fableWindow, now)] : []),
1103 ]
1104 const mainPanel = (w: number, isNarrow = false) => {
1105 const layout = meterLayout(usageMeters, w)
1106 const effortBar = '▮'.repeat(effortN) + '▯'.repeat(4 - effortN)
1107 const narrowLine = narrowEffort(m.effort, m.mode, m.steps, w - PANEL_FRAME)
1108 return (
1109 <Box flexDirection="column" borderStyle="round" borderColor={C.main} paddingX={1} width={w}>
1110 <Box justifyContent="space-between">
1111 <Text color={C.main} bold>
1112 {modelName} · main
1113 </Text>
1114 <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? '● working' : '○ idle'}</Text>
1115 </Box>
1116 {isNarrow ? (
1117 <Text wrap="truncate">
1118 <Text color={C.main}>{effortBar} </Text>
1119 <Text color={C.main} bold>
1120 {narrowLine.effort}
1121 </Text>
1122 <Text dimColor>{narrowLine.tail}</Text>
1123 </Text>
1124 ) : (
1125 <Text wrap="truncate">
1126 <Text dimColor>effort </Text>
1127 <Text color={C.main}>{effortBar} </Text>
1128 <Text color={C.main} bold>
1129 {m.effort || '—'}
1130 </Text>
1131 {m.mode ? <Text dimColor>{` mode ${m.mode}`}</Text> : null}
1132 <Text dimColor>{` ${m.steps} req`}</Text>
1133 </Text>
1134 )}
1135 {usageMeters.map((row, i) => meterRow(`usage-${i}`, row, layout, C.main))}
1136 </Box>
1137 )
1138 }
1139 // The context: one line about half the pane wide, no box, left-aligned in its column with a blank row below; its bar
1140 // is sized by its own detail and note, and the filled cells and the percent share one colour by how full it is.
1141 const ctxLine = (w: number) => {
1142 if (!ctxMeter || ctxMeter.pct === null) return null
1143 const barW = lineMeterWidth(Math.round(w * CTX_LINE_SHARE), cells(ctxMeter.label), ctxMeter.detail, ctxNote)
1144 const bar = gauge(ctxMeter.pct, barW, BLOCK_GLYPHS)
1145 const tone = C[ctxTone(ctxMeter.pct)]
1146 return (
1147 <Box key="context" width={w} marginBottom={1}>
1148 <Text wrap="truncate">
1149 <Text dimColor>{`${ctxMeter.label} `}</Text>
1150 <Text color={tone}>{bar.on}</Text>
1151 <Text color={C.faint}>{bar.off}</Text>
1152 <Text color={tone} bold>{` ${meterPercent(ctxMeter)}`}</Text>
1153 {ctxMeter.detail ? <Text dimColor>{` ${ctxMeter.detail}`}</Text> : null}
1154 {ctxNote ? <Text color={C.amber}>{ctxNote}</Text> : null}
1155 </Text>
1156 </Box>
1157 )
1158 }
1159
1160 // ---- architect
1161 const lastConsult = a.consults[a.consults.length - 1]
1162 const architectPanel = (w: number) => {
1163 const tl = consultTimeline(a, now, Math.max(8, w - 4))
1164 return (
1165 <Box flexDirection="column" borderStyle="round" borderColor={C.arch} paddingX={1} width={w}>
1166 <Box justifyContent="space-between">
1167 <Text color={C.arch} bold>
1168 {cfg.architectLabel} · {advising ? 'advising' : 'on call'}
1169 </Text>
1170 <Text>
1171 <Text dimColor>consults </Text>
1172 <Text color={C.arch} bold>
1173 {a.consults.length}
1174 </Text>
1175 </Text>
1176 </Box>
1177 <Text color={C.arch}>{tl}</Text>
1178 {lastConsult ? (
1179 <Text dimColor wrap="truncate">
1180 {advising
1181 ? `consulting since ${fmtClock(lastConsult.at)}`
1182 : `last ${fmtDuration(now - (lastConsult.endAt ?? lastConsult.at))} ago · took ${fmtDuration((lastConsult.endAt ?? now) - lastConsult.at)}`}
1183 </Text>
1184 ) : (
1185 <Text dimColor>not consulted yet</Text>
1186 )}
1187 {cfg.moments ? (
1188 <Box flexWrap="wrap" columnGap={2}>
1189 {(['before a plan', 'error repeats', 'before done'] as const).map(mo => {
1190 const isOn = lastConsult?.moment === mo
1191 return (
1192 <Text color={isOn ? C.arch : C.dim} bold={isOn}>
1193 {isOn ? '◆' : '◇'} {mo}
1194 </Text>
1195 )
1196 })}
1197 <Text color={C.faint}>(inferred)</Text>
1198 </Box>
1199 ) : null}
1200 {a.lastAdvice ? (hooks/core.ts 793 lines1// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
2// behaviour is testable directly (the test kit cannot raise a subagent's tool call or a
3// permission check inside a call; these functions are what the hooks apply).
4import type {
5 AgentCard,
6 Architect,
7 Bucket,
8 Check,
9 Gate,
10 Layout,
11 LogLine,
12 Loop,
13 Main,
14 Moment,
15 Receipt,
16 Roster,
17 Tally,
18 ToolNote,
19 Turn,
20 Usage,
21 UsageWindow,
22 GitSnap,
23 View,
24} from '../types'
25
26export const SCHEMA_VERSION = 3
27
28// ---------------------------------------------------------------- defaults
29
30export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false }
31export const DEFAULT_USAGE: Usage = {
32 pct: null,
33 tokens: null,
34 window: 0,
35 limits: [],
36 compactions: 0,
37 lastCompactAt: null,
38}
39export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' }
40const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
41export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
42export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0 }
43export const DEFAULT_VIEW: View = { expanded: null, gateOpen: null, layout: null, gitOpen: false }
44export const DEFAULT_ROSTER: Roster = { architectTypes: [] }
45
46const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
47
48/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
49export const normalize = <T extends object>(def: T, stored: unknown): T =>
50 isObject(stored) ? ({ ...def, ...stored } as T) : def
51
52/** A stored list, or empty when what is stored is not a list. */
53export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
54
55export const normalizeGate = (stored: unknown): Gate => {
56 const g = normalize(DEFAULT_GATE, stored)
57 const totals = normalize(DEFAULT_GATE.totals, g.totals)
58 return {
59 recent: listOf<Check>(g.recent),
60 totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) },
61 }
62}
63
64export const normalizeCard = (stored: unknown): AgentCard =>
65 normalize<AgentCard>(
66 {
67 id: '',
68 type: 'agent',
69 model: '',
70 effort: '',
71 description: '',
72 status: 'running',
73 spawnedAt: 0,
74 endedAt: null,
75 ctx: 0,
76 out: 0,
77 steps: 0,
78 lastStop: null,
79 tools: [],
80 answer: '',
81 },
82 stored,
83 )
84
85export const normalizeLog = (stored: unknown): LogLine[] =>
86 listOf<Record<string, unknown>>(stored).map(l => ({
87 at: typeof l.at === 'number' ? l.at : 0,
88 who: String(l.who ?? ''),
89 text: String(l.text ?? ''),
90 agentId: typeof l.agentId === 'string' ? l.agentId : null,
91 kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
92 }))
93
94// ---------------------------------------------------------------- config
95
96export type Panel = 'main' | 'codex' | 'git' | 'architect' | 'gate' | 'agents' | 'loops' | 'receipt' | 'log'
97const PANELS: readonly Panel[] = ['main', 'codex', 'git', 'architect', 'gate', 'agents', 'loops', 'receipt', 'log']
98
99export type Config = {
100 architect: RegExp
101 architectLabel: string
102 gateLabel: string
103 panels: Panel[]
104 motion: boolean
105 moments: boolean
106 matchDescriptions: boolean
107 maxCards: number
108 layout: Layout
109 palette: Palette
110 openOnStart: boolean
111 statusLine: boolean
112 /** The share of the terminal's width the dock asks for in the fullscreen layout, in percent. */
113 dockPercent: number
114}
115
116const safeRegExp = (source: string, fallback: string) => {
117 try {
118 return new RegExp(source || fallback, 'i')
119 } catch {
120 return new RegExp(fallback, 'i')
121 }
122}
123
124/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
125export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
126 const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
127 const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
128 const panels = str('panels', PANELS.join(','))
129 .split(',')
130 .map(s => s.trim())
131 .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
132 const layout = str('layout', 'auto')
133 const max = typeof o.maxCards === 'number' ? Math.round(o.maxCards) : 3
134 const dock = typeof o.dockPercent === 'number' && Number.isFinite(o.dockPercent) ? Math.round(o.dockPercent) : DOCK_PERCENT
135 return {
136 architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'),
137 architectLabel: str('architectLabel', 'ARCHITECT'),
138 gateLabel: str('gateLabel', 'GATE'),
139 panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS],
140 motion: str('motion', 'while-active') !== 'off',
141 moments: bool('moments', true),
142 matchDescriptions: bool('matchDescriptions', false),
143 maxCards: Math.min(6, Math.max(1, max)),
144 layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
145 palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
146 openOnStart: bool('openOnStart', true),
147 statusLine: bool('statusLine', true),
148 dockPercent: Math.min(DOCK_PERCENT_MAX, Math.max(DOCK_PERCENT_MIN, dock)),
149 }
150}
151
152// ---------------------------------------------------------------- dock
153
154const DOCK_PERCENT = 40
155const DOCK_PERCENT_MIN = 20
156const DOCK_PERCENT_MAX = 80
157/** The cells the dock's frame takes beside its body: the divider and the edge. */
158const DOCK_FRAME_COLUMNS = 2
159/** The narrowest body the pane lays out for; the drawing never goes below it either. */
160export const DOCK_MIN_COLUMNS = 40
161
162/** The body columns to ask the dock for: `percent` of the terminal's width, less the frame. */
163export const dockColumns = (terminalColumns: number, percent: number) =>
164 Math.max(DOCK_MIN_COLUMNS, Math.round((terminalColumns * percent) / 100) - DOCK_FRAME_COLUMNS)
165
166/** The cell the engine draws beside a docked pane's body, for the person to drag it by. */
167const DOCK_GRIP_COLUMNS = 1
168
169/**
170 * The terminal's width from a docked drawing: on the terminal `viewport.columns` is the transcript's
171 * columns only, so the dock (its body and its grip) is added back.
172 */
173export const terminalWidth = (viewportColumns: number, bodyColumns: number) =>
174 viewportColumns + bodyColumns + DOCK_GRIP_COLUMNS
175
176// ---------------------------------------------------------------- palette
177
178export type Palette = 'theme' | 'pastel'
179export type Colors = Record<'main' | 'agent' | 'gate' | 'cleared' | 'arch' | 'codex' | 'branch' | 'ok' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
180
181/**
182 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
183 * `pastel` is fixed hex tuned for dark terminals.
184 */
185export const PALETTES: Record<Palette, Colors> = {
186 theme: {
187 main: 'claude',
188 agent: 'suggestion',
189 gate: 'success',
190 cleared: 'permission',
191 arch: 'merged',
192 codex: 'ide',
193 branch: 'promptBorder',
194 ok: 'success',
195 amber: 'warning',
196 warn: 'error',
197 dim: 'inactive',
198 faint: 'subtle',
199 text: 'text',
200 },
201 pastel: {
202 main: '#7dd3fc',
203 agent: '#93c5fd',
204 gate: '#86efac',
205 cleared: '#5eead4',
206 arch: '#c4b5fd',
207 codex: '#60a5fa',
208 branch: '#d0d7de',
209 ok: '#86efac',
210 amber: '#fcd34d',
211 warn: '#fca5a5',
212 dim: '#6b7280',
213 faint: '#3f4654',
214 text: '#e5e7eb',
215 },
216}
217
218/** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */
219export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
220
221// ---------------------------------------------------------------- formatting
222
223/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
224/**
225 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
226 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
227 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
228 */
229export const prettyModel = (id: string) => {
230 if (!id) return '—'
231 const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
232 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
233 const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
234 if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
235 const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
236 if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
237 return shorten(id, 22)
238}
239
240export const shorten = (s: string, n: number) => {
241 const one = s.replace(/\s+/g, ' ').trim()
242 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
243}
244
245export const kTokens = (n: number) =>
246 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
247
248export const fmtDuration = (ms: number) => {
249 const s = Math.max(0, Math.round(ms / 1000))
250 if (s < 60) return `${s}s`
251 const m = Math.floor(s / 60)
252 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
253}
254
255/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
256export const fmtTimer = (ms: number) => {
257 const s = Math.max(0, Math.floor(ms / 1000))
258 const m = Math.floor(s / 60)
259 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
260}
261
262export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
263
264/** `1 error`, `2 errors`. */
265export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
266
267/** A gauge of `width` cells: ▰ filled, ▱ empty, or in the glyphs given. */
268export const gauge = (pct: number, width: number, [filled, empty]: readonly [string, string] = ['▰', '▱']) => {
269 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
270 return { on: filled.repeat(full), off: empty.repeat(Math.max(0, width - full)) }
271}
272
273/** The usage rows' glyphs, as the first version drew its quota bars: ━ used, ─ the rest. */
274export const LINE_GLYPHS = ['━', '─'] as const
275
276/** The context bar's glyphs, as the first version drew it: █ used, ░ the rest. Swap them here. */
277export const BLOCK_GLYPHS = ['█', '░'] as const
278
279/** The colour of the context bar and its percent: green under 50, yellow from 50, red from 80. */
280export const ctxTone = (pct: number): 'ok' | 'amber' | 'warn' => (pct >= 80 ? 'warn' : pct >= 50 ? 'amber' : 'ok')
281
282/** A rate-limit reading as the pane keeps it, its ISO reset time turned into ms since the epoch. */
283export const limitOf = (r: { kind: string; percentUsed: number; resetsAt?: string }) => {
284 const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN
285 return { kind: r.kind, pct: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null }
286}
287
288/** Time left until a window resets, its two largest units: `25m`, `3h10m`, `4d2h`; empty when unknown or past. */
289export const fmtResetIn = (resetsAt: number | null | undefined, now: number) => {
290 if (resetsAt === null || resetsAt === undefined || resetsAt <= now) return ''
291 const m = Math.max(1, Math.floor((resetsAt - now) / 60_000))
292 const d = Math.floor(m / 1440)
293 const h = Math.floor((m % 1440) / 60)
294 if (d > 0) return `${d}d${h}h`
295 return h > 0 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
296}
297
298/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
299export const limitLabel = (kind: string) =>
300 kind
301 .replace(/five[_ -]?hours?/i, '5h')
302 .replace(/seven[_ -]?days?/i, '7d')
303 .replace(/[_-]+/g, ' ')
304 .trim()
305
306// ---------------------------------------------------------------- redaction
307
308const SECRETS: [RegExp, string][] = [
309 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
310 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
311 [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
312 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
313 [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
314 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
315 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
316]
317
318/** What the gate drill-down may store: credentials masked before anything is written to state. */
319export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
320
321// ---------------------------------------------------------------- tools and gate
322
323const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
324const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
325export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
326
327export const bucketOf = (tool: string): Bucket =>
328 FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other'
329
330/** One line saying what a call was about: its command, path or pattern; redacted. */
331export const describeInput = (tool: string, input: unknown) => {
332 const i = isObject(input) ? input : {}
333 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
334 const what =
335 typeof i.command === 'string'
336 ? i.command
337 : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
338 return redact(what ? `${tool} → ${what}` : tool)
339}
340
341/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
342export const trimRecent = (list: Check[], max: number): Check[] => {
343 let extra = list.length - max
344 if (extra <= 0) return list
345 const out: Check[] = []
346 for (const c of list) {
347 if (extra > 0 && c.verdict !== 'ask') {
348 extra -= 1
349 continue
350 }
351 out.push(c)
352 }
353 return out.slice(-max * 2)
354}
355
356export const recordCheck = (g: Gate, c: Check): Gate => {
357 const t = g.totals[c.bucket]
358 return {
359 recent: trimRecent([...g.recent, c], 80),
360 totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
361 }
362}
363
364/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
365export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => {
366 const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
367 if (!c) return g
368 const verdict = didRun ? 'cleared' : 'deny'
369 const t = g.totals[c.bucket]
370 return {
371 recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
372 totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
373 }
374}
375
376export const gateSummary = (g: Gate) => {
377 const all = (['file', 'shell', 'other'] as const).reduce(
378 (s, k) => ({
379 rule: s.rule + g.totals[k].rule,
380 ask: s.ask + g.totals[k].ask,
381 cleared: s.cleared + g.totals[k].cleared,
382 deny: s.deny + g.totals[k].deny,
383 }),
384 { ...ZERO },
385 )
386 return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
387}
388
389// ---------------------------------------------------------------- turn and architect
390
391/**
392 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
393 * own); edits count from every loop, so delegated work still reaches "before done".
394 */
395export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
396 ...t,
397 errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
398 errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
399 edits: t.edits + (c.isEdit ? 1 : 0),
400})
401
402/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
403export const momentOf = (t: Pick<Turn, 'edits' | 'errorStreak'>): Moment =>
404 t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
405
406export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect =>
407 a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
408
409/** Ends the open consult (the latest without an end), or the one named. */
410export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => {
411 const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
412 return {
413 ...a,
414 consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
415 lastAdvice: advice ?? a.lastAdvice,
416 }
417}
418
419export const isAdvising = (a: Architect) => a.consults.some(c => c.endAt === null)
420
421/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
422export const consultTimeline = (a: Architect, now: number, width: number) => {
423 if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
424 const first = a.consults[0]?.at ?? now
425 const span = Math.max(1, now - first)
426 const cells = Array.from({ length: width }, () => '─')
427 for (const c of a.consults) {
428 const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
429 const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
430 for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
431 cells[from] = '◆'
432 }
433 return cells.join('')
434}
435
436export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; reason: string }): Receipt => ({
437 durationMs: o.durationMs,
438 agents: o.agentsSince,
439 edits: t.edits,
440 errors: t.errors,
441 reason: o.reason,
442})
443
444// ---------------------------------------------------------------- agents and loops
445
446type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
447
448/**
449 * A card after one of its model requests: its context is the latest step's whole input; output adds up.
450 * Its model is first-wins (the spawn's, else the first step's) but its effort is latest-wins: the latest
451 * step that carried one, and a step without one keeps the previous.
452 */
453export const applyStep = (c: AgentCard, s: { model: string; effort?: string | number; usage: StepUsage; stopReason: string | null }): AgentCard => {
454 const u = s.usage ?? {}
455 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
456 return {
457 ...c,
458 model: c.model || s.model,
459 effort: s.effort === undefined ? c.effort : String(s.effort),
460 steps: c.steps + 1,
461 ctx: ctx > 0 ? ctx : c.ctx,
462 out: c.out + (u.output_tokens ?? 0),
463 lastStop: s.stopReason,
464 }
465}
466
467/**
468 * A card's kind line: its type, its model when that is not the main loop's (`mainModel`, as prettyModel
469 * spells it), and its effort when it has one, as the main box says it.
470 */
471export const cardKind = (c: AgentCard, mainModel: string) =>
472 [c.type, ...(!c.model || prettyModel(c.model) === mainModel ? [] : [prettyModel(c.model)]), ...(c.effort ? [c.effort] : [])].join(' · ')
473
474export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) })
475
476export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => {
477 const found = loops.find(l => l.id === id)
478 const next = found
479 ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l))
480 : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
481 return next.slice(-60)
482}
483
484export const LOOP_ACTIVE_MS = 15_000
485export const isLoopActive = (l: Loop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
486
487/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
488export const lanes = (cards: AgentCard[], now: number, width: number) => {
489 const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
490 const span = Math.max(1, now - start)
491 return cards.map(c => {
492 const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
493 const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
494 return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
495 })
496}
497
498// ---------------------------------------------------------------- layout
499
500/** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */
501export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3))
502
503/** Terminal cells a string takes: CJK, full-width characters and emoji (U+1F300 to U+1FAFF, the branch marker among them) take two. */
504export const cells = (s: string) =>
505 [...s].reduce((n, ch) => n + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{1f300}-\u{1faff}]/u.test(ch) ? 2 : 1), 0)
506
507/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
508export const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
509 const out: T[] = []
510 let used = 0
511 for (const it of items) {
512 const w = cells(it.label) + 4
513 if (used + w > width) break
514 out.push(it)
515 used += w
516 }
517 return out
518}
519
520/** A card's title row: the task in the agent's own words, the type only when there is none. */
521export const cardTitle = (c: AgentCard) => c.description || c.type
522
523/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
524export const titleLines = (title: string, first: number, rest: number): [string, string] => {
525 const t = title.replace(/\s+/g, ' ').trim()
526 if (t.length <= first) return [t, '']
527 const cut = t.lastIndexOf(' ', first)
528 const at = cut > 0 ? cut : first
529 return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
530}
531
532/**
533 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
534 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
535 */
536export const promptLine = (text: string): { who: string; text: string } => {
537 const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
538 if (!tag) return { who: 'you', text: shorten(text, 70) }
539 const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
540 return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
541}
542
543/**
544 * A subagent's hand-back message: who sent it and the first line of what it said. The report
545 * follows a framing header in the message; without one, the first line after the opening tag.
546 */
547export const handbackOf = (text: string): { from: string; body: string } | null => {
548 const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
549 if (!from) return null
550 const afterHeader = text.split(/The report follows:\s*\n/)[1]
551 const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
552 const body =
553 rest
554 .split('\n')
555 .map(l => l.trim())
556 .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
557 return body ? { from, body } : null
558}
559
560/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
561export const adviceLine = (report: string) => {
562 const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
563 return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
564}
565
566export const elapsedOf = (c: AgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt
567
568// ---------------------------------------------------------------- Codex usage
569
570/**
571 * A usage window as the pane draws it: percent used, whether the reading is past its reset, and the
572 * time left to the reset. A window past its reset has restarted (the next request opens a new one),
573 * so it reads 0% used, still marked until a fresh reading arrives. Null when the window is unknown.
574 */
575export const windowView = (w: UsageWindow | undefined, now: number) => {
576 if (w === undefined || !Number.isFinite(w.usedPercent)) return null
577 const isStale = w.resetsAtMs !== undefined && w.resetsAtMs <= now
578 const pct = isStale ? 0 : Math.max(0, Math.min(100, Math.round(w.usedPercent)))
579 return { pct, isStale, resetIn: isStale ? '' : fmtResetIn(w.resetsAtMs ?? null, now) }
580}
581
582/** `5h 13%`, `~0%` past its reset, `—` unknown: the window in a few cells. */
583export const windowShort = (label: string, w: UsageWindow | undefined, now: number) => {
584 const v = windowView(w, now)
585 return `${label} ${v === null ? '—' : `${v.isStale ? '~' : ''}${v.pct}%`}`
586}
587
588const CLAUDE_WINDOWS = ['five_hour', 'seven_day']
589
590/** A rate-limit reading as a usage window, its reset kept when the API gave one. */
591export const limitWindow = (l: { pct: number; resetsAt?: number | null }): UsageWindow =>
592 l.resetsAt == null ? { usedPercent: l.pct } : { usedPercent: l.pct, resetsAtMs: l.resetsAt }
593
594/** Claude's 5-hour and 7-day windows in a few cells, `5h 19% · 7d 3%`; null before any reading. */
595export const claudeWindows = (limits: readonly { kind: string; pct: number; resetsAt?: number | null }[], now: number) => {
596 const shown = CLAUDE_WINDOWS.flatMap(kind => limits.filter(l => l.kind === kind).slice(0, 1))
597 return shown.length === 0 ? null : shown.map(l => windowShort(limitLabel(l.kind), limitWindow(l), now)).join(' · ')
598}
599
600// ---------------------------------------------------------------- usage rows
601
602/** The cells a round-bordered panel with one cell of padding on each side takes from its width. */
603export const PANEL_FRAME = 4
604const METER_MIN = 4
605const METER_MAX = 40
606/** The percent column, right-aligned after a space: `100%`, or `~100%` past its reset. */
607const METER_PCT_W = 5
608/** The detail column of a boxed row: `⏱ 10d23h` (two spaces after the clock) or `stale`. */
609const METER_DETAIL_W = 9
610
611/** One usage row as the pane draws it: `pct` null is a window not read yet. */
612export type Meter = { label: string; pct: number | null; isStale: boolean; detail: string }
613
614/** A usage window as a row: the time to its reset, or `stale` once past it, at 0% used. */
615export const windowMeter = (label: string, w: UsageWindow | undefined, now: number): Meter => {
616 const v = windowView(w, now)
617 if (v === null) return { label, pct: null, isStale: false, detail: '' }
618 return { label, pct: v.pct, isStale: v.isStale, detail: v.isStale ? 'stale' : v.resetIn ? `⏱ ${v.resetIn}` : '' }
619}
620
621/** ` 58%`, ` ~58%` past its reset: right-aligned in the percent column. */
622export const meterPercent = (m: Meter) => `${m.isStale ? '~' : ''}${m.pct ?? 0}%`.padStart(METER_PCT_W)
623
624/**
625 * The bar's cells for the rows of a panel `panelW` wide: the label column, a space, the bar, a space,
626 * the percent, a space, then the widest of the rows' details. One width for every row of the panel,
627 * so the bars start and end together whatever each row's detail is.
628 */
629export const meterWidth = (panelW: number, labelW: number, details: readonly string[]) =>
630 Math.min(METER_MAX, Math.max(METER_MIN, panelW - PANEL_FRAME - labelW - METER_PCT_W - 3 - Math.max(METER_DETAIL_W, ...details.map(cells))))
631
632/**
633 * The bar of a line drawn without a box, alone in its row (the context): whatever the label, the
634 * percent, its own detail and its note leave of `lineW`, with no upper cap. The detail has a space
635 * before it only when there is one; the note follows as written.
636 */
637export const lineMeterWidth = (lineW: number, labelW: number, detail: string, note = '') =>
638 Math.max(METER_MIN, lineW - labelW - METER_PCT_W - 2 - (detail ? 1 + cells(detail) : 0) - cells(note))
639
640/** The share of the pane the context line takes. */
641export const CTX_LINE_SHARE = 0.5
642
643/** `s` padded with spaces to `width` cells, a wide character counting two. */
644export const padCells = (s: string, width: number) => s + ' '.repeat(Math.max(0, width - cells(s)))
645
646// ---------------------------------------------------------------- branch
647
648/** How many unreviewed paths the unfolded list draws before counting the rest. */
649export const GIT_PATH_ROWS = 8
650
651/** `unreviewed 3 files`; `✓ unreviewed 0` when every change was seen; `unreviewed ?` when that cannot be known, never 0. */
652export const unreviewedLabel = (s: GitSnap) =>
653 s.unreviewed === undefined ? 'unreviewed ?' : s.unreviewed === 0 ? '✓ unreviewed 0' : `unreviewed ${plural(s.unreviewed, 'file')}`
654
655/** `changed 5 files`: files that differ from HEAD, untracked ones included. */
656export const changedLabel = (s: GitSnap) => `changed ${plural(s.changed, 'file')}`
657
658/** `↔ origin/main ↑2 ↓1`, or `no upstream`. */
659export const syncLabel = (s: GitSnap) => (s.upstream === undefined ? 'no upstream' : `↔ ${s.upstream} ↑${s.ahead ?? 0} ↓${s.behind ?? 0}`)
660
661/** The list of unreviewed paths is only worth unfolding when there are some. */
662export const canUnfold = (s: GitSnap) => (s.unreviewed ?? 0) > 0
663
664/**
665 * The paths to draw in `slots` rows, and how many unreviewed files are left out. Room for all of
666 * them draws every path; less room gives the last row to the count of those not drawn.
667 */
668export const pathLines = (s: GitSnap, slots: number): { paths: string[]; hidden: number } => {
669 const listed = s.unreviewedPaths
670 const total = s.unreviewed ?? 0
671 const more = total > listed.length ? 1 : 0
672 if (slots <= 0 || total === 0) return { paths: [], hidden: 0 }
673 if (slots >= listed.length + more) return { paths: listed, hidden: total - listed.length }
674 return { paths: listed.slice(0, slots - 1), hidden: total - (slots - 1) }
675}
676
677/** The rows the unfolded list takes: its paths and a closing count, within GIT_PATH_ROWS. */
678export const pathRows = (s: GitSnap, isOpen: boolean) => {
679 if (!isOpen) return 0
680 const { paths, hidden } = pathLines(s, GIT_PATH_ROWS)
681 return paths.length + (hidden > 0 ? 1 : 0)
682}
683
684// ---------------------------------------------------------------- Codex runs as cards
685
686/** A `codex exec` run the main session started, as a running card: no steps or tokens to tell. */
687export const codexCard = (id: string, call: { purpose: string; note?: string }, model: string, at: number, effort = ''): AgentCard => ({
688 ...normalizeCard({}),
689 id,
690 type: 'Codex',
691 model,
692 effort,
693 description: call.note ? `${call.purpose}: ${call.note}` : call.purpose,
694 spawnedAt: at,
695 codex: {},
696})
697
698/** A card ended: done when it went well, failed otherwise. A card already ended stays as it ended. */
699export const endCard = (c: AgentCard, ok: boolean, at: number): AgentCard =>
700 c.status !== 'running' ? c : { ...c, status: ok ? 'done' : 'failed', endedAt: at }
701
702/**
703 * The Codex runs a turn's stop settles: a foreground run still open was abandoned (failed); a
704 * background run the engine no longer lists has finished (done). Unknown lists settle nothing
705 * in the background. Answers the ids it ended, with how.
706 */
707export const stopCodex = (cards: AgentCard[], liveIds: readonly string[] | undefined): { id: string; ok: boolean }[] =>
708 cards.flatMap((c): { id: string; ok: boolean }[] => {
709 if (c.codex === undefined || c.status !== 'running') return []
710 if (c.codex.taskId === undefined) return [{ id: c.id, ok: false }]
711 return liveIds !== undefined && !liveIds.includes(c.codex.taskId) ? [{ id: c.id, ok: true }] : []
712 })
713
714/** The card a notification ends: by its background task id, else by the shell call that started it. */
715export const codexCardFor = (cards: AgentCard[], taskId: string, toolUseId: string | undefined) =>
716 cards.find(c => c.codex !== undefined && (c.codex.taskId === taskId || (toolUseId !== undefined && c.id === toolUseId)))
717
718// ---------------------------------------------------------------- panels side by side
719
720/** The narrowest pane that seats two panels in a row. */
721const PAIR_MIN_COLUMNS = 70
722
723/** The two panels' widths in a pane `w` wide, a cell between them, the left one taking the odd cell; null when too narrow. */
724export const pairWidths = (w: number): [number, number] | null =>
725 w < PAIR_MIN_COLUMNS ? null : [Math.ceil((w - 1) / 2), Math.floor((w - 1) / 2)]
726
727/** A row of the single column: one panel, or two side by side (the left one first). */
728export type PairRow = Panel | readonly [Panel, Panel]
729
730const PAIRS: readonly (readonly [Panel, Panel])[] = [
731 ['main', 'codex'],
732 ['git', 'gate'],
733]
734
735/**
736 * The single column's rows when panels may share one. The gate rises over the architect to sit
737 * beside the branch; a pair forms only for panels listed next to each other (in either order),
738 * so a person's own `panels` order is kept and a panel without its partner has a row to itself.
739 */
740export const pairRows = (panels: readonly Panel[]): PairRow[] => {
741 const g = panels.indexOf('git')
742 const k = panels.indexOf('gate')
743 const lifted =
744 g >= 0 && k > g && panels.slice(g + 1, k).every(p => p === 'architect')
745 ? [...panels.slice(0, g + 1), 'gate' as const, ...panels.slice(g + 1, k), ...panels.slice(k + 1)]
746 : [...panels]
747 const rows: PairRow[] = []
748 for (let i = 0; i < lifted.length; i++) {
749 const [a, b] = [lifted[i], lifted[i + 1]]
750 const pair = PAIRS.find(([x, y]) => (a === x && b === y) || (a === y && b === x))
751 if (pair) i++
752 rows.push(pair ?? (a as Panel))
753 }
754 return rows
755}
756
757/** A row of the single column: a panel, two side by side, or the context line under the main box. */
758export type ColumnRow = PairRow | 'ctx'
759
760/**
761 * The column's rows with the context line put in: right after the row that holds the main box, or
762 * after the Codex box when that is the next row. The context belongs to the main box, so a column
763 * without the main box has no context line.
764 */
765export const withCtx = (rows: readonly PairRow[]): ColumnRow[] => {
766 const main = rows.findIndex(r => r === 'main' || (typeof r !== 'string' && r.includes('main')))
767 if (main < 0) return [...rows]
768 const at = rows[main] === 'main' && rows[main + 1] === 'codex' ? main + 2 : main + 1
769 return [...rows.slice(0, at), 'ctx', ...rows.slice(at)]
770}
771
772/** The cells the effort bar and its space take ahead of the narrow effort line's effort. */
773const EFFORT_LEAD = 5
774
775/**
776 * The main box's effort line when two boxes share a row: `▮▮▮▯ high · auto · 12 req`. The bar is
777 * drawn by the caller; the mode is cut (or left out) so the line fits `inner` cells.
778 */
779export const narrowEffort = (effort: string, mode: string, steps: number, inner: number) => {
780 const label = effort || '—'
781 const count = ` · ${steps} req`
782 const room = inner - EFFORT_LEAD - cells(label) - cells(count) - 3
783 const shown = mode && room >= 2 ? shorten(mode, room) : ''
784 return { effort: label, tail: `${shown ? ` · ${shown}` : ''}${count}` }
785}
786
787/** The rows that items `widths` wide take in a wrapping row of `inner` cells with `gap` cells between them. */
788export const wrapRows = (widths: readonly number[], inner: number, gap: number) =>
789 widths.reduce(
790 (s, w) => (s.rows > 0 && s.used + gap + w <= inner ? { rows: s.rows, used: s.used + gap + w } : { rows: s.rows + 1, used: w }),
791 { rows: 0, used: 0 },
792 ).rows
793hooks/lib/codex.ts 76 lines1// Reading a `codex exec` call: what the shell command says it is for. The pane
2// shows each run as a subagent card. Plain text in, plain data out.
3
4import { argumentsEnd, commandAfter, maskShell } from './shell-mask'
5import type { Shell } from './shell-mask'
6
7/** What a shell command that runs `codex exec` tells about the run. */
8export type CodexCall = { purpose: string; note?: string }
9
10// `codex exec` counts only in command position (see ./shell-mask): in the code of
11// the command, with quoted strings, comments and heredoc bodies taken out, so
12// `echo note: codex exec`, `echo "codex exec"` and a script written by
13// `cat <<'EOF'` are not runs. Only the first 64 KB of a call is read (the masking
14// is one pass: 100 KB take about 20 ms); a string still open at that cut is masked on.
15const MAX_CHARS = 65_536
16// The flag is found in the code, where a quoted prompt cannot hold one; its value is read from the text as written.
17const SANDBOX_FLAG = /(?<![\w-])(?:--sandbox|-s)(?:=|[ \t])/
18const FLAG_VALUE = /^(?:[ \t]|\\\r?\n)*(?:"([^"]*)"|'([^']*)'|(\S+))/
19
20/** `codex exec` flags that take the next word as their value. */
21const VALUE_FLAGS = new Set([
22 '-m', '--model', '-s', '--sandbox', '-c', '--config', '-C', '--cd', '-p', '--profile',
23 '-o', '--output-last-message', '-i', '--image', '--color', '--output-schema',
24])
25
26const SANDBOX_PURPOSE: Readonly<Record<string, string>> = {
27 'read-only': 'Codex plan draft',
28 'workspace-write': 'Codex review',
29}
30const RESUME_PURPOSE = 'Codex resume'
31const PLAIN_PURPOSE = 'Codex'
32
33// The sandbox the run asks for: `--sandbox=X`, `--sandbox X` or `-s X`, the flag as the code has it, the value as written.
34const sandboxOf = (maskedArgs: string, args: string): string | undefined => {
35 const flag = SANDBOX_FLAG.exec(maskedArgs)
36 if (flag === null) return undefined
37
38 const value = FLAG_VALUE.exec(args.slice(flag.index + flag[0].length))
39
40 return value?.[1] ?? value?.[2] ?? value?.[3]
41}
42
43// The words after `resume` when it is the subcommand: the first word that is
44// not a flag (or a flag's value). A `resume` further on is part of a prompt.
45const resumeArgs = (args: string): string | undefined => {
46 const words = args.trim().split(/\s+/)
47 let i = 0
48
49 while (words[i]?.startsWith('-') === true) i += VALUE_FLAGS.has(words[i] ?? '') ? 2 : 1
50
51 return words[i] === 'resume' ? words.slice(i + 1).join(' ') : undefined
52}
53
54/**
55 * The call as Codex run, or undefined when the command does not run `codex exec`.
56 * The flags read are those of that command alone, up to the next `;`, `|` or `&`.
57 */
58export const describeCodex = (call: string, description?: string, shell: Shell = 'bash'): CodexCall | undefined => {
59 const command = call.slice(0, MAX_CHARS)
60 if (!/codex/i.test(command)) return undefined
61
62 const code = maskShell(command, shell, call.length > MAX_CHARS)
63 const execEnd = commandAfter(code)
64 if (execEnd < 0) return undefined
65
66 const argsEnd = argumentsEnd(code, execEnd)
67 const args = command.slice(execEnd, argsEnd)
68 const note = description === undefined || description === '' ? {} : { note: description }
69 const resume = resumeArgs(args)
70 if (resume !== undefined) return { purpose: RESUME_PURPOSE, ...note }
71
72 const sandbox = sandboxOf(code.slice(execEnd, argsEnd), args)
73
74 return { purpose: SANDBOX_PURPOSE[sandbox ?? ''] ?? PLAIN_PURPOSE, ...note }
75}
76hooks/lib/codex-quota.ts 297 lines1import type { CodexQuota, UsageWindow } from '../../types'
2import { CODEX_USAGE_URL, parseCodexAuth, parseCodexUsage, usageHeaders } from './codex-usage'
3
4// Codex's usage windows, model and effort, read from what Codex itself writes.
5// One `token_count` event in a Codex rollout carries
6// "rate_limits":{"limit_id":"codex","primary":{"used_percent":13.0,
7// "window_minutes":300,"resets_at":1791132451},"secondary":{...}|null,...}
8// Lines are huge (tool output rides along), so the figures are cut out with a
9// regex instead of parsing the whole line. Windows are told apart by their
10// length, not by position (which of primary/secondary is the weekly one is the
11// plan's choice), and a plan with a single window just has one.
12const WINDOW = /\{"used_percent":([\d.]+),"window_minutes":(\d+),"resets_at":(\d+|null)\}/g
13
14const FIVE_HOUR_MAX_MINUTES = 360
15const CODEX_MARK = '"limit_id":"codex"'
16
17const descending = (names: readonly string[]): string[] => [...names].sort().reverse()
18
19// Names are what Codex itself writes; anything else in these folders is not read.
20const YEAR = /^\d{4}$/
21const TWO_DIGITS = /^\d{2}$/
22const ROLLOUT = /^rollout-[\w.-]+\.jsonl$/
23
24/** A directory listing, as `$.fs.list` gives it; the caller hands it in so this stays free of `$`. */
25export type ListDir = (path: string) => Promise<readonly { name: string; kind: string; mtimeMs: number }[]>
26
27/** A file's own last-modified time, as `$.fs.stat` gives it; rejects when it cannot be read. */
28export type StatMtime = (path: string) => Promise<number>
29
30// A rollout is written to for as long as its session lives, so the one Codex is working in is the one
31// last modified, not the one started last (the folders and names say when it started). Looking for it
32// stays bounded: the newest ROLLOUT_DAYS_WALKED day folders (Codex makes a folder only for a day it
33// ran, so two weeks of use), and no further folder, month or year is listed once ROLLOUT_FILES_MAX
34// files are in hand (the day folder that crosses the mark is still taken whole).
35export const ROLLOUT_DAYS_WALKED = 14
36export const ROLLOUT_FILES_MAX = 200
37
38type Candidate = { path: string; mtimeMs: number }
39
40/**
41 * The rollout files under `root`/YYYY/MM/DD in the newest day folders, the most recently modified
42 * first (equal times: the later name, i.e. the later-started). Only plain files with the names Codex
43 * writes; a link, a folder or anything else is not listed. A folder whose listing fails counts as empty.
44 *
45 * A directory listing can show a file that is still held open for appending at an older time than it
46 * has (NTFS updates the directory entry late), so with `stat` each candidate's time is read from the
47 * file itself: one in-process call per candidate. Where that fails the listing's time stands.
48 */
49export const recentRollouts = async (list: ListDir, root: string, stat?: StatMtime): Promise<string[]> => {
50 const found: Candidate[] = []
51 let days = 0
52 const full = (): boolean => days >= ROLLOUT_DAYS_WALKED || found.length >= ROLLOUT_FILES_MAX
53 const entriesOf = (path: string) => list(path).catch(() => [])
54 const subdirs = async (path: string, shape: RegExp): Promise<string[]> =>
55 descending((await entriesOf(path)).filter(entry => entry.kind === 'dir' && shape.test(entry.name)).map(entry => entry.name))
56
57 walk: for (const year of await subdirs(root, YEAR)) {
58 if (full()) break
59 for (const month of await subdirs(`${root}/${year}`, TWO_DIGITS)) {
60 if (full()) break walk
61 for (const day of await subdirs(`${root}/${year}/${month}`, TWO_DIGITS)) {
62 if (full()) break walk
63
64 const dir = `${root}/${year}/${month}/${day}`
65 days += 1
66 for (const entry of await entriesOf(dir)) {
67 if (entry.kind === 'file' && ROLLOUT.test(entry.name)) found.push({ path: `${dir}/${entry.name}`, mtimeMs: entry.mtimeMs })
68 }
69 }
70 }
71 }
72
73 return byRecency(stat === undefined ? found : await Promise.all(found.map(file => withOwnTime(file, stat))))
74}
75
76const withOwnTime = async (file: Candidate, stat: StatMtime): Promise<Candidate> => {
77 try {
78 const mtimeMs = await stat(file.path)
79
80 return Number.isFinite(mtimeMs) ? { ...file, mtimeMs } : file
81 } catch {
82 return file
83 }
84}
85
86const byRecency = (files: readonly Candidate[]): string[] =>
87 [...files].sort((a, b) => b.mtimeMs - a.mtimeMs || (a.path < b.path ? 1 : a.path > b.path ? -1 : 0)).map(file => file.path)
88
89/** What the rules fall back to when no choice has been made. */
90export const DEFAULT_CODEX_MODEL = 'gpt-6-sol'
91
92type Raw = { usedPercent: number; minutes: number; resetsAtMs: number | undefined }
93
94const toWindow = ({ usedPercent, resetsAtMs }: Raw): UsageWindow =>
95 resetsAtMs === undefined ? { usedPercent } : { usedPercent, resetsAtMs }
96
97// A percent that is not a number (`1.2.3`) says nothing: that window is left out.
98const windowsOf = (line: string): Raw[] =>
99 [...line.slice(line.indexOf(CODEX_MARK)).matchAll(WINDOW)]
100 .slice(0, 2)
101 .map(match => ({
102 usedPercent: Number(match[1]),
103 minutes: Number(match[2]),
104 resetsAtMs: match[3] === 'null' ? undefined : Number(match[3]) * 1000,
105 }))
106 .filter(window => Number.isFinite(window.usedPercent))
107
108// The event's own time is the line's first key. A line without a parseable one is a reading of
109// unknown age: -Infinity, older than any reading that has a time.
110const EVENT_TIME = /^\{\s*"timestamp"\s*:\s*"([^"]+)"/
111const UNKNOWN_TIME = Number.NEGATIVE_INFINITY
112
113const eventTimeOf = (line: string): number => {
114 const stamp = EVENT_TIME.exec(line)?.[1]
115 const ms = stamp === undefined ? Number.NaN : Date.parse(stamp)
116
117 return Number.isNaN(ms) ? UNKNOWN_TIME : ms
118}
119
120/** A Codex rate-limit reading and the time of the event that carried it. */
121export type CodexReading = { atMs: number; limits: Pick<CodexQuota, 'fiveHour' | 'weekly'> }
122
123/** The newest Codex rate-limit reading in `text` (a rollout's tail), if any. */
124export const parseCodexReading = (text: string): CodexReading | undefined => {
125 const lines = text.split('\n')
126
127 for (let i = lines.length - 1; i >= 0; i -= 1) {
128 const line = lines[i] ?? ''
129 if (!line.includes(CODEX_MARK)) continue
130
131 const windows = windowsOf(line)
132 if (windows.length === 0) continue
133
134 const five = windows.find(w => w.minutes <= FIVE_HOUR_MAX_MINUTES)
135 const week = windows.find(w => w.minutes > FIVE_HOUR_MAX_MINUTES)
136
137 return {
138 atMs: eventTimeOf(line),
139 limits: {
140 fiveHour: five === undefined ? undefined : toWindow(five),
141 weekly: week === undefined ? undefined : toWindow(week),
142 },
143 }
144 }
145
146 return undefined
147}
148
149/** The newest Codex rate-limit windows in `text` (a rollout's tail), if any. */
150export const parseCodexLimits = (text: string): Pick<CodexQuota, 'fiveHour' | 'weekly'> | undefined =>
151 parseCodexReading(text)?.limits
152
153// The choice file is read by the rules and the slug ends up in `codex exec -m
154// <slug>`, so only what a real model name looks like is accepted. The config's
155// effort is drawn in cards and panels, so it has to look like a slug too.
156const SLUG = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/
157
158/** The reasoning effort `~/.codex/config.toml` selects; a value that is not slug-like (control characters, escapes, bidi marks) is no effort. */
159export const parseCodexEffort = (toml: string): string | undefined => {
160 const value = /^model_reasoning_effort\s*=\s*"([^"]+)"/m.exec(toml)?.[1]
161
162 return value !== undefined && SLUG.test(value) ? value : undefined
163}
164
165/** The model `~/.claude/model-choice.json` pins Codex calls to: its `codex` key. Read only. */
166export const parseChoice = (json: string): string | undefined => {
167 try {
168 const value: unknown = JSON.parse(json)
169 const choice = typeof value === 'object' && value !== null ? (value as { codex?: unknown }).codex : undefined
170
171 return typeof choice === 'string' && SLUG.test(choice) ? choice : undefined
172 } catch {
173 return undefined
174 }
175}
176
177const ROLLOUT_TAIL_LINES = 2000
178// Rollouts are queried ROLLOUTS_PER_ROUND at a time, the most recently modified first; a second round
179// runs only when the first found no codex reading (a rollout of an exec that never reached a token_count),
180// and nothing beyond ROLLOUTS_TRIED is read. So a refresh runs 2 PowerShell queries, at most 4.
181const ROLLOUTS_PER_ROUND = 2
182export const ROLLOUTS_TRIED = 4
183const CHOICE_FILE = '.claude/model-choice.json'
184
185// The newest codex reading in a rollout's tail. The path rides in the
186// environment, never in the command text, and only the matching lines come
187// back, so a big rollout cannot push the figures out of the output limit.
188export const ROLLOUT_QUERY =
189 `Get-Content -LiteralPath $env:ROLLOUT -Tail ${ROLLOUT_TAIL_LINES} | ` +
190 `Select-String -SimpleMatch '${CODEX_MARK}' | Select-Object -Last 5 | ForEach-Object { $_.Line }`
191
192/** The reads Codex's figures need; the caller hands them in so this stays free of `$`. */
193export type CodexIo = {
194 /** A file's text; empty when it cannot be read. */
195 readText: (path: string) => Promise<string>
196 list: ListDir
197 /** A file's own modified time (it rejects when it cannot be read); the listing's time can be stale for an open file. */
198 stat: StatMtime
199 /** What the rollout query prints for the rollout at `path`; empty when it fails. */
200 queryRollout: (path: string) => Promise<string>
201 /** A GET of `url` with `headers`; it may reject. Used only when the server is asked (see readCodexQuota). */
202 fetch?: (url: string, headers: Record<string, string>) => Promise<{ ok: boolean; text: string }>
203}
204
205// The account's windows as the ChatGPT backend reports them, signed with the ChatGPT login Codex
206// keeps in auth.json (see ./codex-usage). The token is used for this one request and goes nowhere
207// else. No login, an expired token (Codex refreshes it when it next runs; this never writes it), a
208// refusal or an answer of another shape: undefined, and the rollouts are read instead.
209const serverWindows = async (root: string, io: CodexIo): Promise<Pick<CodexQuota, 'fiveHour' | 'weekly'> | undefined> => {
210 if (io.fetch === undefined) return undefined
211 const auth = parseCodexAuth(await io.readText(`${root}/auth.json`))
212 if (auth === undefined) return undefined
213 try {
214 const answer = await io.fetch(CODEX_USAGE_URL, usageHeaders(auth))
215
216 return answer.ok ? parseCodexUsage(answer.text) : undefined
217 } catch {
218 return undefined
219 }
220}
221
222/**
223 * Codex's model is the one the choice file pins its calls to (the rules' default
224 * without one) and its reasoning effort comes from Codex's config: both are
225 * only displayed. Its 5-hour and weekly windows come, when `askServer` is set (the person pressed
226 * refresh) and the login allows, from the ChatGPT backend (current even when Codex has not run here,
227 * dated `nowMs`); otherwise, or when that fails, from the newest
228 * token_count event in a rollout, the only place the CLI records them. Of the most recently modified
229 * rollouts the reading with the latest event time is shown, not the one from the rollout started last.
230 */
231export const readCodexQuota = async (
232 home: string,
233 io: CodexIo,
234 options: { askServer?: boolean; nowMs?: number } = {},
235): Promise<CodexQuota> => {
236 const root = `${home}/.codex`
237 const model = parseChoice(await io.readText(`${home}/${CHOICE_FILE}`)) ?? DEFAULT_CODEX_MODEL
238 const effort = parseCodexEffort(await io.readText(`${root}/config.toml`))
239 const base = { model, ...(effort === undefined ? {} : { effort }) }
240
241 const server = options.askServer === true ? await serverWindows(root, io) : undefined
242 if (server !== undefined) {
243 const at = options.nowMs
244 return { ...base, ...server, ...(at === undefined ? {} : { readAtMs: at, serverAtMs: at }) }
245 }
246
247 const paths = (await recentRollouts(io.list, `${root}/sessions`, io.stat).catch(() => [])).slice(0, ROLLOUTS_TRIED)
248 for (let from = 0; from < paths.length; from += ROLLOUTS_PER_ROUND) {
249 const latest = latestOf(await Promise.all(paths.slice(from, from + ROLLOUTS_PER_ROUND).map(path => readingOf(io, path))))
250 if (latest !== undefined) return { ...base, ...latest.limits, ...(Number.isFinite(latest.atMs) ? { readAtMs: latest.atMs } : {}) }
251 }
252
253 return { ...base, note: 'no usage recorded yet' }
254}
255
256// A query that fails is a rollout with no reading: it must not take the others' readings with it.
257const readingOf = async (io: CodexIo, path: string): Promise<CodexReading | undefined> => {
258 try {
259 return parseCodexReading(await io.queryRollout(path))
260 } catch {
261 return undefined
262 }
263}
264
265// The latest event wins. On a tie the earlier one in `readings` stays: they come most recently
266// modified first, so that is the rollout modified last.
267const latestOf = (readings: readonly (CodexReading | undefined)[]): CodexReading | undefined =>
268 readings.reduce<CodexReading | undefined>(
269 (best, reading) => (reading !== undefined && (best === undefined || reading.atMs > best.atMs) ? reading : best),
270 undefined,
271 )
272
273/**
274 * The panel's figures after a new reading: the windows of whichever reading is newer (a reading of
275 * unknown time is the oldest), a window the newer one lacks kept from the other. So a server reading
276 * the person asked for is not undone by an older rollout at the next poll, and Codex used here later
277 * shows through. The model and effort are the new reading's; the time of the last server answer stays.
278 */
279export const mergeCodex = (old: CodexQuota | null, fresh: CodexQuota): CodexQuota => {
280 if (old === null) return fresh
281 const isNewer = (fresh.readAtMs ?? Number.NEGATIVE_INFINITY) >= (old.readAtMs ?? Number.NEGATIVE_INFINITY)
282 const [first, other] = isNewer ? [fresh, old] : [old, fresh]
283 const fiveHour = first.fiveHour ?? other.fiveHour
284 const weekly = first.weekly ?? other.weekly
285 const readAtMs = isNewer ? fresh.readAtMs : old.readAtMs
286 const serverAtMs = fresh.serverAtMs ?? old.serverAtMs
287 const { note, readAtMs: _r, serverAtMs: _s, fiveHour: _f, weekly: _w, ...rest } = fresh
288 return {
289 ...rest,
290 ...(fiveHour === undefined ? {} : { fiveHour }),
291 ...(weekly === undefined ? {} : { weekly }),
292 ...(fiveHour === undefined && weekly === undefined && note !== undefined ? { note } : {}),
293 ...(readAtMs === undefined ? {} : { readAtMs }),
294 ...(serverAtMs === undefined ? {} : { serverAtMs }),
295 }
296}
297hooks/lib/fable.ts 59 lines1import type { UsageWindow } from '../../types'
2
3// The Fable weekly window: not in the session's rate limits, so it is asked of
4// /api/oauth/usage. Plain data in and out; the hooks do the calling.
5
6const clampPercent = (value: number): number => Math.min(100, Math.max(0, value))
7
8const windowOf = (usedPercent: number, resetsAtMs: number): UsageWindow =>
9 Number.isNaN(resetsAtMs) ? { usedPercent } : { usedPercent, resetsAtMs }
10
11// Per-model weekly windows (Fable) are not in the session's rate limits. The
12// server reports them in /api/oauth/usage as `limits[]` entries:
13// {"kind":"weekly_scoped","scope":{"model":{"display_name":"Fable"}},
14// "percent":12,"resets_at":"2026-10-11T08:00:00+00:00"}
15const isRecord = (value: unknown): value is Record<string, unknown> =>
16 typeof value === 'object' && value !== null && !Array.isArray(value)
17
18const isFableLimit = (limit: Record<string, unknown>): boolean => {
19 const model = isRecord(limit.scope) && isRecord(limit.scope.model) ? limit.scope.model : {}
20
21 return (
22 limit.kind === 'weekly_scoped' &&
23 /fable/i.test(String(model.display_name)) &&
24 typeof limit.percent === 'number' &&
25 Number.isFinite(limit.percent)
26 )
27}
28
29/** The Fable weekly window out of an /api/oauth/usage body, if the account has one. */
30export const fableFromUsage = (json: string): UsageWindow | undefined => {
31 try {
32 const data: unknown = JSON.parse(json)
33 const limits = isRecord(data) && Array.isArray(data.limits) ? data.limits : []
34 const hit = limits.filter(isRecord).find(isFableLimit)
35 if (hit === undefined || typeof hit.percent !== 'number') return undefined
36
37 return windowOf(clampPercent(hit.percent), typeof hit.resets_at === 'string' ? Date.parse(hit.resets_at) : NaN)
38 } catch {
39 return undefined
40 }
41}
42
43export const FABLE_POLL_MS = 90_000
44const FABLE_BACKOFF_MAX_MS = 600_000
45
46/**
47 * How long to wait before asking again: the usual interval after an answer,
48 * and after a failure double the last wait up to ten minutes, or as long as
49 * the server asked for in `retry-after` (seconds) when that is longer.
50 */
51export const nextFableDelay = (previousMs: number, isOk: boolean, retryAfter: string | undefined): number => {
52 if (isOk) return FABLE_POLL_MS
53
54 const asked = Number(retryAfter) * 1000
55 const backoff = Math.min(FABLE_BACKOFF_MAX_MS, Math.max(FABLE_POLL_MS, previousMs) * 2)
56
57 return Number.isFinite(asked) && asked > 0 ? Math.min(FABLE_BACKOFF_MAX_MS, Math.max(asked, backoff)) : backoff
58}
59hooks/lib/notify.ts 53 lines1// Reading a background task's notification and a shell result's hand-off to
2// the background. Plain text and plain data in and out. Only which task ended
3// and how is read: nothing a notification names is opened.
4
5/** One finished background task, as its notification reports it. */
6export type Notification = { id: string; toolUseId?: string; ok: boolean }
7
8const BLOCK = /<task-notification>([\s\S]*?)<\/task-notification>/g
9const FAILED_STATUSES = new Set(['failed', 'killed', 'error'])
10
11const tag = (block: string, name: string): string | undefined =>
12 new RegExp(`<${name}>([^<]*)</${name}>`).exec(block)?.[1]?.trim() || undefined
13
14const SUMMARY = '<summary>'
15
16const parseBlock = (block: string): Notification | undefined => {
17 // The task's own tags come before the summary. What follows is text a task wrote (a summary, a
18 // result) and may hold tags of its own: none of it is the task's.
19 const at = block.indexOf(SUMMARY)
20 const head = at < 0 ? block : block.slice(0, at)
21 const id = tag(head, 'task-id')
22 if (id === undefined) return undefined
23
24 const toolUseId = tag(head, 'tool-use-id')
25 // The exit code is the summary's own; a result may quote any number.
26 const code = /\(exit code (\d+)\)/.exec(at < 0 ? '' : (tag(block.slice(at), 'summary') ?? ''))?.[1]
27 const failed = FAILED_STATUSES.has(tag(head, 'status') ?? '') || (code !== undefined && Number(code) !== 0)
28
29 return { id, ...(toolUseId === undefined ? {} : { toolUseId }), ok: !failed }
30}
31
32/** The notifications in a text; one text may merge several. A task is reported once, by the first block that names it. */
33export const parseNotifications = (text: string): Notification[] => {
34 const seen = new Set<string>()
35 const found: Notification[] = []
36
37 for (const match of text.matchAll(BLOCK)) {
38 const note = parseBlock(match[1] ?? '')
39 if (note === undefined || seen.has(note.id)) continue
40 seen.add(note.id)
41 found.push(note)
42 }
43
44 return found
45}
46
47/** The background task id of a shell result that moved to the background. */
48export const backgroundIdOf = (result: unknown): string | undefined => {
49 const id = typeof result === 'object' && result !== null ? (result as { backgroundTaskId?: unknown }).backgroundTaskId : undefined
50
51 return typeof id === 'string' && id !== '' ? id : undefined
52}
53hooks/lib/serial.ts 16 lines1/**
2 * A queue that runs the jobs it is given one after another, each starting when
3 * the one before has finished, whether that one succeeded or not. Every job
4 * gets its own result (or its own rejection).
5 */
6export const serial = (): (<T>(job: () => Promise<T>) => Promise<T>) => {
7 let tail: Promise<unknown> = Promise.resolve()
8
9 return job => {
10 const run = tail.then(job)
11 tail = run.catch(() => undefined)
12
13 return run
14 }
15}
16hooks/lib/sessions.ts 90 lines1import type { LiveSession } from '../../types'
2
3// Every live Claude Code process keeps a registry file, ~/.claude/sessions/<pid>.json:
4// {"pid":31600,"sessionId":"…","name":"jev-router-41","entrypoint":"cli",…}
5// A crashed process can leave its file behind, so a row counts only while its
6// pid is still running as a Claude Code process (pids are reused). Only what
7// the wait banner needs is read: which session, which process, what to call it.
8
9export type SessionFile = { pid: number; sessionId: string; name: string }
10
11const MAX_NAME = 80
12
13export const parseSessionFile = (json: string): SessionFile | undefined => {
14 try {
15 const value: Record<string, unknown> | null = JSON.parse(json)
16 const { pid, sessionId, name } = value ?? {}
17
18 // The pid names a file in the marks folder, so it must be an ordinary process number.
19 if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0 || typeof sessionId !== 'string') return undefined
20
21 return { pid, sessionId, name: typeof name === 'string' ? name.slice(0, MAX_NAME) : sessionId.slice(0, 8) }
22 } catch {
23 return undefined
24 }
25}
26
27/** The registry files that parse; the rest are skipped. */
28export const parseSessionFiles = (texts: readonly string[]): SessionFile[] =>
29 texts.flatMap(text => {
30 const file = parseSessionFile(text)
31
32 return file === undefined ? [] : [file]
33 })
34
35/** The running processes in `tasklist /FO CSV /NH` output, pid to image name: `"claude.exe","31600",…`. */
36export const parseTasklist = (csv: string): Map<number, string> =>
37 new Map(
38 csv.split('\n').flatMap(line => {
39 const match = /^"([^"]*)","(\d+)"/.exec(line.trim())
40
41 return match === null ? [] : [[Number(match[2]), match[1] ?? ''] as [number, string]]
42 }),
43 )
44
45const CLAUDE_IMAGE = /^(claude|node)(\.exe)?$/i
46
47/** The live sessions: those whose pid still runs as Claude Code. A session id appears once. */
48export const liveSessions = (
49 files: readonly SessionFile[],
50 processes: ReadonlyMap<number, string>,
51 hereId: string,
52): LiveSession[] => {
53 const isAlive = (file: SessionFile): boolean => CLAUDE_IMAGE.test(processes.get(file.pid) ?? '')
54 const rows = files
55 .filter(isAlive)
56 .map<LiveSession>(file => ({ id: file.sessionId, pid: file.pid, name: file.name, isHere: file.sessionId === hereId }))
57
58 return [...new Map(rows.map(row => [row.id, row])).values()]
59}
60
61const REGISTRY_DIR = '.claude/sessions'
62
63/** The reads the registry needs; the caller hands them in so this stays free of `$`. */
64export type SessionIo = {
65 list: (dir: string) => Promise<readonly { name: string; size: number; mtimeMs: number; kind: string; isLink: boolean }[]>
66 /** A file's text; empty when it cannot be read. */
67 read: (path: string) => Promise<string>
68 /** The process table, as `tasklist /FO CSV /NH` prints it. */
69 tasklist: () => Promise<{ exitCode: number; stdout: string }>
70}
71
72/** At most this many registry files are read, the newest; and none over this many bytes (a real one is a few hundred). */
73const MAX_FILES = 32
74const MAX_FILE_BYTES = 65_536
75
76/** The live sessions out of the registry and the process table; a table that fails rejects. */
77export const readLiveSessions = async (home: string, hereId: string, io: SessionIo): Promise<LiveSession[]> => {
78 const dir = `${home}/${REGISTRY_DIR}`
79 const names = (await io.list(dir))
80 .filter(entry => entry.kind === 'file' && !entry.isLink && /^\d+\.json$/.test(entry.name) && entry.size <= MAX_FILE_BYTES)
81 .sort((a, b) => b.mtimeMs - a.mtimeMs)
82 .slice(0, MAX_FILES)
83 .map(entry => entry.name)
84 const files = parseSessionFiles(await Promise.all(names.map(name => io.read(`${dir}/${name}`))))
85 const table = await io.tasklist()
86 if (table.exitCode !== 0) throw new Error(`tasklist exited ${table.exitCode}`)
87
88 return liveSessions(files, parseTasklist(table.stdout), hereId)
89}
90hooks/lib/waiting.ts 79 lines1import type { LiveSession, WaitReason, WaitingRow } from '../../types'
2import { ago, plain } from './plain'
3
4// Each session keeps one small mark file, named by its pid, saying whether it
5// is waiting on the person right now and since when. The session id inside it
6// tells a live session's mark from one a reused pid left behind. The format is
7// the one status-sidebar writes and reads, so sessions running either see each other.
8
9const MARK_DIR = '.claude/mods-state/status-sidebar/waiting'
10
11/** The mark file of the session running as `pid`. */
12export const markPath = (home: string, pid: number): string => `${home}/${MARK_DIR}/${pid}.json`
13
14export const markText = (sessionId: string, reason: WaitReason | null, sinceMs: number): string =>
15 JSON.stringify({ sessionId, reason, sinceMs })
16
17const REASONS: readonly string[] = ['permission', 'question']
18
19/** The wait a mark records for `sessionId`; undefined when cleared, broken or another session's. */
20export const parseMark = (text: string, sessionId: string): { reason: WaitReason; sinceMs: number } | undefined => {
21 try {
22 const value: Record<string, unknown> | null = JSON.parse(text)
23 const { reason, sinceMs } = value ?? {}
24
25 if (value?.sessionId !== sessionId || typeof reason !== 'string' || !REASONS.includes(reason)) return undefined
26
27 return { reason: reason as WaitReason, sinceMs: typeof sinceMs === 'number' ? sinceMs : 0 }
28 } catch {
29 return undefined
30 }
31}
32
33/** The other live sessions waiting on the person, the longest wait first. */
34export const waitingRows = (rows: readonly LiveSession[], markOf: (pid: number) => string): WaitingRow[] =>
35 rows
36 .filter(row => !row.isHere)
37 .flatMap(row => {
38 const mark = parseMark(markOf(row.pid), row.id)
39
40 return mark === undefined ? [] : [{ id: row.id, name: row.name, ...mark }]
41 })
42 .sort((a, b) => a.sinceMs - b.sinceMs)
43
44/** The other live sessions waiting on the person: each one's mark file is read (`readFile`; empty when it cannot be). */
45export const readWaits = async (
46 home: string,
47 rows: readonly LiveSession[],
48 readFile: (path: string) => Promise<string>,
49): Promise<WaitingRow[]> => {
50 const marks = new Map(
51 await Promise.all(rows.filter(row => !row.isHere).map(async row => [row.pid, await readFile(markPath(home, row.pid))] as const)),
52 )
53
54 return waitingRows(rows, pid => marks.get(pid) ?? '')
55}
56
57/** True when two reads found the same waits, in the same order. */
58export const sameWaits = (a: readonly WaitingRow[], b: readonly WaitingRow[]): boolean =>
59 JSON.stringify(a) === JSON.stringify(b)
60
61/** A tool that stops to ask the person: a question, or a plan to approve. */
62export const isQuestionTool = (tool: string): boolean => tool === 'AskUserQuestion' || tool === 'ExitPlanMode'
63
64const WAIT_LABEL: Record<WaitReason, string> = { permission: 'permission', question: 'question' }
65
66/**
67 * The banner line: the session that has waited longest, why, and since when,
68 * with how many more wait. Undefined while nobody does. Permission waits are
69 * known to start and not to end until their call returns, hence "waiting on you".
70 */
71export const bannerText = (rows: readonly WaitingRow[], nowMs: number): string | undefined => {
72 const [first] = rows
73 if (first === undefined) return undefined
74
75 const more = rows.length > 1 ? ` · +${rows.length - 1} more` : ''
76
77 return `! waiting on you: ${plain(first.name)} ${WAIT_LABEL[first.reason]} ${ago(first.sinceMs, nowMs)}${more}`
78}
79hooks/lib/git.ts 159 lines1import type { GitSnap } from '../../types'
2import { plain } from './plain'
3
4/** How many unreviewed paths the branch panel can unfold. */
5export const UNREVIEWED_SHOWN = 20
6
7// The work tree as `git status --porcelain=v2 --branch -z -uall` reports it:
8// NUL-separated records, `# branch.*` headers first, a rename (`2`) followed
9// by one more record holding its old path. Paths are relative to the root.
10
11/**
12 * One changed path. A symbolic link is not hashed (`hash-object` would follow
13 * it to whatever it points at): `isLink` when status says the work tree holds
14 * one. Status does not say that of an untracked path, so `isUntracked` tells
15 * the caller which to look at itself.
16 */
17export type GitEntry = { path: string; isMissing: boolean; isUntracked?: true; isLink?: true }
18
19/** Stands for the content of a changed path that was not hashed. A review never stores it, so the path stays unreviewed. */
20export const UNHASHED = 'unhashed'
21
22export type GitStatus = { branch: string; upstream?: string; ahead?: number; behind?: number; entries: GitEntry[] }
23
24// How many space-separated fields come before the path, per record kind.
25const PATH_AFTER: Record<string, number> = { '1': 8, '2': 9, u: 10, '?': 1 }
26
27const pathOf = (record: string, fields: number): string => record.split(' ').slice(fields).join(' ')
28
29// Gone from the work tree: deleted there, or deleted from the index and not put back.
30const isMissing = (xy: string): boolean => xy[1] === 'D' || (xy[0] === 'D' && xy[1] === '.')
31
32// The work-tree mode sits after the head and index modes: `1`/`2` records read `<kind> <XY> <sub> <mH> <mI> <mW> …`,
33// an unmerged one `u <XY> <sub> <m1> <m2> <m3> <mW> …`. 120000 is a symbolic link.
34const MODE_AT: Record<string, number> = { '1': 5, '2': 5, u: 6 }
35const SYMLINK = '120000'
36
37const linkFlag = (kind: string, fields: readonly string[]): { isLink?: true } =>
38 fields[MODE_AT[kind] ?? -1] === SYMLINK ? { isLink: true } : {}
39
40export const parseGitStatus = (output: string): GitStatus | undefined => {
41 const records = output.split('\0')
42 let branch: string | undefined
43 let upstream: string | undefined
44 let ahead: number | undefined
45 let behind: number | undefined
46 const entries: GitEntry[] = []
47
48 for (let i = 0; i < records.length; i++) {
49 const record = records[i] ?? ''
50 const kind = record.split(' ', 1)[0] ?? ''
51
52 if (record.startsWith('# branch.head ')) branch = record.slice('# branch.head '.length)
53 if (record.startsWith('# branch.upstream ')) upstream = record.slice('# branch.upstream '.length)
54 const ab = /^# branch\.ab \+(\d+) -(\d+)$/.exec(record)
55 if (ab !== null) {
56 ahead = Number(ab[1])
57 behind = Number(ab[2])
58 }
59
60 const fields = PATH_AFTER[kind]
61 if (fields === undefined) continue
62 const xy = kind === '?' ? '??' : (record.split(' ')[1] ?? '')
63 // A conflicted file is on disk in some form unless both sides deleted it.
64 entries.push({
65 path: pathOf(record, fields),
66 isMissing: kind === 'u' ? xy === 'DD' : isMissing(xy),
67 ...(kind === '?' ? { isUntracked: true as const } : linkFlag(kind, record.split(' '))),
68 })
69 if (kind === '2') i++
70 }
71
72 return branch === undefined ? undefined : { branch, upstream, ahead, behind, entries }
73}
74
75/** The paths `git hash-object --stdin-paths` is given: the files still on disk (not a deleted path, not a link). */
76export const hashTargets = (entries: readonly GitEntry[]): string[] =>
77 entries.filter(entry => !entry.isMissing && entry.isLink !== true).map(entry => entry.path)
78
79/**
80 * Each changed path with its content hash (`deleted` for a missing one,
81 * `unhashed` for a link), from the hash-object output for
82 * `hashTargets(entries)`; undefined when the two do not line up.
83 */
84export const withHashes = (entries: readonly GitEntry[], output: string): Record<string, string> | undefined => {
85 const hashes = output.split('\n').map(line => line.trim()).filter(line => line !== '')
86 if (hashes.length !== hashTargets(entries).length) return undefined
87
88 let next = 0
89
90 const valueOf = (entry: GitEntry): string => {
91 if (entry.isMissing) return 'deleted'
92
93 return entry.isLink === true ? UNHASHED : (hashes[next++] ?? '')
94 }
95
96 return Object.fromEntries(entries.map(entry => [entry.path, valueOf(entry)]))
97}
98
99/** What a review stores of the hashes it saw: not the paths that were not hashed, which must stay unreviewed. */
100export const storable = (hashes: Record<string, string>): Record<string, string> =>
101 Object.fromEntries(Object.entries(hashes).filter(([, hash]) => hash !== UNHASHED))
102
103/** A stored path-to-hash map as read back from the store; anything else is no record. */
104export const asHashes = (value: unknown): Record<string, string> =>
105 value === null || typeof value !== 'object' || Array.isArray(value)
106 ? {}
107 : Object.fromEntries(Object.entries(value).filter((pair): pair is [string, string] => typeof pair[1] === 'string'))
108
109/** Changed files whose content the last review did not see as it is now. */
110export const unreviewedPaths = (current: Record<string, string>, reviewed: Record<string, string>): string[] =>
111 Object.entries(current)
112 .filter(([path, hash]) => reviewed[path] !== hash)
113 .map(([path]) => path)
114
115const REVIEW = /review/i
116
117const field = (input: unknown, key: string): string => {
118 const value = (input as Record<string, unknown> | null)?.[key]
119
120 return typeof value === 'string' ? value : ''
121}
122
123/** A tool call that starts a review: a reviewing skill, or a reviewer subagent. */
124export const isReviewCall = (tool: string, input: unknown): boolean =>
125 (tool === 'Skill' && REVIEW.test(field(input, 'skill'))) ||
126 (tool === 'Agent' && REVIEW.test(field(input, 'subagent_type')))
127
128/** A typed slash command that starts a review (`/code-review high`). */
129export const isReviewPrompt = (text: string): boolean => /^\/[\w:-]*review(?=\s|$)/i.test(text.trim())
130
131/** Only a person's own prompt can be a typed review: a notification or a peer message is not one. */
132export const isPersonPrompt = (originKind: string): boolean =>
133 originKind === 'composer' || originKind === 'bridge' || originKind === 'sdk'
134
135/**
136 * The branch panel's figures from a status, its hashes and what the last review
137 * saw. When either the hashes or the review record could not be had, how many
138 * files are unreviewed is unknown (absent), never zero.
139 */
140export const snapOf = (
141 status: GitStatus,
142 hashes: Record<string, string> | undefined,
143 reviewed: Record<string, string> | undefined,
144): GitSnap => {
145 // What the repository names (branch, upstream, paths) is drawn as text, so it is made plain first.
146 const base = {
147 branch: plain(status.branch),
148 upstream: status.upstream === undefined ? undefined : plain(status.upstream),
149 ahead: status.ahead,
150 behind: status.behind,
151 changed: status.entries.length,
152 }
153 if (hashes === undefined || reviewed === undefined) return { ...base, unreviewedPaths: [] }
154
155 const unseen = unreviewedPaths(hashes, reviewed)
156
157 return { ...base, unreviewed: unseen.length, unreviewedPaths: unseen.slice(0, UNREVIEWED_SHOWN).map(plain) }
158}
159hooks/lib/git-tree.ts 155 lines1import { hashTargets, parseGitStatus, withHashes } from './git'
2import type { GitEntry, GitStatus } from './git'
3import { CONFIG_QUERY, filterDrivers, filterOverrides } from './git-filters'
4
5// Reading the session's work tree through git. Nothing here reaches `$`: the
6// caller hands in a runner, so the exact commands are spelled in one place and
7// a test can see every one of them. Only read-only git runs: `rev-parse`,
8// `config` (to list), `status` and `hash-object` without `-w`;
9// `--no-optional-locks` keeps even `status` from refreshing the index, so a poll
10// never writes into the repository or takes a lock a person's own git command
11// could trip over.
12//
13// The repository's own configuration is not trusted either, since a poll would
14// otherwise run whatever a hostile one names:
15// - `core.fsmonitor=false` stops `status` from running a hook program;
16// - `protocol.ext.allow=never` refuses the `ext::` transport, which runs a command;
17// - `GIT_NO_LAZY_FETCH=1` keeps a partial clone from contacting its promisor remote
18// (git 2.44 and later; older git ignores it, so a repository that is a partial
19// clone at all is not read, see ./git-filters);
20// - `status --ignore-submodules=all` leaves submodules (and their filters) out;
21// - each `filter.<driver>` is listed first and switched off for `status` and
22// `hash-object` (see ./git-filters), and `hash-object --no-filters` skips filters anyway.
23// Review time and poll time hash the same way, so their hashes still compare.
24// If the filters cannot be listed, nothing more is read. Output the engine cut
25// short (it keeps the first 4 MiB) is never taken for a whole answer: a listing
26// that was cut could hide a driver, and a cut status would give partial counts.
27
28export type GitRun = (
29 argv: readonly string[],
30 init?: { cwd?: string; stdin?: string; env?: Record<string, string> },
31) => Promise<{ exitCode: number; stdout: string; isStdoutTruncated?: boolean }>
32
33/** What is known of a path (absolute): whether it is itself a symbolic link, and where it really lands (absent: nowhere). */
34export type Inspect = (path: string) => Promise<{ isLink: boolean; realPath?: string }>
35
36/** The repository root, what differs from HEAD and each changed file's content hash. */
37export type Tree = {
38 root: string
39 status: GitStatus
40 /** Undefined when the hashes could not be had: nothing is then counted as unreviewed. */
41 hashes?: Record<string, string>
42}
43
44export const GIT_TIMEOUT_MS = 15_000
45
46/** Set on every git call. */
47export const GIT_ENV: Readonly<Record<string, string>> = { GIT_NO_LAZY_FETCH: '1' }
48
49/** More untracked files than this are not each looked at for being a link: the hashes are then unknown. */
50export const MAX_UNTRACKED = 500
51
52const GIT = [
53 'git',
54 '--no-optional-locks',
55 '-c',
56 'core.quotePath=false',
57 '-c',
58 'core.fsmonitor=false',
59 '-c',
60 'protocol.ext.allow=never',
61] as const
62
63/** What the last review saw is kept per repository root. */
64export const reviewedKey = (root: string): string => `git-reviewed:${root}`
65
66const call = (run: GitRun, argv: readonly string[], init: { cwd?: string; stdin?: string } = {}) =>
67 run(argv, { ...init, env: { ...GIT_ENV } })
68
69// The `-c` options that switch off every configured filter driver. `git config --get-regexp`
70// exits 1 and prints nothing when nothing matches: that is no filters, not a failure. Any
71// other failure, a listing the engine cut short, or a name that cannot be passed safely
72// (or a partial clone) is undefined: read nothing further.
73const filterArgs = async (run: GitRun, root: string): Promise<string[] | undefined> => {
74 const listed = await call(run, [...GIT, 'config', '-z', '--name-only', '--get-regexp', CONFIG_QUERY], { cwd: root })
75 if (listed.isStdoutTruncated === true) return undefined
76 if (listed.exitCode === 1) return listed.stdout === '' ? [] : undefined
77 if (listed.exitCode !== 0) return undefined
78
79 const drivers = filterDrivers(listed.stdout)
80
81 return drivers === undefined ? undefined : filterOverrides(drivers)
82}
83
84// Paths are compared in one spelling: forward slashes, no trailing one, lower case (Windows paths).
85const spelled = (path: string): string => path.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
86
87// A symbolic link or a junction (which lands elsewhere) has to be told by the file system for the
88// paths status does not describe (untracked ones). One that is a link, lands outside the
89// repository (under its root as spelled, or under the root's own real path) or leads nowhere,
90// counts as a link. Undefined when there are too many to check.
91const markLinks = async (entries: readonly GitEntry[], root: string, inspect: Inspect): Promise<GitEntry[] | undefined> => {
92 const untracked = entries.filter(entry => entry.isUntracked === true && !entry.isMissing)
93 if (untracked.length > MAX_UNTRACKED) return undefined
94 if (untracked.length === 0) return [...entries]
95
96 const rootReal = (await inspect(root)).realPath
97 const inside = [root, ...(rootReal === undefined ? [] : [rootReal])].map(spelled)
98 const isOutside = ({ isLink, realPath }: { isLink: boolean; realPath?: string }): boolean => {
99 if (isLink || realPath === undefined) return true
100 const real = spelled(realPath)
101
102 return !inside.some(base => real === base || real.startsWith(`${base}/`))
103 }
104 const links = new Set(
105 (await Promise.all(untracked.map(async entry => [entry.path, isOutside(await inspect(`${root}/${entry.path}`))] as const)))
106 .filter(([, outside]) => outside)
107 .map(([path]) => path),
108 )
109
110 return entries.map(entry => (links.has(entry.path) ? { ...entry, isLink: true as const } : entry))
111}
112
113const hashesOf = async (
114 run: GitRun,
115 root: string,
116 entries: readonly GitEntry[],
117 off: readonly string[],
118): Promise<Record<string, string> | undefined> => {
119 const targets = hashTargets(entries)
120 if (targets.length === 0) return withHashes(entries, '')
121
122 const hashed = await call(run, [...GIT, ...off, 'hash-object', '--no-filters', '--stdin-paths'], {
123 cwd: root,
124 stdin: `${targets.join('\n')}\n`,
125 })
126
127 return hashed.exitCode === 0 && hashed.isStdoutTruncated !== true ? withHashes(entries, hashed.stdout) : undefined
128}
129
130/**
131 * The session's repository, or undefined outside one (or when `status` fails,
132 * or the filters could not be listed). A runner that throws (git cannot start,
133 * the call times out) is passed on.
134 */
135export const readTree = async (run: GitRun, inspect: Inspect): Promise<Tree | undefined> => {
136 const top = await call(run, [...GIT, 'rev-parse', '--show-toplevel'])
137 if (top.exitCode !== 0 || top.isStdoutTruncated === true) return undefined
138
139 const root = top.stdout.trim()
140 const off = await filterArgs(run, root)
141 if (off === undefined) return undefined
142
143 const listed = await call(run, [...GIT, ...off, 'status', '--ignore-submodules=all', '--porcelain=v2', '--branch', '-z', '-uall'], {
144 cwd: root,
145 })
146 // A status the engine cut short would give partial counts: it is no status.
147 const status = listed.exitCode === 0 && listed.isStdoutTruncated !== true ? parseGitStatus(listed.stdout) : undefined
148 if (status === undefined) return undefined
149
150 const entries = await markLinks(status.entries, root, inspect)
151 if (entries === undefined) return { root, status }
152
153 return { root, status: { ...status, entries }, hashes: await hashesOf(run, root, entries, off) }
154}
155hooks/lib/plain.ts 18 lines1// Text from outside (a branch name, a path, a model slug) drawn as it is could
2// carry control or format characters, and the engine refuses a Text holding
3// them (the whole tree is then not drawn). Every such string is made plain first.
4
5/** Control (Cc), format (Cf), line and paragraph separators and tag characters become spaces. */
6export const plain = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u{e0000}-\u{e007f}]/gu, ' ')
7
8/** How long ago `thenMs` was: `just now`, `3m ago`, `2h ago`; `—` when it never happened. */
9export const ago = (thenMs: number | undefined, nowMs: number): string => {
10 if (thenMs === undefined) return '—'
11
12 const minutes = Math.max(0, Math.floor((nowMs - thenMs) / 60000))
13 if (minutes < 1) return 'just now'
14 if (minutes < 60) return `${minutes}m ago`
15
16 return `${Math.floor(minutes / 60)}h ago`
17}
18