SLOPSHOPPER

cc-dashboard

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

newpaneguardcommandstatusprompt
v0.2.0MITupdated 2026-10-16kaisudo0520/cc-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ CC DASHBOARD · — IDLE │ ┃ ■ main ■ agents ■ gate ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ — · main ⏺ Update(src/auth.ts) │ ┃ │ effort ▯▯▯▯ — 0 req ⎿ Added 2 lines, removed 1 line │ ┃ │ 5h ━━━━━━━━━━─────────────────────── 31% ⏺ Bash(bun test) │ ┃ ╰─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ▣ client module ./rail.tsx │ ┃ Context ██░░░ 49% 97k/200k ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ▣ client module ./rail.tsx ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ✓ last turn 42s · 0 agents · 3 edits · 1 e › /dashboard │ ┃ ╰─────────────────────────────────────────── ⎿ cc-dashboard: Dashboard opened. Focus it with ctrl+x tab; 1-6 ex │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ session log │ ┃ │ 08:53:20 main Edit → src/auth.ts │ ┃ │ 08:53:20 main Write → src/audit.ts │ ┃ │ 08:53:20 main Write → src/cache.ts │ ┃ │ 08:53:20 main Bash → bun test ✗ │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cc-dashboard: ctx 49%

Draws

Pane · Dashboard
CC DASHBOARD · — IDLE ■ main ■ agents ■ gate ╭──────────────────────────────────────────────────────╮ │ — · main ○ idle │ │ effort ▯▯▯▯ — 0 req │ │ 5h ━━━━━━━━━━─────────────────────── 31% │ ╰──────────────────────────────────────────────────────╯ ▣ client module ./rail.tsx Context ██░░░ 49% 97k/200k ▣ client module ./rail.tsx ╭──────────────────────────────────────────────────────╮ │ ✓ last turn 42s · 0 agents · 3 edits · 1 error │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ session log │ │ 08:53:20 main Edit → src/auth.ts │ │ 08:53:20 main Write → src/audit.ts │ │ 08:53:20 main Write → src/cache.ts │ │ 08:53:20 main Bash → bun test ✗ │ ╰──────────────────────────────────────────────────────╯
README

cc-dashboard

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:

  • A Codex usage panel: 5-hour and weekly usage. It normally reads local files only; the server is asked for fresh figures only when you press ↻.
  • A branch panel: branch, sync state, and how many files are unreviewed and how many changed.
  • Fable weekly usage in the main box.
  • A red "waiting on you" line when another Claude Code session needs you (this session also writes its own wait mark).
  • codex exec runs shown as subagent cards (no Codex session id).
  • An orange flow line under the main box: it runs while the main session works, matching the subagents' line.
  • Paired layout, a context line of its own, and a dock width that follows the terminal.

Panels

| 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.

  • Wide (docked, 110 columns or more): the left column holds main, Codex, architect and gate; the right column holds branch, agents, other loops and receipt; the log runs full width at the bottom.
  • Single column with the layout on 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.
  • Inline above the prompt (not fullscreen): a summary of at most 8 rows. The first row is the model, the context and Claude's 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.

Dock (fullscreen)

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.

Use

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).

Codex usage

Read-only, display-only. It needs USERPROFILE and SystemRoot (Windows); without either nothing is read and the Codex panel does not appear.

  • Model and effort: the model is the 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.
  • Windows, the server only when you press ↻: 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.
  • Windows, the rollouts normally (the schedule reads local files only and sends no request; also used when you press ↻ 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.
  • The newer reading is shown. A server reading is dated by the ask, a rollout by its event time (a reading with no time counts as oldest), so after ↻ 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.

Fable weekly usage

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%.

Waiting on you

  • Reading (other sessions): the local session registry ~/.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.
  • Writing (this session's own): the same folder and the same format as status-sidebar, {"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.
  • A permission wait is known to start and not to end until its call returns, hence "may be waiting on you". The session name is made plain (control characters removed) before it is drawn.

Codex runs (as subagents)

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.

  • A foreground run ends when its call returns; an isError or a refusal is a failure.
  • A run moved to the background (the result carries 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.
  • At each turn's 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.

Branch

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.

  • Changed = files that differ from HEAD (untracked included).
  • Unreviewed = changed files whose content differs from what the last review saw.
  • The review record (this plugin's only write): its own $.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).

Inferred, not measured

  • The architect's moments: 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.
  • A server-side architect (such as advisor) is encrypted: it can be counted and timed, not read.
  • An agent's ctx is its latest request's whole input, not a cost.

Configure

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 |

Structure

| 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) |

Test

claude plugin test
claude plugin validate .
npx -p typescript tsc -p .   # after a first interactive load with --plugin-dir, which writes .claude-plugin/types/

License

MIT, see LICENSE. Flightdeck © Stephen Casella.

Source 20 files
hooks/register.tsx 1813 lines
1import { 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 lines
1// 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
793
hooks/lib/codex.ts 76 lines
1// 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}
76
hooks/lib/codex-quota.ts 297 lines
1import 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}
297
hooks/lib/fable.ts 59 lines
1import 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}
59
hooks/lib/notify.ts 53 lines
1// 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}
53
hooks/lib/serial.ts 16 lines
1/**
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}
16
hooks/lib/sessions.ts 90 lines
1import 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}
90
hooks/lib/waiting.ts 79 lines
1import 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}
79
hooks/lib/git.ts 159 lines
1import 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}
159
hooks/lib/git-tree.ts 155 lines
1import { 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}
155
hooks/lib/plain.ts 18 lines
1// 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