A Claude Code mod for long sessions: a cockpit pane of what the session is doing, context against its handoff budget, usage limits with forecasts, subagents…

A Claude Code mod for long sessions, and for dr-superpowers plans in particular. It is a hooks module, not a set of skills, so it costs the model no context: it draws in the interface and acts on the engine's events.
Claude Code only. Built and tested against Claude Code 2.1.292 to 2.1.295; the mod API is early access and may change between releases.
Cockpit pane. A pane with the session at a glance. It opens by itself when a session starts, once the terminal is 144 columns wide; /cockpit opens it at any width, and says why if Claude Code holds it back. In fullscreen at 110 columns or more it docks beside the transcript; otherwise it sits above the prompt. In the full layout each section is a framed card in its own color, its headline on the title line, under one Now row that says what the session is doing:
● running 2m14s · step 18 · Bash in impl-sonnet-low
╭ Account ──────────────────────────── subscription ╮
│ ● you@example.com │
│ You · Acme (admin) │
│ Opus 5.5 · xhigh effort │
│ Remote ● connected · phone │
│ config ~/.claude │
╰───────────────────────────────────────────────────╯
╭ Context ─────────────────── 68% of handoff · ≈7 turns ╮
│ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱┃▱▱▱▱▱┊▱▱▱▱▱▱▱▱▱▱▱▱▱▱ │
│ 412k of 1.0M ┃ handoff 604k ┊ compacts 744k │
│ trend ▁▂▃▃▄▄▅▆▆▇▇█ +23k │
│ ██████████████████████████████████████▓▓▓▓▒▒▒░░ │
│ ■ Messages 318k 78% │
│ ■ System tools 41k 10% │
│ ╭──────────╮ ╭─────────╮ ╭──────────╮ │
│ │ Hand off │ │ Compact │ │ Settings │ │
│ ╰──────────╯ ╰─────────╯ ╰──────────╯ │
╰───────────────────────────────────────────────────╯
╭ Usage ──────────────────────────── $4.85 · $2.35/h ╮
│ 5h ▰▰▰▰▰┊▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 23% │
│ resets in 3h40m · on pace │
╰───────────────────────────────────────────────────╯
╭ Agents & shells ─────────────── $ 1 ● 1 running ✓ 2 ╮
│ $ npm run dev 12m │
│ ● impl-sonnet-low Task 4 4m │
│ ✓ reviewer-opus Review task 3 │
│ Seats by input │
│ impl-sonnet-low ×1 │
│ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 120k in · 80% cached · 7k out │
╰───────────────────────────────────────────────────╯
╭ Run ──────────────────────────────── round 2 · 3/6 ╮
│ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ │
│ docs/plans/cockpit.md │
│ ✓ Task 3: Draw the context section · clean │
│ ▸ Task 4: Draw usage and agents │
│ round 2 · impl-sonnet-low │
│ ○ Task 5: Track the plan and repo │
╰───────────────────────────────────────────────────╯
╭ Repo ─────────────────────────────────── ● dirty ╮
│ ⎇ feat/cockpit-pane-overhaul ↑3 │
│ ~2 changed ?1 untracked │
╰───────────────────────────────────────────────────╯
The context track runs over the whole window: the fill is the reading, ┃ the handoff budget and ┊ where auto-compaction runs. The trend line is the session's recent readings. The colored bar splits the context by /context category, each color matching its row. On a usage-limit meter ┊ marks how far through its window the limit is, so a fill past it is spending faster than the window refills.
Discord ✓ once a notification went out for what it shows..claude.json (under CLAUDE_CONFIG_DIR when set), the billing type, the model and effort the last request used, Remote Control, and the config directory. Remote reads connected with the devices attached (a phone, the desktop app), on · no device yet when "Enable Remote Control for all sessions" is on in /config, and not connected otherwise; a session started with /remote-control reads not connected until a device joins. Its frame takes the color dr-status assigns the account in ~/.claude/dcc-statusline.json (DCC_STATUSLINE_CONFIG when set)./context categories, estimated locally with no API calls. The title adds about how many turns are left to the budget at the pace of the last few. Hand off appears in dr-superpowers sessions; Resume (r) appears outside them when .superpowers/handoff/latest.md is under three days old, and queues the prompt that resumes it.on pace), or when it runs out first, in amber.dr-superpowers run it becomes Run, read from the run's ledger (.superpowers/sdd/<run>/progress.md) and its plan: the review round in flight, the tasks done, which were reviewed clean, and a blocked task in red with its reason.When the pane sits above the prompt (layout auto, the default), it draws what dr-status draws, with the cockpit's buttons under it: the frame in the account's color with its email on the top rule, the path, branch, model and effort, then the context, cache, cost and limit meters. A running turn rides on the top rule's right end, a dr-superpowers run adds run 3/6 r2 to the first line, past warnAt a handoff 86% reading follows ctx, and the bottom rule counts the background shells running and names the Remote Control devices attached:
╭─ you@example.com ──────────────────────────────────────────────────────────────── ● 2m14s · Bash ─╮
│ darkraise-ai-plugins/…/dr-cockpit · feat/cockpit-pane-overhaul* · Opus · xhigh · run 3/6 r2 │
│ ctx ▰▰▱▱ 41% · cache ▰▰▰▱ 93% · $4.85 · 5h ▰▱▱ 23% · 7d ▰▰▱ 61% │
╰─ $ 2 shells · ● phone ───────────────────────────────────────────────────────────────────────────╯
╭──────────╮ ╭─────────╮ ╭──────────╮
│ Hand off │ │ Compact │ │ Settings │
╰──────────╯ ╰─────────╯ ╰──────────╯
Each line shrinks the way dr-status' do, a step at a time until it fits: the path drops what leads to the repository, then its middle, then all but the leaf; the branch drops its counters, then shortens; the model drops its version; the meters narrow, drop the token count and reset times, and at last show the percentage alone. Below 48 columns the frame goes and the email takes a line of its own above them. The pane draws no Nerd Font icons. The context meter is the share of the model's window, as dr-status shows it.
Each button sits in a rounded box, the letter of its hotkey underlined in the label as a desktop app marks one; the hotkey presses it while the pane has the keyboard: h Hand off, r Resume, c Compact, s Settings, and b Back in the settings view. Before Claude Code 2.1.295, which first draws a styled label, the hotkey leads the label instead (c: Compact). The box is gray at rest and takes the accent color on the one that matters: Hand off once the context passes warnAt, Resume whenever it shows. In the settings view a switch reads ● On or ○ Off, the chosen option of a row takes the accent, and what is off is drawn dim.
Settings view. Settings (or /cockpit settings) turns the pane into the mod's settings: whether it shows at session start, the layout, which sections show, the handoff budget, where the strip's handoff reading and the limit alert appear, how many context rows to list, the note and guard switches, and the Discord notifications. Each press saves the setting the way /config does, and the pane redraws with it. Back (b) returns to the cockpit.
The breakdown and the repo are read after each turn while the pane is open, and when it opens; nothing in the pane costs the model context.
Limit alerts. When the 5-hour or 7-day limit crosses 90%, a toast says so once, with when it resets; it can say so again after the window resets.
Handoff reading. The handoff reading shows only in the cockpit: the Context card, and the strip's handoff N% once the context reaches warnAt (yellow, red past the budget). The main session re-reads its whole context on every request, so a session that runs past its budget pays for it on each turn after. In a session running dr-superpowers (one that has loaded one of its skills or started one of its agents), Hand off queues a prompt asking the controller to finish the task in flight and hand off with the handoff skill. Past the budget the mod also adds one note to the conversation saying the same, so a controller deep in a long turn sees it before its next task. A compaction clears the reading until the next response, and re-arms the note.
Discord notifications. The mod can post to a Discord channel when the session needs you, which works the same over ssh and tmux. Create a webhook (channel settings → Integrations → Webhooks → copy URL) and paste it in the settings view's Notifications group, or run /cockpit notify url <url>. It is kept in the plugin's own store, not in settings.json, and only ever shows masked; DR_COCKPIT_NOTIFY_URL in the environment wins over it. Send test (or /cockpit notify test) posts a sample. Set your Discord user id (Mention, or /cockpit notify mention <id>) to be pinged for the events that need you; everything else posts silently.
| Event | When | Default | Pings |
|---|---|---|---|
ask | A tool call waits for your approval past notifyAskAfter; the message is edited once you approve or deny it | on | yes |
question | Claude asks you something or has a plan ready, likewise | on | yes |
done | A turn ends after running at least notifyAfter seconds | on | no |
error | A turn fails | on | yes |
blocked | A dr-superpowers task is blocked | on | yes |
budget | The context crosses the handoff budget | on | no |
limit | A usage limit crosses limitAlertAt | on | no |
task | A run's task completes, or its final review is clean | off | no |
session | The session ends, with its resume command | off | no |
agent | A subagent finishes | off | no |
Each message names the host, repository, branch, account and tmux session it came from. Command and answer excerpts are cut short and pass through a redactor that masks common tokens and key=/token=/password= values; notifyDetail brief leaves them out. A turn you interrupt sends nothing, and approvals are not watched when the session runs in auto, bypassPermissions or dontAsk mode.
State file for dr-status. The mod writes the turn's state and the run's progress to <config dir>/dr-cockpit/state/<session id>.json, and dr-status draws them as its turn and run segments.
Attribution guard. Claude Code asks the model to add a Co-Authored-By trailer to commits and a "Generated with Claude Code" footer to pull requests. The mod blanks both where the engine composes them, and refuses a git commit, gh pr create/edit or GitHub pull-request tool call that still carries AI attribution (an AI Co-Authored-By, Claude-Session, "Generated with Claude Code" or a Claude session link). Human co-authors pass.
Set these in the pane's settings view, in /config under the plugin's rows, or in your user settings (~/.claude/settings.json; project settings are not read) under pluginConfigs["dr-cockpit@darkraise"].options, then start a new session:
{
"pluginConfigs": {
"dr-cockpit@darkraise": {
"options": { "layout": "compact", "sections": "context,usage,plan", "warnAt": 70 }
}
}
}
| Option | Default | Effect |
|---|---|---|
handoffTokens | 0 | The handoff budget in tokens. 0 follows DR_SUPERPOWERS_BUDGET, else dr-superpowers' own rule: 93% of the smaller of autoCompactWindow and the model's window, minus 140,000 (465k at 650k, 604k on a 1M model with autoCompactWindow at 800k). Windows too small for that rule get a fifth of the window. |
nudgeModel | true | Add the one note to the conversation at the budget, in dr-superpowers sessions. |
guardAttribution | true | Blank the engine's attribution text and refuse git writes that carry it. |
warnAt | 80 | Where the strip's handoff reading appears, as a percentage of the handoff budget (1-99). |
layout | auto | auto: the full layout docked beside the transcript, the compact strip (the dr-status lines and the buttons) above the prompt. full or compact: always that one. |
sections | empty | The pane's sections and their order, comma-separated: account, context, usage, agents, plan, repo, guard. Empty shows all seven. The compact strip always draws the dr-status lines. |
breakdownRows | 6 | How many /context categories the Context section lists, largest first, up to 12. 0 hides them. |
openAtStart | true | Open the pane when a session starts. Claude Code shows it once the terminal is 144 columns wide (110 once you have opened it before); /cockpit opens it at any width. |
limitAlertAt | 90 | The usage-limit alert's line, in percent. 0 turns the alert off. |
notifyOn | empty | The Discord events to send, comma-separated (see above). Empty sends the default set; none sends nothing. |
notifyAfter | 60 | Seconds a turn must run before done sends. 0 sends every turn. |
notifyAskAfter | 20 | Seconds an approval or question waits before it sends. 0 sends at once. |
notifyDetail | full | full includes command and answer excerpts; brief sends titles and fields only. |
/plugin marketplace add darkraise/darkraise-ai-plugins
/plugin install dr-cockpit@darkraise
An installed copy stays at the version it was installed at. To take a new release, run claude plugin update dr-cockpit@darkraise and start a new session (or run /reload-plugins).
claude plugin validate plugins/dr-cockpit
claude plugin test plugins/dr-cockpit
claude --plugin-dir plugins/dr-cockpit
The last loads the working copy into a session and reloads it on save. The engine lays its type declarations under .claude-plugin/types/ when it loads the folder; they are not committed.
hooks/register.tsx 2091 lines1import { atom, read, update } from 'claude-code'
2import type { ConfigValue, EngineInterface, Register, RenderChildren, Timer, TurnUsage } from 'claude-code'
3
4import type {
5 CockpitAccount,
6 CockpitActivity,
7 CockpitBreakdown,
8 CockpitBudget,
9 CockpitPlanItem,
10 CockpitRefusal,
11 CockpitRun,
12 CockpitSeat,
13 CockpitShell,
14 CockpitSpawn,
15 CockpitUsage,
16} from '../types'
17import {
18 accountKey,
19 attributionIn,
20 backgroundBadge,
21 bar,
22 billingLabel,
23 budgetFor,
24 duration,
25 elapsed,
26 fitStatus,
27 isAtLeast,
28 isGitWriteTool,
29 isSuperpowers,
30 kTokens,
31 levelOf,
32 limitForecast,
33 limitLabel,
34 modelName,
35 nowBadge,
36 nowLine,
37 parseLedger,
38 planTasks,
39 planFromTodos,
40 planWithUpdate,
41 positive,
42 rampColor,
43 remoteRuns,
44 repoFromPorcelain,
45 roundNumber,
46 runsWidth,
47 SECTIONS,
48 sectionsFrom,
49 sectionsToggled,
50 sparkline,
51 stackedRuns,
52 stepped,
53 tasksEnded,
54 termColor,
55 track,
56 turnsToBudget,
57 windowElapsed,
58 warnShare,
59 writesGitText,
60} from './lib'
61import type { Run, Section, StatusInput, TrackPart } from './lib'
62import {
63 discordBody,
64 excerpt,
65 isDiscordWebhook,
66 maskWebhook,
67 mentionId,
68 NOTIFY_KINDS,
69 notifyKindsFrom,
70 notifyKindsToggled,
71} from './notify'
72import type { NotifyEvent, NotifyKind, NotifyPlace, SendResult } from './notify'
73
74const PANE = 'dr-cockpit'
75const PANE_COLUMNS = 56
76const INDENT = 2
77// A button's rounded frame: the accent on the one that matters, gray at rest.
78const BUTTON_MAIN = 'permission'
79const BUTTON_REST = 'inactive'
80// The rows the compact layout asks for above the prompt: the framed two
81// lines, and the buttons in their boxes.
82const COMPACT_ROWS = 7
83// The first Claude Code that takes styled children in a Button.
84const UNDERLINE_SINCE = '2.1.295'
85// The most /context categories the pane lists (breakdownRows caps it lower).
86const MAX_BREAKDOWN_ROWS = 12
87// Below this the strip drops its frame, as dr-status does.
88const FRAME_MIN_COLUMNS = 48
89const HANDOFF_PROMPT =
90 'Finish the task in flight through its completion line, then hand off with the dr-superpowers handoff skill. Start no new task.'
91const RESUME_PROMPT = 'Resume the work in .superpowers/handoff/latest.md with the dr-superpowers resume-execution skill.'
92// A handoff older than this is not offered for resuming.
93const RESUME_WITHIN = 3 * 86_400_000
94// The tools that wait on the person by their nature: their dialog is the question.
95const ASKING_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
96// Permission modes in which an "ask" is settled without the person.
97const UNATTENDED_MODES = ['auto', 'bypassPermissions', 'dontAsk']
98// How often the state file is rewritten while a turn runs, so dr-status can
99// tell a live turn from one whose session went away.
100const HEARTBEAT_MS = 30_000
101// How many main-turn context readings the forecast keeps.
102const TURN_READINGS = 12
103const STORE_URL = 'notifyUrl'
104const STORE_MENTION = 'notifyMention'
105// How many context readings the trend line keeps.
106const TREND_LENGTH = 40
107// The colors the context breakdown cycles through, largest category first.
108const BREAKDOWN_COLORS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red'] as const
109// The handoff budget's step and ceiling in the settings view.
110const HANDOFF_STEP = 50_000
111const MAX_HANDOFF = 2_000_000
112const GIT_STATUS = ['git', '--no-optional-locks', '-c', 'core.fsmonitor=false', 'status', '--porcelain=v2', '--branch']
113
114const budget = atom({ plugin: 'dr-cockpit', key: 'budget' } as const, null)
115const usage = atom({ plugin: 'dr-cockpit', key: 'usage' } as const, null)
116const breakdown = atom({ plugin: 'dr-cockpit', key: 'breakdown' } as const, null)
117const seats = atom({ plugin: 'dr-cockpit', key: 'seats' } as const, [])
118const spawns = atom({ plugin: 'dr-cockpit', key: 'spawns' } as const, [])
119const shells = atom({ plugin: 'dr-cockpit', key: 'shells' } as const, [])
120const remote = atom({ plugin: 'dr-cockpit', key: 'remote' } as const, { clients: [], setting: null })
121const plan = atom({ plugin: 'dr-cockpit', key: 'plan' } as const, [])
122const repo = atom({ plugin: 'dr-cockpit', key: 'repo' } as const, null)
123const refusals = atom({ plugin: 'dr-cockpit', key: 'refusals' } as const, [])
124const isNudged = atom({ plugin: 'dr-cockpit', key: 'isNudged' } as const, false)
125const isPlanSession = atom({ plugin: 'dr-cockpit', key: 'isPlanSession' } as const, false)
126const alerted = atom({ plugin: 'dr-cockpit', key: 'alerted' } as const, [])
127const trend = atom({ plugin: 'dr-cockpit', key: 'trend' } as const, [])
128const view = atom({ plugin: 'dr-cockpit', key: 'view' } as const, 'cockpit')
129const account = atom({ plugin: 'dr-cockpit', key: 'account' } as const, null)
130const place = atom({ plugin: 'dr-cockpit', key: 'place' } as const, null)
131const engine = atom({ plugin: 'dr-cockpit', key: 'engine' } as const, { model: null, effort: null, cache: null })
132const IDLE: CockpitActivity = { kind: 'idle', since: 0, turnStartedAt: null, step: 0, tool: null, agent: null, detail: null, lastTurnMs: null, endedAt: null, isSent: false }
133const activity = atom({ plugin: 'dr-cockpit', key: 'activity' } as const, IDLE)
134const turnTokens = atom({ plugin: 'dr-cockpit', key: 'turnTokens' } as const, [])
135const run = atom({ plugin: 'dr-cockpit', key: 'run' } as const, null)
136const handoffAt = atom({ plugin: 'dr-cockpit', key: 'handoffAt' } as const, null)
137const notifier = atom({ plugin: 'dr-cockpit', key: 'notifier' } as const, {
138 url: null,
139 isFromEnv: false,
140 mention: null,
141 editing: null,
142 error: null,
143 last: null,
144})
145
146type Options = {
147 handoffTokens: number
148 nudgeModel: boolean
149 guardAttribution: boolean
150 warnAt: number
151 layout: string
152 sections: string
153 breakdownRows: number
154 openAtStart: boolean
155 limitAlertAt: number
156 notifyOn: string
157 notifyAfter: number
158 notifyAskAfter: number
159 notifyDetail: string
160}
161
162/** What the budget reading is judged by. */
163type Tuning = { handoffTokens: number; nudgeModel: boolean; warnAt: number }
164
165export const register: Register = (on, options) => {
166 const {
167 handoffTokens = 0,
168 nudgeModel = true,
169 guardAttribution = true,
170 warnAt: warnPercent = 80,
171 layout: layoutSetting = 'auto',
172 sections: sectionList = '',
173 breakdownRows = 6,
174 openAtStart = true,
175 limitAlertAt = 90,
176 notifyOn = '',
177 notifyAfter = 60,
178 notifyAskAfter = 20,
179 notifyDetail = 'full',
180 } = options as Partial<Options>
181 // A plain string in the manifest (older validators refuse a picker), so
182 // anything but the three words reads as auto.
183 const layout = layoutSetting === 'full' || layoutSetting === 'compact' ? layoutSetting : 'auto'
184 const tuning: Tuning = { handoffTokens, nudgeModel, warnAt: warnShare(warnPercent) }
185 const shown = sectionsFrom(sectionList)
186 const paneArgs = { id: PANE, title: 'Cockpit', columns: PANE_COLUMNS, ...(layout === 'full' ? {} : { rows: COMPACT_ROWS }) }
187 // The settings' autoCompactWindow, read at the start and after each main
188 // turn rather than on every tool call.
189 let compactWindow: number | undefined
190 S.kinds = notifyOn === 'none' ? [] : notifyKindsFrom(notifyOn)
191 S.isBrief = notifyDetail === 'brief'
192 S.notifyAskAfter = notifyAskAfter
193
194 on('session.start', async ($, e, next) => {
195 await $.command.register({
196 name: 'cockpit',
197 description: 'Show context, usage, subagents, the plan and the repo in a pane',
198 argumentHint: '[settings]',
199 })
200 compactWindow = await readCompactWindow($)
201 await onBudget($, await refresh($, tuning, compactWindow))
202 await readAccount($)
203 await readPlace($)
204 await readEngine($)
205 await readWhere($)
206 await readNotifier($)
207 await readRun($)
208 await readVersion($)
209 await readRemote($)
210 // Unasked, the engine seats the pane from 144 columns and holds it below.
211 if (openAtStart) void $.ui.open(paneArgs)
212
213 return next(e)
214 })
215
216 on('command.run', { command: 'cockpit' }, async ($, e) => {
217 const words = e.args.trim().split(/\s+/)
218 if (words[0]?.toLowerCase() === 'notify') return notifyCommand($, words.slice(1))
219 const wantsSettings = e.args.trim().toLowerCase() === 'settings'
220 await update($, view, () => (wantsSettings ? 'settings' : 'cockpit'))
221 await readAccount($)
222 // The settings take the keyboard and as many rows as they need.
223 const opened = await $.ui.open(wantsSettings ? { id: PANE, title: 'Cockpit', columns: PANE_COLUMNS, focus: true } : paneArgs)
224 await refreshDetail($)
225
226 // A surface that seats no panes (an older desktop) holds it undrawn: say
227 // why rather than claim it opened.
228 return { text: opened.isPlaced ? 'Cockpit pane opened.' : `Cockpit pane is waiting: ${opened.reason}` }
229 })
230
231 on('session.measure', async ($, e, next) => {
232 const done = await next(e)
233 const now = await $.clock.now()
234 const startedAt = (await read($, usage))?.startedAt ?? (await $.session.usage()).startedAt
235 const reading: CockpitUsage = {
236 startedAt,
237 readAt: now,
238 limits: e.rateLimits.map(limit => ({
239 kind: limit.kind,
240 percent: limit.percentUsed,
241 resetsAt: limit.resetsAt === undefined ? null : Date.parse(limit.resetsAt) || null,
242 })),
243 }
244 await update($, usage, () => reading)
245 if (limitAlertAt > 0) {
246 for (const limit of await alertLimits($, reading, limitAlertAt)) {
247 await notify($, {
248 kind: 'limit',
249 title: `📊 ${limitLabel(limit.kind)} limit at ${Math.round(limit.percent)}%`,
250 detail: limit.resetsAt === null ? undefined : `Resets in ${duration(limit.resetsAt - now) || 'under a minute'}.`,
251 isPlain: true,
252 })
253 }
254 }
255 if (await isPaneUp($)) await refreshDetail($)
256
257 return done
258 })
259
260 on('session.end', async ($, e, next) => {
261 if (e.reason !== 'clear') {
262 const head = await read($, budget)
263 const started = (await read($, usage))?.startedAt
264 const stamp = await $.clock.now()
265 await notify($, {
266 kind: 'session',
267 title: '🏁 Session ended',
268 detail: `claude --resume ${e.sessionId}`,
269 isPlain: true,
270 fields: [
271 ...(started === undefined ? [] : [{ name: 'Took', value: elapsed(stamp - started) }]),
272 ...(head?.usd == null ? [] : [{ name: 'Cost', value: `$${head.usd.toFixed(2)}` }]),
273 ],
274 })
275 }
276 S.ticker?.cancel()
277 S.ticker = null
278 await setActivity($, () => IDLE)
279 await writeState($)
280 return next(e)
281 }).catch(($, e, next) => next(e))
282
283 // A compaction leaves no reading until the next response: drop the stale
284 // one and re-arm the note, so it does not stay latched.
285 on('session.compact', async ($, e, next) => {
286 const done = await next(e)
287 if (e.agentId === undefined && e.trigger !== 'precompute' && 'messages' in done && done.messages !== undefined) {
288 await update($, budget, () => null)
289 await update($, isNudged, () => false)
290 }
291
292 return done
293 }).catch(($, e, next) => next(e))
294
295 on('agent.spawn', async ($, e, next) => {
296 const started = await next(e)
297 if (started.deny !== undefined || started.agentId === undefined) return started
298 if (isSuperpowers(e.subagentType)) await update($, isPlanSession, () => true)
299 const spawn: CockpitSpawn = {
300 agentId: started.agentId,
301 seat: e.subagentType,
302 model: started.model,
303 description: e.description,
304 isDone: false,
305 startedAt: await $.clock.now(),
306 }
307 await update($, spawns, list => [...list, spawn].slice(-50))
308
309 return started
310 })
311
312 // Remote Control clients joining and leaving; the terminal raises neither.
313 on('session.attach', async ($, e, next) => {
314 const joined = await next(e)
315 await update($, remote, now => ({ ...now, clients: [...now.clients.filter(one => one.id !== e.clientId), { id: e.clientId, surface: e.surface }] }))
316 return joined
317 }).catch(($, e, next) => next(e))
318
319 on('session.detach', async ($, e, next) => {
320 const left = await next(e)
321 await update($, remote, now => ({ ...now, clients: now.clients.filter(one => one.id !== e.clientId) }))
322 return left
323 }).catch(($, e, next) => next(e))
324
325 // A background shell's end arrives as its task notification.
326 on('prompt.submit', async ($, e, next) => {
327 if (e.origin.kind === 'task-notification') {
328 const ended = new Set(tasksEnded(e.text).map(one => one.id))
329 if (ended.size > 0) await update($, shells, list => list.filter(one => !ended.has(one.id)))
330 }
331 return next(e)
332 }).catch(($, e, next) => next(e))
333
334 // A main turn begins: the Now row starts its clock.
335 on('turn.start', async ($, e, next) => {
336 const stamp = await $.clock.now()
337 for (const id of [...S.waits.keys()]) await settleWait($, id, '⏹ Turn ended')
338 await setActivity($, now => ({
339 ...IDLE,
340 kind: 'running',
341 since: stamp,
342 turnStartedAt: stamp,
343 lastTurnMs: now.lastTurnMs,
344 }))
345 return next(e)
346 })
347
348 // Watches the permission decision, never changes it: an "ask" on a call
349 // means the person is about to be asked, unless the mode settles it alone.
350 on('tool.check', async ($, e, next) => {
351 const decided = await next(e)
352 const id = e.tool_use_id
353 if (decided.decision !== 'ask' || id === undefined || S.isUnattended || ASKING_TOOLS.includes(e.tool) || S.waits.has(id)) return decided
354 const seat = e.agentId === undefined ? null : ((await read($, spawns)).find(one => one.agentId === e.agentId)?.seat ?? null)
355 await startWait($, id, 'waiting', {
356 kind: 'ask',
357 title: '⏳ Waiting for approval',
358 detail: `${e.tool}${callSummary(e.tool, e.input) === '' ? '' : `: ${excerpt(callSummary(e.tool, e.input), 200)}`}`,
359 fields: [...(seat === null ? [] : [{ name: 'Agent', value: shortSeat(seat) }]), ...(await turnFields($)).slice(0, 1)],
360 })
361 return decided
362 }).catch(($, e, next) => next(e))
363
364 // The background pill under a Bash call is drawn only once the call runs,
365 // so an approval waiting on it was given.
366 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
367 if (S.waits.has(e.props.tool_use_id)) await endWait($, e.props.tool_use_id, '✓ Approved')
368 return next(e)
369 })
370
371 // The model and effort the main loop's requests go out with, as the status
372 // line shows them.
373 on('turn.step', async function* ($, e, next) {
374 if (e.agentId === undefined) {
375 const effort = typeof e.effort === 'string' ? e.effort : null
376 const before = await read($, engine)
377 if (before.model !== e.model || before.effort !== effort) await update($, engine, now => ({ ...now, model: e.model, effort }))
378 const step = e.index + 1
379 await update($, activity, now => (now.kind === 'idle' || now.kind === 'failed' || now.step === step ? now : { ...now, step }))
380 }
381 return yield* next(e)
382 })
383
384 on('turn.complete', async ($, e, next) => {
385 const done = await next(e)
386 if (e.agentId === undefined) {
387 compactWindow = await readCompactWindow($)
388 await onBudget($, await refresh($, tuning, compactWindow))
389 const turnUsage = e.usage as TurnUsage | undefined
390 if (turnUsage !== undefined) {
391 const cache = cacheOf(turnUsage)
392 await update($, engine, now => ({ ...now, cache }))
393 }
394 const head = await read($, budget)
395 if (head !== null) await update($, turnTokens, list => [...list, head.tokens].slice(-TURN_READINGS))
396 for (const id of [...S.waits.keys()]) await settleWait($, id, '⏹ Turn ended')
397 const stamp = await $.clock.now()
398 const failed = e.reason === 'error' || e.reason === 'refusal'
399 const why = failed ? failureOf(e) : null
400 await readRemote($)
401 await setActivity($, now => ({
402 ...IDLE,
403 kind: failed ? 'failed' : e.reason === 'aborted' ? 'interrupted' : 'idle',
404 since: now.since,
405 lastTurnMs: e.durationMs,
406 endedAt: stamp,
407 detail: why,
408 }))
409 await readRun($)
410 if (failed) {
411 const messageId = await notify($, {
412 kind: 'error',
413 title: `❌ Turn failed${why === null ? '' : `: ${why}`}`,
414 detail: `After ${duration(e.durationMs) || '<1m'}. Send a prompt to continue.`,
415 isPlain: true,
416 fields: await turnFields($),
417 })
418 if (messageId !== null) await update($, activity, now => ({ ...now, isSent: true }))
419 } else if (e.reason === 'answer' && e.durationMs >= Math.max(0, notifyAfter) * 1000) {
420 const messageId = await notify($, {
421 kind: 'done',
422 title: `✅ Done in ${elapsed(e.durationMs)}`,
423 detail: e.answer.trim() === '' ? undefined : excerpt(e.answer, 300),
424 fields: await turnFields($),
425 })
426 if (messageId !== null) await update($, activity, now => ({ ...now, isSent: true }))
427 }
428 return done
429 }
430 const agentId = e.agentId
431 const list = await read($, spawns)
432 const spawn = list.find(one => one.agentId === agentId)
433 if (spawn === undefined) return done
434 await update($, spawns, all => all.map(one => (one.agentId === agentId ? { ...one, isDone: true } : one)))
435 if (e.usage !== undefined) await update($, seats, all => addRun(all, spawn.seat, e.usage as TurnUsage))
436 await notify($, {
437 kind: 'agent',
438 title: `🤖 ${shortSeat(spawn.seat)} finished`,
439 detail: spawn.description,
440 isPlain: true,
441 fields: [{ name: 'Took', value: elapsed(e.durationMs) }],
442 })
443
444 return done
445 })
446
447 if (guardAttribution) {
448 on('attribution.text', { kind: 'commit' }, () => ({ text: '' }))
449 on('attribution.text', { kind: 'pr' }, () => ({ text: '' }))
450 }
451
452 // One hook for every tool: the attribution guard judges before anything
453 // runs, and the main loop's budget and plan are read after each call, the
454 // only point its context grows between turns.
455 on('tool.call', async ($, e, next) => {
456 const tool = String(e.tool)
457 const line = guardAttribution ? attributionLine(tool, e) : undefined
458 if (line !== undefined) {
459 const refusal: CockpitRefusal = { at: await $.clock.now(), tool, line }
460 await update($, refusals, list => [...list, refusal].slice(-20))
461 return { deny: `dr-cockpit: this project keeps AI attribution out of git. Remove "${line}" and try again.` }
462 }
463 const id = e.tool_use_id
464 const seat = e.agentId === undefined ? null : ((await read($, spawns)).find(one => one.agentId === e.agentId)?.seat ?? null)
465 if ((await read($, activity)).kind === 'running') await setActivity($, now => ({ ...now, tool, agent: seat === null ? null : shortSeat(seat) }))
466 if (id !== undefined && e.agentId === undefined && ASKING_TOOLS.includes(tool)) {
467 await startWait($, id, 'asking', {
468 kind: 'question',
469 title: tool === 'ExitPlanMode' ? '📋 Plan ready for review' : '❓ Claude is asking',
470 detail: questionOf(e) || undefined,
471 fields: (await turnFields($)).slice(0, 1),
472 })
473 }
474 let ran: Awaited<ReturnType<typeof next>>
475 try {
476 ran = await next(e)
477 } catch (error) {
478 if (id !== undefined && S.waits.has(id)) await endWait($, id, '⏹ Interrupted')
479 throw error
480 }
481 if (id !== undefined && S.waits.has(id)) {
482 await endWait($, id, ran.deny !== undefined ? '✗ Denied' : ASKING_TOOLS.includes(tool) ? '✓ Answered' : '✓ Approved')
483 }
484 if ((await read($, activity)).tool === tool) await setActivity($, now => (now.kind === 'running' ? { ...now, tool: null, agent: null } : now))
485 if (tool === 'Skill' && isSuperpowers(String((e as { skill?: unknown }).skill ?? ''))) {
486 await update($, isPlanSession, () => true)
487 }
488 if (ran.deny === undefined && !ran.isError) await trackShells($, tool, e, ran.result)
489 if (e.agentId === undefined) {
490 if (ran.deny === undefined && !ran.isError) await trackPlan($, tool, e, ran.result)
491 await onBudget($, await refresh($, tuning, compactWindow))
492 // The controller writes its ledger with its own tool calls.
493 if (JSON.stringify(e).includes('.superpowers')) await readRun($)
494 }
495
496 return ran
497 }).catch(($, e, next) =>
498 next.called || !guardAttribution ? next(e) : { deny: 'dr-cockpit: its attribution guard failed.' },
499 )
500
501 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
502 const { Box, Button, Text } = $.ui.resolve(e)
503 const now = await $.clock.now()
504 const room = e.props.bodyColumns
505 const isCompact = layout === 'compact' || (layout === 'auto' && e.props.placement === 'inline')
506 const head = await read($, budget)
507 const spend = await read($, usage)
508 const rows = await read($, breakdown)
509 const list = await read($, seats)
510 const all = await read($, spawns)
511 const background = await read($, shells)
512 const remoteNow = await read($, remote)
513 const items = await read($, plan)
514 const git = await read($, repo)
515 const refused = await read($, refusals)
516 const canHandOff = await read($, isPlanSession)
517 const act = await read($, activity)
518 const summary = await read($, run)
519 const handoffTime = await read($, handoffAt)
520 const canResume = !canHandOff && handoffTime !== null && now - handoffTime < RESUME_WITHIN
521 const drawRuns = (key: string, runs: Run[]) =>
522 runs.map((one, index) => (
523 <Text key={`${key}-${index}`} color={one.color} bold={one.bold} dimColor={one.dim}>
524 {one.text}
525 </Text>
526 ))
527
528 // Every control is a button in a rounded box: gray at rest, the accent on
529 // the one that matters (`isMain`), dim when it reads as off.
530 if (S.canUnderline === null) await readVersion($)
531 const button = (key: string, label: string, onPress: () => unknown, look: { hotkey?: string; isMain?: boolean; isOff?: boolean } = {}) => {
532 // The hotkey's letter is underlined in the label, as a desktop app marks
533 // one; a hidden twin holds the hotkey, since a Button with one draws "c: "
534 // before its label. Older Claude Code draws that prefix instead.
535 if (!S.canUnderline) {
536 return (
537 <Box key={`${key}-box`} borderStyle="round" borderColor={look.isMain ? BUTTON_MAIN : BUTTON_REST} paddingX={1}>
538 <Button key={key} label={label} hotkey={look.hotkey} plain dimColor={look.isOff} hover={{ bold: true }} onPress={onPress} />
539 </Box>
540 )
541 }
542 const at = look.hotkey === undefined ? -1 : label.toLowerCase().indexOf(look.hotkey)
543 return (
544 <Box key={`${key}-box`} borderStyle="round" borderColor={look.isMain ? BUTTON_MAIN : BUTTON_REST} paddingX={1}>
545 {at < 0 ? (
546 <Button key={key} label={label} plain dimColor={look.isOff} hover={{ bold: true }} onPress={onPress} />
547 ) : (
548 <Button key={key} label={label} plain dimColor={look.isOff} hover={{ bold: true }} onPress={onPress}>
549 {label.slice(0, at)}
550 <Text underline>{label.slice(at, at + 1)}</Text>
551 {label.slice(at + 1)}
552 </Button>
553 )}
554 {at >= 0 && (
555 <Box key={`${key}-hotkey-box`} display="none">
556 <Button key={`${key}-hotkey`} label={label} hotkey={look.hotkey} plain onPress={onPress} />
557 </Box>
558 )}
559 </Box>
560 )
561 }
562 const isNearHandoff = head !== null && levelOf(head.tokens, head.limit, tuning.warnAt) !== 'quiet'
563 const actions = (
564 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1}>
565 {canHandOff && button('pane-handoff', 'Hand off', () => $.prompt.submit({ text: HANDOFF_PROMPT, asUser: true }), { hotkey: 'h', isMain: isNearHandoff })}
566 {canResume && button('pane-resume', 'Resume', () => $.prompt.submit({ text: RESUME_PROMPT, asUser: true }), { hotkey: 'r', isMain: true })}
567 {button('pane-compact', 'Compact', () => $.session.compact(), { hotkey: 'c' })}
568 {button('pane-settings', 'Settings', () => update($, view, () => 'settings'), { hotkey: 's' })}
569 </Box>
570 )
571
572 if ((await read($, view)) === 'settings') {
573 // Each press writes the setting as /config would; Claude Code then
574 // reloads the mod with it, so the rows below always show what is saved.
575 const set = (key: keyof Options, value: ConfigValue) => setOption($, key, value)
576 const row = (key: string, label: string, ...controls: RenderChildren[]) => (
577 <Box key={key} flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1}>
578 <Text>{label.padEnd(15)}</Text>
579 {controls}
580 </Box>
581 )
582 const toggle = (key: keyof Options, label: string, isOn: boolean) =>
583 row(`set-${key}`, label, button(`set-${key}-toggle`, isOn ? '● On' : '○ Off', () => set(key, !isOn), { isMain: isOn, isOff: !isOn }))
584 const choices = <T extends string>(key: keyof Options, label: string, all: readonly T[], chosen: T) =>
585 row(`set-${key}`, label, ...all.map(choice => button(`set-${key}-${choice}`, choice, () => set(key, choice), { isMain: chosen === choice, isOff: chosen !== choice })))
586 const chips = (key: string, names: readonly string[], isOn: (name: string) => boolean, label: (name: string) => string, onPress: (name: string) => unknown) => (
587 <Box key={key} flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={INDENT}>
588 {names.map(name => button(`${key}-${name}`, `${isOn(name) ? '✓' : '○'} ${label(name)}`, () => onPress(name), { isOff: !isOn(name) }))}
589 </Box>
590 )
591 const number = (key: keyof Options, label: string, value: string, onLess: () => unknown, onMore: () => unknown) =>
592 row(
593 `set-${key}`,
594 label,
595 <Text key={`set-${key}-value`} bold>
596 {value.padEnd(6)}
597 </Text>,
598 button(`set-${key}-less`, '−', onLess),
599 button(`set-${key}-more`, '+', onMore),
600 )
601
602 // A surface without text fields (the mobile app) sets the webhook from a terminal.
603 const elements = $.ui.resolve(e)
604 const Input = 'Input' in elements ? elements.Input : undefined
605 const canType = Input !== undefined
606 const notes = await read($, notifier)
607 const field = (key: 'url' | 'mention', placeholder: string, onSave: (value: string) => Promise<string | null>) =>
608 Input !== undefined && (
609 <Box key={`set-notify-${key}-field`} flexDirection="column" flexGrow={1}>
610 <Input
611 key={`set-notify-${key}-input`}
612 placeholder={placeholder}
613 submitLabel="save"
614 autoFocus
615 onSubmit={async (value: string) => {
616 const problem = await onSave(value)
617 await update($, notifier, now => ({ ...now, editing: problem === null ? null : key, error: problem }))
618 }}
619 />
620 {notes.error !== null && <Text color="red">✗ {notes.error}</Text>}
621 <Box key={`set-notify-${key}-cancel`}>
622 {button(`set-notify-${key}-cancel-button`, 'Cancel', () => update($, notifier, now => ({ ...now, editing: null, error: null })))}
623 </Box>
624 </Box>
625 )
626 const edit = (key: 'url' | 'mention') => update($, notifier, now => ({ ...now, editing: key, error: null }))
627 const notifyRows = (
628 <Box key="set-notify" flexDirection="column">
629 <Text bold>
630 Notifications <Text dimColor>(Discord)</Text>
631 </Text>
632 <Box key="set-notify-rows" flexDirection="column" paddingLeft={INDENT}>
633 {notes.editing === 'url' && canType
634 ? row('set-notify-url', 'Webhook', field('url', 'https://discord.com/api/webhooks/…', value => saveWebhook($, value)))
635 : row(
636 'set-notify-url',
637 'Webhook',
638 notes.url === null ? <Text key="set-notify-url-none" dimColor>not set </Text> : <Text key="set-notify-url-set"><Text color="green">✓ </Text>{maskWebhook(notes.url)} </Text>,
639 notes.isFromEnv && <Text key="set-notify-url-env" dimColor>from DR_COCKPIT_NOTIFY_URL</Text>,
640 notes.url !== null && button('set-notify-test', 'Send test', () => sendTest($), { isMain: true }),
641 !notes.isFromEnv && canType && button('set-notify-url-edit', notes.url === null ? 'Set' : 'Change', () => edit('url'), { isMain: notes.url === null }),
642 !notes.isFromEnv && notes.url !== null && button('set-notify-url-remove', 'Remove', () => saveWebhook($, '')),
643 !canType && notes.url === null && <Text key="set-notify-url-cli" dimColor>set it from a terminal: /cockpit notify url</Text>,
644 )}
645 {notes.last !== null && (
646 <Text key="set-notify-last" color={notes.last.isError ? 'red' : 'green'} wrap="truncate-end">
647 {' '.repeat(16)}
648 {notes.last.isError ? '✗ ' : '✓ '}
649 {notes.last.text}
650 </Text>
651 )}
652 {notes.editing === 'mention' && canType
653 ? row('set-notify-mention', 'Mention', field('mention', 'your Discord user id', value => saveMention($, value)))
654 : row(
655 'set-notify-mention',
656 'Mention',
657 <Text key="set-notify-mention-value" dimColor={notes.mention === null}>
658 {notes.mention === null ? 'nobody ' : `${notes.mention.slice(0, 10)}… `}
659 </Text>,
660 canType && button('set-notify-mention-edit', notes.mention === null ? 'Set' : 'Change', () => edit('mention')),
661 )}
662 <Text key="set-notify-on">Send on</Text>
663 {chips(
664 'set-notify-kind',
665 NOTIFY_KINDS,
666 kind => S.kinds.includes(kind as NotifyKind),
667 kind => kindLabel(kind as NotifyKind),
668 kind => set('notifyOn', notifyKindsToggled(S.kinds, kind as NotifyKind)),
669 )}
670 {number(
671 'notifyAfter',
672 'Done after',
673 notifyAfter > 0 ? `${notifyAfter}s` : 'any',
674 () => set('notifyAfter', stepped(notifyAfter, notifyAfter > 60 ? -60 : -15, 0, 3600)),
675 () => set('notifyAfter', stepped(notifyAfter, notifyAfter >= 60 ? 60 : 15, 0, 3600)),
676 )}
677 {number(
678 'notifyAskAfter',
679 'Ask after',
680 `${notifyAskAfter}s`,
681 () => set('notifyAskAfter', stepped(notifyAskAfter, -10, 0, 600)),
682 () => set('notifyAskAfter', stepped(notifyAskAfter, 10, 0, 600)),
683 )}
684 {choices('notifyDetail', 'Detail', ['full', 'brief'] as const, S.isBrief ? 'brief' : 'full')}
685 </Box>
686 </Box>
687 )
688
689 return (
690 <Box flexDirection="column">
691 <Text bold>Settings</Text>
692 <Box flexDirection="column" paddingLeft={INDENT}>
693 {toggle('openAtStart', 'Show at start', openAtStart)}
694 {choices('layout', 'Layout', ['auto', 'full', 'compact'] as const, layout)}
695 <Text key="set-sections">Sections</Text>
696 {/* Under their label, wrapping to the pane's width. */}
697 {chips(
698 'set-section',
699 SECTIONS,
700 name => shown.includes(name as Section),
701 name => name,
702 name => set('sections', sectionsToggled(shown, name as Section)),
703 )}
704 {number(
705 'handoffTokens',
706 'Handoff budget',
707 handoffTokens > 0 ? kTokens(handoffTokens) : 'auto',
708 () => set('handoffTokens', stepped(handoffTokens, -HANDOFF_STEP, 0, MAX_HANDOFF)),
709 () => set('handoffTokens', stepped(handoffTokens, HANDOFF_STEP, 0, MAX_HANDOFF)),
710 )}
711 {number(
712 'warnAt',
713 'Warn at',
714 `${warnPercent}%`,
715 () => set('warnAt', stepped(warnPercent, -5, 5, 95)),
716 () => set('warnAt', stepped(warnPercent, 5, 5, 95)),
717 )}
718 {number(
719 'limitAlertAt',
720 'Limit alert at',
721 limitAlertAt > 0 ? `${limitAlertAt}%` : 'off',
722 () => set('limitAlertAt', stepped(limitAlertAt, -5, 0, 100)),
723 () => set('limitAlertAt', stepped(limitAlertAt, 5, 0, 100)),
724 )}
725 {number(
726 'breakdownRows',
727 'Context rows',
728 String(breakdownRows),
729 () => set('breakdownRows', stepped(breakdownRows, -1, 0, MAX_BREAKDOWN_ROWS)),
730 () => set('breakdownRows', stepped(breakdownRows, 1, 0, MAX_BREAKDOWN_ROWS)),
731 )}
732 {toggle('nudgeModel', 'Handoff note', nudgeModel)}
733 {toggle('guardAttribution', 'Attr. guard', guardAttribution)}
734 </Box>
735 {notifyRows}
736 <Text dimColor>Saved to your user settings, like /config.</Text>
737 <Box key="settings-actions">
738 {button('settings-back', 'Back', () => update($, view, () => 'cockpit'), { hotkey: 'b' })}
739 </Box>
740 </Box>
741 )
742 }
743 const running = all.filter(one => !one.isDone)
744 // After Esc, what kept going: background shells and agents survive it.
745 const kept = [
746 background.length > 0 ? `${background.length} ${background.length === 1 ? 'shell' : 'shells'}` : '',
747 running.length > 0 ? `${running.length} ${running.length === 1 ? 'agent' : 'agents'}` : '',
748 ].filter(Boolean)
749 const actNow = act.kind === 'interrupted' ? { ...act, detail: kept.length === 0 ? 'nothing left running' : `${kept.join(' and ')} still running` } : act
750 const finished = all.filter(one => one.isDone)
751 const doneCount = items.filter(item => item.status === 'completed').length
752 const current = items.find(item => item.status === 'in_progress') ?? items.find(item => item.status === 'pending')
753 const limits = spend?.limits ?? []
754 const cost = head?.usd == null ? null : `$${head.usd.toFixed(2)}`
755
756 const who = await read($, account)
757 const tint = termColor(who?.color)
758
759 if (isCompact) {
760 // dr-status' two lines, framed in the account's color with its email on
761 // the top rule, and the cockpit's buttons under them. Each line shrinks
762 // on its own until it fits.
763 const where = await read($, place)
764 const engineNow = await read($, engine)
765 const isFramed = room >= FRAME_MIN_COLUMNS
766 const status: StatusInput = {
767 cwd: where?.cwd ?? null,
768 root: where?.root ?? null,
769 home: where?.home ?? null,
770 repo: git,
771 model: engineNow.model,
772 effort: engineNow.effort,
773 context: head === null ? null : { tokens: head.tokens, window: head.window },
774 cache: engineNow.cache,
775 usd: head?.usd ?? null,
776 limits,
777 now,
778 handoff: head === null ? null : { tokens: head.tokens, limit: head.limit, warnAt: tuning.warnAt },
779 run: summary === null || summary.total === 0 ? null : { done: summary.done, total: summary.total, round: roundNumber(runRound(summary)) },
780 }
781 const width = isFramed ? room - 4 : room
782 const { lines } = fitStatus(status, width)
783 const row = (key: string, runs: Run[]) =>
784 isFramed ? (
785 <Text key={key} wrap="truncate-end">
786 <Text color={tint}>{'│ '}</Text>
787 {drawRuns(key, runs)}
788 {' '.repeat(Math.max(0, width - runsWidth(runs)))}
789 <Text color={tint}>{' │'}</Text>
790 </Text>
791 ) : (
792 <Text key={key} wrap="truncate-end">
793 {drawRuns(key, runs)}
794 </Text>
795 )
796 const title = who?.email == null ? '' : [...who.email].slice(0, Math.max(0, room - 6)).join('')
797 // The turn's activity rides on the top rule, so the strip keeps its height.
798 const left = title === '' ? 2 : 4 + [...title].length
799 const fullBadge = nowBadge(actNow, now)
800 const lowBadge = backgroundBadge(background.length, remoteNow.clients)
801 const lowFits = lowBadge.length > 0 && room - runsWidth(lowBadge) - 6 >= 2
802 const badge = room - left - runsWidth(fullBadge) - 4 >= 2 ? fullBadge : []
803 const dashes = Math.max(0, room - left - (badge.length > 0 ? runsWidth(badge) + 4 : 1))
804
805 return (
806 <Box flexDirection="column">
807 {isFramed && (
808 <Text key="frame-top" color={tint} wrap="truncate-end">
809 {title === '' ? '╭─' : '╭─ '}
810 {title !== '' && <Text bold>{title}</Text>}
811 {title === '' ? '' : ' '}
812 {'─'.repeat(dashes)}
813 {badge.length > 0 && ' '}
814 {badge.length > 0 && drawRuns('frame-badge', badge)}
815 {badge.length > 0 ? ' ─╮' : '╮'}
816 </Text>
817 )}
818 {/* Without a frame to carry it, the email takes a line of its own. */}
819 {!isFramed && who?.email != null && (
820 <Text key="status-email" color={tint} bold wrap="truncate-end">
821 {who.email}
822 </Text>
823 )}
824 {!isFramed && fullBadge.length > 0 && (
825 <Text key="status-now" wrap="truncate-end">
826 {drawRuns('status-now', fullBadge)}
827 </Text>
828 )}
829 {row('status-1', lines[0])}
830 {row('status-2', lines[1])}
831 {isFramed && (
832 <Text key="frame-bottom" color={tint} wrap="truncate-end">
833 {lowFits ? '╰─ ' : '╰' + '─'.repeat(room - 2) + '╯'}
834 {lowFits && drawRuns('frame-low', lowBadge)}
835 {lowFits && ' ' + '─'.repeat(Math.max(0, room - runsWidth(lowBadge) - 5)) + '╯'}
836 </Text>
837 )}
838 {actions}
839 </Box>
840 )
841 }
842
843 // Each section is a card: a rounded frame in its own color, its title and
844 // headline on the first line, its rows below, and a row's details two cells in.
845 const inner = Math.max(20, room - 4)
846 const wide = Math.max(10, inner - 2 * INDENT)
847 const section = (key: string, title: string, color: string, headline: RenderChildren, ...body: RenderChildren[]) => (
848 <Box key={key} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
849 <Box key={`${key}-head`} flexDirection="row" justifyContent="space-between">
850 <Text color={color} bold>
851 {title}
852 </Text>
853 {headline}
854 </Box>
855 {body}
856 </Box>
857 )
858 const details = (key: string, ...body: RenderChildren[]) => (
859 <Box key={key} flexDirection="column" paddingLeft={INDENT}>
860 {body}
861 </Box>
862 )
863 // A meter with marks on it, its filled cells in the ramp color.
864 const drawTrack = (key: string, percent: number, parts: TrackPart[], marks: Record<string, string>) => (
865 <Text key={key}>
866 {parts.map((part, index) =>
867 part.kind === 'mark' ? (
868 <Text key={`${key}-${index}`} color={marks[part.mark ?? ''] ?? 'white'} bold>
869 {part.text}
870 </Text>
871 ) : (
872 <Text key={`${key}-${index}`} color={part.kind === 'fill' ? rampColor(percent) : undefined} dimColor={part.kind === 'empty'}>
873 {part.text}
874 </Text>
875 ),
876 )}
877 </Text>
878 )
879 const recent = [...running, ...finished.slice(-Math.max(0, 6 - running.length))]
880 const shownItems = planWindow(items, 8)
881 const sortedSeats = [...list].sort((a, b) => totalIn(b) - totalIn(a))
882 const mostIn = Math.max(1, ...sortedSeats.map(totalIn))
883 const breakdownShown = (rows?.rows ?? []).slice(0, Math.max(0, Math.min(MAX_BREAKDOWN_ROWS, breakdownRows)))
884 const readings = await read($, trend)
885
886 // The context track runs over the whole window: filled to the reading,
887 // with the handoff line and the compaction point marked on it.
888 const contextBody: RenderChildren[] = []
889 if (head !== null) {
890 const percent = (head.tokens / head.limit) * 100
891 const compactAt = rows?.compactAt ?? null
892 contextBody.push(
893 drawTrack(
894 'ctx-track',
895 percent,
896 track(head.tokens / head.window, inner, [
897 ...(compactAt === null ? [] : [{ at: compactAt / head.window, glyph: '┊', name: 'compact' }]),
898 { at: head.limit / head.window, glyph: '┃', name: 'handoff' },
899 ]),
900 { handoff: rampColor(percent), compact: 'gray' },
901 ),
902 <Text key="ctx-legend" wrap="truncate-end">
903 <Text bold>{kTokens(head.tokens)}</Text>
904 <Text dimColor> of {kTokens(head.window)}</Text>
905 <Text color={rampColor(percent)}> ┃ handoff {kTokens(head.limit)}</Text>
906 {compactAt !== null && <Text dimColor> ┊ compacts {kTokens(compactAt)}</Text>}
907 </Text>,
908 )
909 if (readings.length > 1) {
910 const change = (readings[readings.length - 1] ?? 0) - (readings[readings.length - 2] ?? 0)
911 contextBody.push(
912 <Text key="ctx-trend" wrap="truncate-end">
913 <Text dimColor>trend </Text>
914 <Text color="cyan">{sparkline(readings.slice(-Math.max(4, inner - 16)))}</Text>
915 <Text dimColor>
916 {' '}
917 {change >= 0 ? '+' : '−'}
918 {kTokens(Math.abs(change))}
919 </Text>
920 </Text>,
921 )
922 }
923 if (breakdownShown.length > 0) {
924 const runs = stackedRuns(
925 breakdownShown.map(row => row.tokens),
926 inner,
927 )
928 const colorOf = (index: number) => BREAKDOWN_COLORS[index % BREAKDOWN_COLORS.length]
929 const used = breakdownShown.reduce((sum, row) => sum + row.tokens, 0)
930 contextBody.push(
931 <Text key="ctx-stack">
932 {breakdownShown.map((row, index) => (
933 <Text key={`ctx-stack-${row.name}`} color={colorOf(index)}>
934 {'█'.repeat(runs[index] ?? 0)}
935 </Text>
936 ))}
937 </Text>,
938 details(
939 'ctx-rows',
940 breakdownShown.map((row, index) => (
941 <Text key={`ctx-row-${row.name}`} wrap="truncate-end">
942 <Text color={colorOf(index)}>■ </Text>
943 {row.name.padEnd(Math.min(20, Math.max(8, wide - 14)))}
944 <Text bold> {kTokens(row.tokens).padStart(5)}</Text>
945 <Text dimColor> {String(Math.round((row.tokens / Math.max(1, used)) * 100)).padStart(3)}%</Text>
946 </Text>
947 )),
948 ),
949 )
950 }
951 }
952
953 const engineNow = await read($, engine)
954 const billing = billingLabel(who?.billing ?? null)
955 const turnsLeft = head === null ? null : turnsToBudget(await read($, turnTokens), head.limit)
956 const runCard = () => {
957 if (summary === null) return null
958 const blocked = summary.tasks.filter(task => task.state === 'blocked')
959 const round = runRound(summary)
960 const color = blocked.length > 0 ? 'red' : 'yellow'
961 return section(
962 'plan',
963 'Run',
964 color,
965 <Text key="plan-head">
966 {blocked.length > 0 ? (
967 <Text color="red" bold>
968 {blocked.length} blocked
969 </Text>
970 ) : (
971 round !== null && <Text dimColor>round {round}</Text>
972 )}
973 {(blocked.length > 0 || round !== null) && <Text dimColor> · </Text>}
974 <Text bold>
975 {summary.done}/{summary.total}
976 </Text>
977 </Text>,
978 <Text key="plan-progress" color={color}>
979 {bar(summary.done / summary.total, inner)}
980 </Text>,
981 summary.plan !== null && (
982 <Text key="plan-path" dimColor wrap="truncate-end">
983 plan {summary.plan}
984 </Text>
985 ),
986 runWindow(summary.tasks, 6).map(task => {
987 const label = `Task ${task.n}${task.title === '' ? '' : `: ${task.title}`}`
988 if (task.state === 'complete') {
989 return (
990 <Text key={`plan-${task.n}`} wrap="truncate-end">
991 <Text color="green">✓ </Text>
992 <Text dimColor>
993 {label}
994 {task.isClean ? ' · clean' : ''}
995 </Text>
996 </Text>
997 )
998 }
999 if (task.state === 'blocked') {
1000 return (
1001 <Box key={`plan-${task.n}`} flexDirection="column">
1002 <Text color="red" bold wrap="truncate-end">
1003 ✗ {label}
1004 </Text>
1005 <Box paddingLeft={INDENT}>
1006 <Text color="red">BLOCKED{task.reason === null ? '' : ` · ${task.reason}`}</Text>
1007 </Box>
1008 </Box>
1009 )
1010 }
1011 if (task.state === 'assigned') {
1012 return (
1013 <Box key={`plan-${task.n}`} flexDirection="column">
1014 <Text wrap="truncate-end">
1015 <Text color="yellow">▸ </Text>
1016 <Text bold>{label}</Text>
1017 </Text>
1018 {(task.round !== null || task.seat !== null) && (
1019 <Text dimColor wrap="truncate-end">
1020 {' '}
1021 {[task.round === null ? 'implementing' : `round ${task.round}`, task.seat === null ? null : shortSeat(task.seat)].filter(Boolean).join(' · ')}
1022 </Text>
1023 )}
1024 </Box>
1025 )
1026 }
1027 return (
1028 <Text key={`plan-${task.n}`} wrap="truncate-end">
1029 <Text dimColor>○ </Text>
1030 {label}
1031 </Text>
1032 )
1033 }),
1034 summary.isFinished && (
1035 <Text key="plan-finished" color="green">
1036 Final review clean.
1037 </Text>
1038 ),
1039 )
1040 }
1041 const byName: Record<Section, RenderChildren> = {
1042 // Who the session runs as, framed in the color dr-status gives the account.
1043 account:
1044 who !== null &&
1045 section(
1046 'account',
1047 'Account',
1048 tint ?? 'blue',
1049 <Text key="account-head" dimColor>
1050 {billing ?? ''}
1051 </Text>,
1052 <Text key="account-email" wrap="truncate-end">
1053 <Text color={tint} bold>
1054 ● {who.email ?? 'not signed in'}
1055 </Text>
1056 </Text>,
1057 details(
1058 'account-rows',
1059 (who.name !== null || who.organization !== null) && (
1060 <Text key="account-org" wrap="truncate-end">
1061 {who.name ?? ''}
1062 {who.name !== null && who.organization !== null ? <Text dimColor> · </Text> : ''}
1063 {who.organization ?? ''}
1064 {who.role !== null && <Text dimColor> ({who.role})</Text>}
1065 </Text>
1066 ),
1067 engineNow.model !== null && (
1068 <Text key="account-model" wrap="truncate-end">
1069 <Text color="cyan" bold>
1070 {modelName(engineNow.model)}
1071 </Text>
1072 {engineNow.effort !== null && <Text dimColor> · {engineNow.effort} effort</Text>}
1073 </Text>
1074 ),
1075 <Text key="account-remote" wrap="truncate-end">
1076 <Text dimColor>Remote </Text>
1077 {drawRuns('account-remote', remoteRuns(remoteNow.clients, remoteNow.setting))}
1078 </Text>,
1079 <Text key="account-dir" dimColor wrap="truncate-end">
1080 config {who.key}
1081 </Text>,
1082 ),
1083 ),
1084 context: section(
1085 'context',
1086 'Context',
1087 'cyan',
1088 head === null ? (
1089 <Text key="ctx-head" dimColor>
1090 no reading yet
1091 </Text>
1092 ) : (
1093 <Text key="ctx-head">
1094 <Text color={rampColor((head.tokens / head.limit) * 100)} bold>
1095 {Math.round((head.tokens / head.limit) * 100)}% of handoff
1096 </Text>
1097 {turnsLeft !== null && (
1098 <Text color={turnsLeft <= 3 ? '#ffaf5f' : undefined} bold={turnsLeft <= 3} dimColor={turnsLeft > 3}>
1099 {' '}
1100 · ≈{turnsLeft} {turnsLeft === 1 ? 'turn' : 'turns'}
1101 </Text>
1102 )}
1103 </Text>
1104 ),
1105 ...contextBody,
1106 actions,
1107 ),
1108 usage: section(
1109 'usage',
1110 'Usage',
1111 'blue',
1112 <Text key="usage-head" bold>
1113 {cost ?? ''}
1114 <Text dimColor>{head?.usd == null ? '' : costRate(head.usd, spend?.startedAt, now)}</Text>
1115 </Text>,
1116 limits.length === 0 && (
1117 <Text key="limits-none" dimColor>
1118 No limit reading yet.
1119 </Text>
1120 ),
1121 limits.map(limit => {
1122 // The pace mark: how far through its window the limit is, so a fill
1123 // past the mark is spending faster than the window refills.
1124 const gone = windowElapsed(limit.kind, limit.resetsAt, now)
1125 const forecast = limitForecast(limit.kind, limit.percent, limit.resetsAt, now)
1126 const width = Math.max(6, inner - 14)
1127 return (
1128 <Box key={`limit-${limit.kind}`} flexDirection="column">
1129 <Text>
1130 <Text dimColor>{limitLabel(limit.kind).padEnd(4)}</Text>
1131 {drawTrack(
1132 `limit-${limit.kind}-track`,
1133 limit.percent,
1134 track(limit.percent / 100, width, gone === null ? [] : [{ at: gone, glyph: '┊', name: 'pace' }]),
1135 { pace: 'white' },
1136 )}
1137 <Text color={rampColor(limit.percent)} bold>
1138 {' '}
1139 {String(Math.round(limit.percent)).padStart(3)}%
1140 </Text>
1141 </Text>
1142 {limit.resetsAt !== null && (
1143 <Text wrap="truncate-end">
1144 <Text dimColor>
1145 {' '}resets in {duration(limit.resetsAt - now) || 'now'}
1146 {forecast === null && gone !== null ? ` · ${Math.round(gone * 100)}% of the window gone` : ''}
1147 {forecast === 'pace' ? ' · on pace' : ''}
1148 </Text>
1149 {typeof forecast === 'number' && (
1150 <Text color="#ffaf5f" bold>
1151 {' '}
1152 · runs out in {duration(forecast) || '<1m'}, before reset
1153 </Text>
1154 )}
1155 </Text>
1156 )}
1157 </Box>
1158 )
1159 }),
1160 ),
1161 agents: section(
1162 'agents',
1163 'Agents & shells',
1164 'magenta',
1165 <Text key="agents-head">
1166 {background.length > 0 && <Text color="yellow">$ {background.length} </Text>}
1167 {running.length > 0 && <Text color="cyan">● {running.length} running </Text>}
1168 {finished.length > 0 && <Text color="green">✓ {finished.length}</Text>}
1169 </Text>,
1170 all.length === 0 && background.length === 0 && (
1171 <Text key="agents-none" dimColor>
1172 No subagents or background shells yet.
1173 </Text>
1174 ),
1175 background.map(shell => (
1176 <Text key={`shell-${shell.id}`} wrap="truncate-end">
1177 <Text color="yellow" bold>
1178 ${' '}
1179 </Text>
1180 <Text>{clip(shell.command.split('\n')[0], Math.max(10, inner - 10))}</Text>
1181 <Text color="yellow"> {now - shell.startedAt < 60_000 ? '<1m' : duration(now - shell.startedAt)}</Text>
1182 </Text>
1183 )),
1184 recent.map(spawn => (
1185 <Text key={`spawn-${spawn.agentId}`} wrap="truncate-end">
1186 <Text color={spawn.isDone ? 'green' : 'cyan'}>{spawn.isDone ? '✓' : '●'} </Text>
1187 <Text bold={!spawn.isDone} dimColor={spawn.isDone}>
1188 {shortSeat(spawn.seat)}
1189 </Text>
1190 <Text dimColor={spawn.isDone}> {spawn.description}</Text>
1191 {!spawn.isDone && <Text color="cyan"> {duration(now - spawn.startedAt) || '<1m'}</Text>}
1192 </Text>
1193 )),
1194 sortedSeats.length > 0 && (
1195 <Text key="seats" dimColor>
1196 Seats by input
1197 </Text>
1198 ),
1199 sortedSeats.length > 0 &&
1200 details(hooks/lib.ts 751 lines1// Pure helpers, kept apart from the hooks so tests can reach them directly.
2
3/** The strip's handoff reading shows from this share of the budget. */
4export const WARN_AT = 0.8
5
6/**
7 * The handoff budget: the configured tokens, else DR_SUPERPOWERS_BUDGET, else
8 * dr-superpowers' own rule (reference/session-budget.md): the compaction point
9 * minus one task's worst growth, min(autoCompactWindow, window) × 93% − 140,000.
10 * Small windows, where that comes out near zero, hold a fifth of the window.
11 */
12export function budgetFor(configured: number, env: string | undefined, window: number, compactWindow?: number): number {
13 if (configured > 0) return Math.round(configured)
14 const pinned = Number(env)
15 if (Number.isFinite(pinned) && pinned > 0) return Math.round(pinned)
16 const room = compactWindow !== undefined && compactWindow > 0 ? Math.min(window, compactWindow) : window
17 return Math.max(Math.floor(room * 0.93) - 140_000, Math.floor(room / 5))
18}
19
20/** A positive whole number from a settings value, else undefined. */
21export function positive(value: unknown): number | undefined {
22 const n = Number(value)
23 return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined
24}
25
26export type Level = 'quiet' | 'warn' | 'handoff'
27
28export function levelOf(tokens: number, limit: number, warnAt: number = WARN_AT): Level {
29 if (tokens >= limit) return 'handoff'
30 return tokens >= limit * warnAt ? 'warn' : 'quiet'
31}
32
33/** The warnAt setting, a percentage, as a share: 80 → 0.8, held to 1-99. */
34export function warnShare(percent: number): number {
35 return Number.isFinite(percent) && percent > 0 ? Math.min(Math.max(Math.round(percent), 1), 99) / 100 : WARN_AT
36}
37
38export const SECTIONS = ['account', 'context', 'usage', 'agents', 'plan', 'repo', 'guard'] as const
39export type Section = (typeof SECTIONS)[number]
40
41/** The sections setting, "plan, context" → ['plan', 'context']: known names in the order given, each once. */
42export function sectionsFrom(text: string): Section[] {
43 const names = text
44 .split(/[\s,]+/)
45 .map(name => name.trim().toLowerCase())
46 .filter((name): name is Section => (SECTIONS as readonly string[]).includes(name))
47 const once = [...new Set(names)]
48 return once.length === 0 ? [...SECTIONS] : once
49}
50
51/**
52 * The sections setting after turning one section on or off: the shown order
53 * kept, a section turned on added last, and the last one left never turned
54 * off. All of them in their own order is the empty setting.
55 */
56export function sectionsToggled(shown: readonly Section[], name: Section): string {
57 const next = shown.includes(name)
58 ? shown.length === 1
59 ? [...shown]
60 : shown.filter(one => one !== name)
61 : [...shown, name]
62 const isDefault = next.length === SECTIONS.length && next.every((one, index) => one === SECTIONS[index])
63 return isDefault ? '' : next.join(',')
64}
65
66/** A number setting one step up or down, kept within its range. */
67export function stepped(value: number, step: number, min: number, max: number): number {
68 return Math.min(max, Math.max(min, value + step))
69}
70
71/** 465000 → "465k"; 1234567 → "1.2M". */
72export function kTokens(n: number): string {
73 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
74 return `${Math.round(n / 1000)}k`
75}
76
77const ATTRIBUTION = [
78 /co-authored-by:[^\n]*(claude|anthropic|codex|openai|copilot)/i,
79 /claude-session:/i,
80 /generated with \[?claude code/i,
81 /claude\.ai\/code\/session_/i,
82]
83
84/**
85 * The first AI attribution line in a text, if any, without the quoting around
86 * it: a shell command's closing quote, or a JSON string's escapes.
87 */
88export function attributionIn(text: string): string | undefined {
89 for (const pattern of ATTRIBUTION) {
90 const match = pattern.exec(text)
91 if (match) {
92 const line = text.slice(match.index).split(/\n|\\n/)[0] ?? ''
93 return line.replace(/[\\"'`}\],\s]+$/, '').trim()
94 }
95 }
96 return undefined
97}
98
99/** Whether a shell command writes a commit or a pull request. */
100export function writesGitText(command: string): boolean {
101 return /\bgit\b[^\n|;&]*\bcommit\b/.test(command) || /\bgh\s+pr\s+(create|edit)\b/.test(command)
102}
103
104/** Whether a tool by this name writes a pull request or commits files. */
105export function isGitWriteTool(tool: string): boolean {
106 return /pull_request|create_or_update_file|push_files/.test(tool) && !/read|list|search|get_/.test(tool)
107}
108
109/** Whether a subagent type or skill name belongs to dr-superpowers. */
110export function isSuperpowers(name: string): boolean {
111 return name.startsWith('dr-superpowers:')
112}
113
114/** 0.8 → "▰▰▰▰▰▰▰▰▱▱" at width 10; never full below 100%. */
115export function bar(share: number, width: number): string {
116 const clamped = Math.min(Math.max(share, 0), 1)
117 let on = Math.round(clamped * width)
118 if (on === width && clamped < 1) on = width - 1
119 return '▰'.repeat(on) + '▱'.repeat(width - on)
120}
121
122/** The usage ramp dr-status uses: green, yellow, orange, then red. */
123export function rampColor(percent: number): string {
124 if (percent >= 90) return 'error'
125 if (percent >= 75) return '#ff8700'
126 return percent >= 50 ? 'warning' : 'success'
127}
128
129/** Milliseconds → "3h40m", "2d4h", "12m"; nothing at or below zero. */
130export function duration(ms: number): string {
131 if (!(ms > 0)) return ''
132 const minutes = Math.floor(ms / 60_000)
133 const days = Math.floor(minutes / 1440)
134 const hours = Math.floor((minutes % 1440) / 60)
135 if (days > 0) return `${days}d${hours}h`
136 return hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
137}
138
139/** five_hour → "5h", seven_day → "7d", spend_limit → "spend". */
140export function limitLabel(kind: string): string {
141 if (kind === 'five_hour') return '5h'
142 if (kind === 'seven_day') return '7d'
143 return kind.replace(/_limit$/, '').replace(/_/g, ' ')
144}
145
146export type PlanStatus = 'pending' | 'in_progress' | 'completed'
147export type PlanItem = { id: string; text: string; status: PlanStatus }
148
149/** A TodoWrite call replaces the whole list. */
150export function planFromTodos(todos: readonly { content: string; status: PlanStatus }[]): PlanItem[] {
151 return todos.map((todo, index) => ({ id: `todo-${index}`, text: todo.content, status: todo.status }))
152}
153
154/** A TaskUpdate call moves or retitles one task; `deleted` drops it. */
155export function planWithUpdate(
156 items: readonly PlanItem[],
157 id: string,
158 change: { subject?: string; status?: PlanStatus | 'deleted' },
159): PlanItem[] {
160 const { subject, status } = change
161 if (status === 'deleted') return items.filter(item => item.id !== id)
162 return items.map(item =>
163 item.id === id ? { ...item, text: subject ?? item.text, status: status ?? item.status } : item,
164 )
165}
166
167export type RepoState = { branch: string; ahead: number; behind: number; changed: number; untracked: number }
168
169/** Reads `git status --porcelain=v2 --branch`. */
170export function repoFromPorcelain(text: string): RepoState | null {
171 const state: RepoState = { branch: '', ahead: 0, behind: 0, changed: 0, untracked: 0 }
172 let oid = ''
173 for (const line of text.split('\n')) {
174 if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
175 else if (line.startsWith('# branch.head ')) state.branch = line.slice(14).trim()
176 else if (line.startsWith('# branch.ab ')) {
177 const match = /\+(\d+) -(\d+)/.exec(line)
178 if (match) {
179 state.ahead = Number(match[1])
180 state.behind = Number(match[2])
181 }
182 } else if (line.startsWith('? ')) state.untracked += 1
183 else if (/^[12u] /.test(line)) state.changed += 1
184 }
185 if (state.branch === '') return null
186 if (state.branch === '(detached)') state.branch = oid === '' ? 'detached' : `@${oid.slice(0, 7)}`
187 return state
188}
189
190const SPARKS = '▁▂▃▄▅▆▇█'
191
192/** Readings → "▁▃▅█" scaled between their least and most; flat readings sit mid-height. */
193export function sparkline(values: readonly number[]): string {
194 if (values.length === 0) return ''
195 const low = Math.min(...values)
196 const span = Math.max(...values) - low
197 return values
198 .map(value => SPARKS[span === 0 ? 3 : Math.round(((value - low) / span) * (SPARKS.length - 1))])
199 .join('')
200}
201
202/** One run of a meter: filled, empty, or a mark standing at a share of the width. */
203export type TrackPart = { text: string; kind: 'fill' | 'empty' | 'mark'; mark?: string }
204
205/**
206 * A meter `width` cells wide filled to `share`, with each mark's glyph in the
207 * cell its share lands on (a later mark wins a shared cell). Never full below 100%.
208 */
209export function track(share: number, width: number, marks: readonly { at: number; glyph: string; name: string }[]): TrackPart[] {
210 const filled = bar(share, width)
211 const cells: TrackPart[] = [...filled].map(cell => ({ text: cell, kind: cell === '▰' ? 'fill' : 'empty' }))
212 for (const mark of marks) {
213 if (!(mark.at >= 0 && mark.at <= 1)) continue
214 const index = Math.min(width - 1, Math.floor(mark.at * width))
215 cells[index] = { text: mark.glyph, kind: 'mark', mark: mark.name }
216 }
217 // Neighbouring cells of one kind join into one run.
218 return cells.reduce<TrackPart[]>((runs, cell) => {
219 const last = runs[runs.length - 1]
220 if (last !== undefined && last.kind === cell.kind && cell.kind !== 'mark') last.text += cell.text
221 else runs.push({ ...cell })
222 return runs
223 }, [])
224}
225
226/**
227 * Splits `width` cells among values in proportion, largest remainders first,
228 * every nonzero value at least one cell while the width lasts.
229 */
230export function stackedRuns(values: readonly number[], width: number): number[] {
231 const total = values.reduce((sum, value) => sum + Math.max(0, value), 0)
232 if (total === 0) return values.map(() => 0)
233 const exact = values.map(value => (Math.max(0, value) / total) * width)
234 const runs = exact.map(Math.floor)
235 let left = width - runs.reduce((sum, run) => sum + run, 0)
236 const order = exact.map((value, index) => ({ index, rest: value - Math.floor(value) })).sort((a, b) => b.rest - a.rest)
237 for (const { index } of order) {
238 if (left === 0) break
239 runs[index] = (runs[index] ?? 0) + 1
240 left--
241 }
242 for (const [index, run] of runs.entries()) {
243 if (run > 0 || (values[index] ?? 0) <= 0) continue
244 const donor = runs.indexOf(Math.max(...runs))
245 if ((runs[donor] ?? 0) > 1) {
246 runs[donor] = (runs[donor] ?? 0) - 1
247 runs[index] = 1
248 }
249 }
250 return runs
251}
252
253const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
254
255/** How far through its window a rate limit is, 0 to 1, or null for a window of unknown length. */
256export function windowElapsed(kind: string, resetsAt: number | null, now: number): number | null {
257 const length = WINDOW_MS[kind]
258 if (length === undefined || resetsAt === null) return null
259 return Math.min(1, Math.max(0, 1 - (resetsAt - now) / length))
260}
261
262/** One stretch of text in the status strip, with how it is painted. */
263export type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
264
265/** What the status strip draws, read from the session as dr-status reads its payload. */
266export type StatusInput = {
267 cwd: string | null
268 /** The repository's root, when the working directory is in one. */
269 root: string | null
270 home: string | null
271 repo: RepoState | null
272 model: string | null
273 effort: string | null
274 context: { tokens: number; window: number } | null
275 /** The share of the last main request served from the prompt cache, 0 to 1. */
276 cache: number | null
277 usd: number | null
278 limits: readonly { kind: string; percent: number; resetsAt: number | null }[]
279 now: number
280 /** The context against the handoff budget, shown once it nears it; null keeps it off the strip. */
281 handoff?: { tokens: number; limit: number; warnAt: number } | null
282 /** A dr-superpowers run's progress: tasks done of all, and the round in flight. */
283 run?: { done: number; total: number; round: number | null } | null
284}
285
286// dr-status' default palette.
287const DIR = 'blue'
288const GIT = 'magenta'
289const MODEL = 'cyan'
290const COST = '#af87ff'
291const EFFORT: Record<string, string> = { low: 'gray', medium: 'blue', high: 'cyan', xhigh: '#af87ff', max: 'magenta' }
292// Bar widths per tier, as dr-status narrows them; under 2 draws no bar.
293const METER_WIDTHS = [
294 { ctx: 10, cache: 10, limit: 8 },
295 { ctx: 6, cache: 6, limit: 5 },
296 { ctx: 4, cache: 4, limit: 3 },
297 { ctx: 0, cache: 0, limit: 0 },
298] as const
299export const STATUS_TIERS = METER_WIDTHS.length
300
301/** The cells a row of runs takes. */
302export function runsWidth(runs: readonly Run[]): number {
303 return runs.reduce((sum, run) => sum + [...run.text].length, 0)
304}
305
306/** A row cut to `width` cells, ending in an ellipsis when anything was cut. */
307export function runsCut(runs: readonly Run[], width: number): Run[] {
308 if (runsWidth(runs) <= width) return [...runs]
309 const out: Run[] = []
310 let left = Math.max(0, width - 1)
311 for (const run of runs) {
312 const chars = [...run.text]
313 if (chars.length <= left) {
314 out.push(run)
315 left -= chars.length
316 continue
317 }
318 if (left > 0) out.push({ ...run, text: chars.slice(0, left).join('') })
319 break
320 }
321 if (width > 0) out.push({ text: '…', dim: true })
322 return out
323}
324
325/** A color from dr-status' config: a 256-color number becomes hex, orange its own hex, a name stays. */
326export function termColor(name: string | null | undefined): string | undefined {
327 if (name === null || name === undefined || name === '' || name === 'default') return undefined
328 if (name === 'orange') return '#ff8700'
329 if (!/^\d{1,3}$/.test(name)) return name
330 const n = Number(name)
331 if (n > 255) return undefined
332 const base = ['#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0',
333 '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff']
334 if (n < 16) return base[n]
335 const hex = (v: number) => v.toString(16).padStart(2, '0')
336 if (n >= 232) {
337 const v = 8 + (n - 232) * 10
338 return `#${hex(v)}${hex(v)}${hex(v)}`
339 }
340 const steps = [0, 95, 135, 175, 215, 255]
341 const i = n - 16
342 return `#${hex(steps[Math.floor(i / 36)] ?? 0)}${hex(steps[Math.floor(i / 6) % 6] ?? 0)}${hex(steps[i % 6] ?? 0)}`
343}
344
345/** "claude-opus-5-5[1m]" → "Opus 5.5", or "Opus" when short. */
346export function modelName(id: string, isShort = false): string {
347 const bare = id.replace(/\[.*?\]$/, '').replace(/^claude-/, '').replace(/-\d{8}$/, '')
348 const [family = bare, ...rest] = bare.split('-')
349 const name = family.charAt(0).toUpperCase() + family.slice(1)
350 const version = rest.filter(part => /^\d+$/.test(part)).join('.')
351 return isShort || version === '' ? name : `${name} ${version}`
352}
353
354/** The accounts key dr-status colors by: the config directory, "~"-relative under home. */
355export function accountKey(configDir: string, home: string): string {
356 const dir = slashes(configDir)
357 const base = slashes(home)
358 if (dir === base) return '~'
359 return base !== '' && dir.startsWith(`${base}/`) ? `~${dir.slice(base.length)}` : dir
360}
361
362/** Claude's billing type, "stripe_subscription" → "subscription". */
363export function billingLabel(billing: string | null): string | null {
364 if (billing === null || billing === '') return null
365 return billing.replace(/^(stripe|apple|google)_/, '').replace(/_/g, ' ')
366}
367
368function slashes(path: string): string {
369 return path.replace(/\\/g, '/').replace(/\/+$/, '')
370}
371
372/**
373 * The working directory in dr-status' three tones: what leads to the
374 * repository, the repository's name, and the path inside it.
375 */
376export function pathParts(cwd: string, root: string | null, home: string | null): { lead: string; anchor: string; inner: string } {
377 const tilde = (path: string) => {
378 const h = home === null ? '' : slashes(home)
379 return h !== '' && (path === h || path.startsWith(`${h}/`)) ? `~${path.slice(h.length)}` : path
380 }
381 const at = slashes(cwd)
382 const top = root === null ? null : slashes(root)
383 const anchorPath = top !== null && (at === top || at.startsWith(`${top}/`)) ? top : at
384 const cut = anchorPath.lastIndexOf('/')
385 const lead = cut <= 0 ? '' : tilde(anchorPath.slice(0, cut + 1))
386 return { lead, anchor: anchorPath.slice(cut + 1) || anchorPath, inner: at.slice(anchorPath.length) }
387}
388
389/** dr-status' two lines at one tier: 0 is the fullest, 3 the most compact. */
390export function statusLines(input: StatusInput, tier: number): [Run[], Run[]] {
391 const sep: Run = { text: tier < 2 ? ' · ' : ' · ', dim: true }
392 const join = (segments: (Run[] | null)[]) =>
393 segments.filter((one): one is Run[] => one !== null && one.length > 0).flatMap((one, index) => (index === 0 ? one : [sep, ...one]))
394 const widths = METER_WIDTHS[Math.min(tier, METER_WIDTHS.length - 1)] ?? METER_WIDTHS[0]
395 const extras = tier < 2
396
397 let dir: Run[] | null = null
398 if (input.cwd !== null) {
399 const { lead, anchor, inner } = pathParts(input.cwd, input.root, input.home)
400 const parts = inner.split('/').filter(Boolean)
401 const leaf = parts[parts.length - 1] ?? anchor
402 dir =
403 tier === 0
404 ? [{ text: lead, color: DIR, dim: true }, { text: anchor, color: DIR, bold: true }, { text: inner, color: DIR }]
405 : tier === 1
406 ? [{ text: anchor, color: DIR, bold: true }, { text: inner, color: DIR }]
407 : tier === 2
408 ? [{ text: anchor, color: DIR, bold: true }, { text: parts.length > 1 ? `/…/${leaf}` : inner, color: DIR }]
409 : [{ text: leaf, color: DIR, bold: true }]
410 dir = dir.filter(run => run.text !== '')
411 }
412
413 let git: Run[] | null = null
414 if (input.repo !== null) {
415 const r = input.repo
416 const branch = tier === 3 && r.branch.length > 16 ? `${r.branch.slice(0, 15)}…` : r.branch
417 const counters = [r.ahead > 0 ? `↑${r.ahead}` : '', r.behind > 0 ? `↓${r.behind}` : '', r.untracked > 0 ? `?${r.untracked}` : '']
418 .filter(Boolean)
419 .join(' ')
420 git = [
421 { text: branch, color: GIT, bold: true },
422 ...(r.changed > 0 ? [{ text: '*', color: GIT }] : []),
423 ...(tier < 2 && counters !== '' ? [{ text: ` ${counters}`, color: GIT }] : []),
424 ]
425 }
426
427 const model = input.model === null ? null : [{ text: modelName(input.model, tier >= 2), color: MODEL, bold: true }]
428 const effort = input.effort === null ? null : [{ text: input.effort, color: EFFORT[input.effort] ?? 'gray' }]
429 const run =
430 input.run == null || input.run.total === 0
431 ? null
432 : [{ text: `run ${input.run.done}/${input.run.total}${tier < 2 && input.run.round !== null ? ` r${input.run.round}` : ''}`, color: 'yellow' }]
433
434 const meter = (label: string, share: number, colorShare: number, width: number, extra: string): Run[] => {
435 const percent = Math.round(share * 100)
436 const color = rampColor(colorShare * 100)
437 return [
438 { text: `${label} ` },
439 ...(width >= 2 ? [{ text: `${bar(share, width)} `, color }] : []),
440 { text: `${percent}%`, color, bold: true },
441 ...(extras && extra !== '' ? [{ text: ` · ${extra}`, dim: true }] : []),
442 ]
443 }
444 const ctx =
445 input.context === null
446 ? null
447 : meter('ctx', input.context.tokens / input.context.window, input.context.tokens / input.context.window, widths.ctx, kTokens(input.context.tokens))
448 // A high hit rate is good news: the bar fills with the hits, the color reads the misses.
449 // The handoff reading joins the strip only once the context nears its budget.
450 const near = input.handoff == null ? null : levelOf(input.handoff.tokens, input.handoff.limit, input.handoff.warnAt)
451 const handoff =
452 input.handoff == null || near === null || near === 'quiet'
453 ? null
454 : [
455 { text: tier < 3 ? 'handoff ' : 'ho ' },
456 { text: `${Math.round((input.handoff.tokens / input.handoff.limit) * 100)}%`, color: near === 'handoff' ? 'error' : 'warning', bold: true },
457 ]
458 const cache = input.cache === null ? null : meter('cache', input.cache, 1 - input.cache, widths.cache, '')
459 const cost = input.usd === null ? null : [{ text: `$${input.usd.toFixed(2)}`, color: COST, bold: true }]
460 const limits = input.limits.map(limit =>
461 meter(limitLabel(limit.kind), limit.percent / 100, limit.percent / 100, widths.limit, limit.resetsAt === null ? '' : duration(limit.resetsAt - input.now)),
462 )
463
464 return [join([dir, git, model, effort, run]), join([ctx, handoff, cache, cost, ...limits])]
465}
466
467/**
468 * Each line at the fullest tier that fits `width`, stepping down on its own;
469 * a line too wide even at the last tier is cut.
470 */
471export function fitStatus(input: StatusInput, width: number): { tiers: [number, number]; lines: [Run[], Run[]] } {
472 const fit = (which: 0 | 1): [number, Run[]] => {
473 for (let tier = 0; tier < STATUS_TIERS; tier++) {
474 const line = statusLines(input, tier)[which]
475 if (runsWidth(line) <= width) return [tier, line]
476 }
477 return [STATUS_TIERS - 1, runsCut(statusLines(input, STATUS_TIERS - 1)[which], width)]
478 }
479 const [oneTier, one] = fit(0)
480 const [twoTier, two] = fit(1)
481 return { tiers: [oneTier, twoTier], lines: [one, two] }
482}
483
484/**
485 * How many main turns until the context reaches the budget, at the average
486 * growth of the last few turns; null with too little to go on, no growth, or
487 * already past it.
488 */
489export function turnsToBudget(perTurn: readonly number[], limit: number): number | null {
490 const recent = perTurn.slice(-6)
491 const last = recent[recent.length - 1]
492 if (recent.length < 3 || last === undefined || last >= limit) return null
493 const steps = recent.slice(1).map((value, index) => value - (recent[index] ?? value)).filter(step => step > 0)
494 if (steps.length === 0) return null
495 const growth = steps.reduce((sum, step) => sum + step, 0) / steps.length
496 return Math.max(1, Math.ceil((limit - last) / growth))
497}
498
499/**
500 * Where a rate limit is headed at the pace spent so far in its window: the
501 * milliseconds until it runs out when that comes before the reset, 'pace' when
502 * it lasts, null when the window is too young or its length unknown.
503 */
504export function limitForecast(kind: string, percent: number, resetsAt: number | null, now: number): number | 'pace' | null {
505 const length = WINDOW_MS[kind]
506 if (length === undefined || resetsAt === null || percent <= 0 || percent >= 100) return null
507 const spent = length - (resetsAt - now)
508 if (spent < 10 * 60_000) return null
509 const left = ((100 - percent) / percent) * spent
510 return now + left < resetsAt ? left : 'pace'
511}
512
513/** One task's state in a dr-superpowers SDD ledger. */
514export type LedgerTask = {
515 n: number
516 state: 'assigned' | 'complete' | 'blocked'
517 /** The fix or review round in flight, "2/5". */
518 round: string | null
519 /** The seat the task was assigned to. */
520 seat: string | null
521 isClean: boolean
522 reason: string | null
523}
524
525/** What a run's ledger says: its plan, each task it names, and whether the final review came back clean. */
526export type Ledger = { plan: string | null; tasks: LedgerTask[]; isFinished: boolean }
527
528/** Reads the subagent-driven-development ledger (progress.md) as its grammar writes it. */
529export function parseLedger(text: string): Ledger {
530 const tasks = new Map<number, LedgerTask>()
531 const task = (n: number) => {
532 const found = tasks.get(n) ?? { n, state: 'assigned' as const, round: null, seat: null, isClean: false, reason: null }
533 tasks.set(n, found)
534 return found
535 }
536 let plan: string | null = null
537 let isFinished = false
538 for (const raw of text.split(/\r?\n/)) {
539 const line = raw.trim()
540 const header = /^#\s*SDD ledger\s*[—-]+\s*plan:\s*(.+)$/.exec(line)
541 if (header) {
542 plan = header[1]?.trim() ?? null
543 continue
544 }
545 if (/^Final review: clean/.test(line)) {
546 isFinished = true
547 continue
548 }
549 const group = /^Group (\d+)-(\d+): review round (\d+\/\d+)/.exec(line)
550 if (group) {
551 for (let n = Number(group[1]); n <= Number(group[2]); n++) {
552 const one = task(n)
553 if (one.state === 'assigned') one.round = group[3] ?? null
554 }
555 continue
556 }
557 const match = /^Task (\d+): (.*)$/.exec(line)
558 if (!match) continue
559 const one = task(Number(match[1]))
560 const rest = match[2] ?? ''
561 const assigned = /^implementer (\S+) \(assigned/.exec(rest)
562 if (assigned) {
563 one.seat = assigned[1] ?? null
564 if (one.state !== 'complete') one.state = 'assigned'
565 one.reason = null
566 continue
567 }
568 const round = /^fix round (\d+\/\d+)/.exec(rest)
569 if (round) {
570 one.round = round[1] ?? null
571 continue
572 }
573 if (/^complete\b/.test(rest)) {
574 one.state = 'complete'
575 one.round = null
576 one.isClean = /review clean/.test(rest)
577 continue
578 }
579 const blocked = /^BLOCKED\s*[—-]+\s*(.*)$/.exec(rest)
580 if (blocked) {
581 one.state = 'blocked'
582 one.reason = blocked[1]?.trim() || null
583 }
584 }
585 return { plan, tasks: [...tasks.values()].sort((a, b) => a.n - b.n), isFinished }
586}
587
588/** A plan file's tasks, from its "### Task N: title" headings. */
589export function planTasks(text: string): { n: number; title: string }[] {
590 const out: { n: number; title: string }[] = []
591 for (const line of text.split(/\r?\n/)) {
592 const match = /^#{2,4}\s+Task\s+(\d+)\s*[:.\-—]?\s*(.*)$/.exec(line.trim())
593 if (match && !out.some(one => one.n === Number(match[1]))) out.push({ n: Number(match[1]), title: (match[2] ?? '').trim() })
594 }
595 return out
596}
597
598/** "2/5" → 2. */
599export function roundNumber(round: string | null): number | null {
600 const n = Number((round ?? '').split('/')[0])
601 return round !== null && Number.isFinite(n) && n > 0 ? n : null
602}
603
604/** Milliseconds → "42s", "2m14s", "1h05m": a turn's clock, to the second under an hour. */
605export function elapsed(ms: number): string {
606 const seconds = Math.max(0, Math.floor(ms / 1000))
607 if (seconds < 60) return `${seconds}s`
608 const minutes = Math.floor(seconds / 60)
609 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
610 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
611}
612
613/** What the session is doing, as the Now row reads it. */
614export type ActivityInput = {
615 kind: 'idle' | 'running' | 'waiting' | 'asking' | 'failed' | 'interrupted'
616 since: number
617 turnStartedAt: number | null
618 step: number
619 tool: string | null
620 agent: string | null
621 detail: string | null
622 lastTurnMs: number | null
623 endedAt: number | null
624 isSent: boolean
625}
626
627const AMBER = '#ffaf5f'
628
629/** The Now row: one line, whatever the state, so the pane never jumps. */
630export function nowLine(now: ActivityInput, at: number): Run[] {
631 const sep: Run = { text: ' · ', dim: true }
632 const sent: Run[] = now.isSent ? [sep, { text: 'Discord ✓', dim: true }] : []
633 switch (now.kind) {
634 case 'running':
635 return [
636 { text: '● ', color: 'cyan', bold: true },
637 { text: 'running ' },
638 { text: elapsed(at - (now.turnStartedAt ?? now.since)), color: 'cyan', bold: true },
639 ...(now.step > 0 ? [sep, { text: `step ${now.step}`, dim: true }] : []),
640 ...(now.tool === null ? [] : [sep, { text: now.tool }]),
641 ...(now.tool !== null && now.agent !== null ? [{ text: ' in ', dim: true }, { text: now.agent, color: 'magenta' }] : []),
642 ]
643 case 'waiting':
644 return [
645 { text: '⏳ approve? ', color: AMBER, bold: true },
646 ...(now.detail === null ? [] : [{ text: now.detail }, sep]),
647 { text: elapsed(at - now.since), color: AMBER, bold: true },
648 ...sent,
649 ]
650 case 'asking':
651 return [
652 { text: '❓ Claude is asking ', color: AMBER, bold: true },
653 { text: elapsed(at - now.since), color: AMBER, bold: true },
654 ...(now.detail === null ? [] : [sep, { text: now.detail }]),
655 ...sent,
656 ]
657 case 'failed':
658 return [
659 { text: '✗ last turn failed', color: 'error', bold: true },
660 ...(now.detail === null ? [] : [{ text: ` ${now.detail}` }]),
661 ...(now.lastTurnMs === null ? [] : [sep, { text: elapsed(now.lastTurnMs), dim: true }]),
662 ...sent,
663 ]
664 case 'interrupted':
665 return [
666 { text: '■ interrupted', color: AMBER, bold: true },
667 ...(now.detail === null ? [] : [sep, { text: now.detail }]),
668 ...(now.endedAt === null ? [] : [sep, { text: at - now.endedAt < 60_000 ? 'just now' : `${duration(at - now.endedAt)} ago`, dim: true }]),
669 ]
670 default:
671 return [
672 { text: '○ idle', dim: true },
673 ...(now.lastTurnMs === null ? [] : [sep, { text: 'last turn ', dim: true }, { text: elapsed(now.lastTurnMs) }]),
674 ...(now.endedAt === null ? [] : [sep, { text: `ended ${duration(at - now.endedAt) || '<1m'} ago`, dim: true }]),
675 ]
676 }
677}
678
679/** The Now row cut down for the strip's top rule; nothing while idle. */
680export function nowBadge(now: ActivityInput, at: number): Run[] {
681 switch (now.kind) {
682 case 'running':
683 return [
684 { text: '● ', color: 'cyan', bold: true },
685 { text: elapsed(at - (now.turnStartedAt ?? now.since)), color: 'cyan' },
686 ...(now.tool === null ? [] : [{ text: ` · ${now.tool}`, color: 'cyan' }]),
687 ]
688 case 'waiting':
689 return [{ text: `⏳ waiting ${elapsed(at - now.since)}`, color: AMBER, bold: true }]
690 case 'asking':
691 return [{ text: `❓ asking ${elapsed(at - now.since)}`, color: AMBER, bold: true }]
692 case 'failed':
693 return [{ text: '✗ failed', color: 'error', bold: true }]
694 case 'interrupted':
695 return [{ text: '■ interrupted', color: AMBER, bold: true }]
696 default:
697 return []
698 }
699}
700
701/** Whether a release (`2.1.295`) is at least `least`, compared part by part; an unreadable one is not. */
702export function isAtLeast(base: string | undefined, least: string): boolean {
703 const parts = (text: string) => text.split('-')[0]!.split('.').map(Number)
704 if (base === undefined || !/^\d+\.\d+\.\d+/.test(base)) return false
705 const have = parts(base)
706 const want = parts(least)
707 for (let i = 0; i < want.length; i++) {
708 if ((have[i] ?? 0) !== want[i]) return (have[i] ?? 0) > want[i]!
709 }
710 return true
711}
712
713/** A background task's end as its notification reads: `<task-id>` and `<status>`. */
714export function tasksEnded(text: string): { id: string; status: string }[] {
715 const ended: { id: string; status: string }[] = []
716 for (const block of text.split('<task-notification>').slice(1)) {
717 const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(block)?.[1]
718 if (id === undefined) continue
719 ended.push({ id, status: /<status>\s*([^<\s]+)\s*<\/status>/.exec(block)?.[1] ?? 'completed' })
720 }
721 return ended
722}
723
724/** A remote client's surface as a person names the device. */
725export function deviceName(surface: string): string {
726 return surface === 'mobile' ? 'phone' : surface === 'desktop' ? 'desktop app' : surface === 'vscode' ? 'VS Code' : surface
727}
728
729/** Remote Control as the cockpit can see it: the devices attached, or the startup setting. */
730export function remoteRuns(clients: readonly { surface: string }[], setting: string | null): Run[] {
731 if (clients.length > 0) {
732 const names = [...new Set(clients.map(one => deviceName(one.surface)))]
733 const what = clients.length === 1 ? names[0] : names.length === 1 ? `${clients.length} ${names[0]}s` : `${clients.length} devices`
734 return [{ text: '● ', color: 'green', bold: true }, { text: 'connected' }, { text: ' · ', dim: true }, { text: what, color: 'green' }]
735 }
736 if (setting === 'true') return [{ text: '○ ', color: 'green' }, { text: 'on', color: 'green' }, { text: ' · no device yet', dim: true }]
737 return [{ text: '○ not connected', dim: true }]
738}
739
740/** What keeps running beside the turn, for the strip's bottom rule: shells and remote devices. */
741export function backgroundBadge(shells: number, clients: readonly { surface: string }[]): Run[] {
742 const runs: Run[] = []
743 if (shells > 0) runs.push({ text: '$ ', color: 'yellow', bold: true }, { text: `${shells} ${shells === 1 ? 'shell' : 'shells'}`, color: 'yellow' })
744 if (clients.length > 0) {
745 if (runs.length > 0) runs.push({ text: ' · ', dim: true })
746 const names = [...new Set(clients.map(one => deviceName(one.surface)))]
747 runs.push({ text: '● ', color: 'green', bold: true }, { text: clients.length === 1 || names.length === 1 ? names[0] : `${clients.length} devices`, color: 'green' })
748 }
749 return runs
750}
751hooks/notify.ts 141 lines1// Discord notifications: what each event sends, kept pure so tests reach it.
2// register.tsx holds the sender, since only it may reach the host.
3export const NOTIFY_KINDS = ['ask', 'question', 'done', 'error', 'blocked', 'budget', 'limit', 'task', 'session', 'agent'] as const
4export type NotifyKind = (typeof NOTIFY_KINDS)[number]
5
6/** The kinds that send unless the settings say otherwise. */
7export const DEFAULT_NOTIFY_ON: readonly NotifyKind[] = ['ask', 'question', 'done', 'error', 'blocked', 'budget', 'limit']
8
9// The kinds that need the person: they mention them, the rest post silently.
10const PINGS: readonly NotifyKind[] = ['ask', 'question', 'error', 'blocked']
11const COLORS: Record<NotifyKind, number> = {
12 ask: 0xffaf00,
13 question: 0xffaf00,
14 done: 0x57d787,
15 task: 0x57d787,
16 error: 0xff5f5f,
17 blocked: 0xff5f5f,
18 budget: 0x5f87ff,
19 limit: 0x5f87ff,
20 session: 0x5f87ff,
21 agent: 0x5f87ff,
22}
23// Discord's flag for a message that notifies nobody (`@silent`).
24const SUPPRESS_NOTIFICATIONS = 4096
25const WEBHOOK = /^https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api\/webhooks\/(\d+)\/([\w-]+)\/?$/
26
27/** The notifyOn setting, "ask, done" → ['ask', 'done']: known kinds, each once; empty is the default set. */
28export function notifyKindsFrom(text: string): NotifyKind[] {
29 const names = text
30 .split(/[\s,]+/)
31 .map(name => name.trim().toLowerCase())
32 .filter((name): name is NotifyKind => (NOTIFY_KINDS as readonly string[]).includes(name))
33 return names.length === 0 ? [...DEFAULT_NOTIFY_ON] : [...new Set(names)]
34}
35
36/** The notifyOn setting after turning one kind on or off; the default set is the empty setting. */
37export function notifyKindsToggled(on: readonly NotifyKind[], kind: NotifyKind): string {
38 const next = on.includes(kind) ? on.filter(one => one !== kind) : [...on, kind]
39 const ordered = NOTIFY_KINDS.filter(one => next.includes(one))
40 const isDefault = ordered.length === DEFAULT_NOTIFY_ON.length && ordered.every(one => DEFAULT_NOTIFY_ON.includes(one))
41 // Every kind turned off is spelt out, so it does not read back as the default.
42 return isDefault ? '' : ordered.length === 0 ? 'none' : ordered.join(',')
43}
44
45/** Whether a URL is a Discord webhook, the only kind the cockpit posts to. */
46export function isDiscordWebhook(url: string): boolean {
47 return WEBHOOK.test(url.trim())
48}
49
50/** A webhook as the settings show it: the host, the id's start, the token hidden. */
51export function maskWebhook(url: string): string {
52 const match = WEBHOOK.exec(url.trim())
53 if (!match) return '••••'
54 return `discord.com/…/${(match[1] ?? '').slice(0, 4)}…/••••`
55}
56
57/** A Discord user id, digits only, as the mention setting takes it; else null. */
58export function mentionId(text: string): string | null {
59 const id = text.trim().replace(/^<@!?/, '').replace(/>$/, '')
60 return /^\d{5,25}$/.test(id) ? id : null
61}
62
63const SECRETS: [RegExp, string][] = [
64 [/\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, '[token]'],
65 [/\b(sk-[A-Za-z0-9_-]{16,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})/g, '[token]'],
66 [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [token]'],
67 [/\b((?:api[_-]?key|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[hidden]'],
68 [/https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api\/webhooks\/\S+/g, '[webhook]'],
69]
70
71/** Masks the secrets text may carry before it leaves the machine. */
72export function redact(text: string): string {
73 return SECRETS.reduce((out, [pattern, mask]) => out.replace(pattern, mask), text)
74}
75
76/** Text cut to `max` characters on one line or a few, redacted. */
77export function excerpt(text: string, max: number): string {
78 const clean = redact(text.replace(/\r/g, '').trim())
79 const chars = [...clean]
80 return chars.length <= max ? clean : `${chars.slice(0, Math.max(0, max - 1)).join('').trimEnd()}…`
81}
82
83/** One event to send: its kind, a title, and what to say under it. */
84export type NotifyEvent = {
85 kind: NotifyKind
86 title: string
87 /** The one thing to read; full detail only, unless `isPlain`. */
88 detail?: string
89 /** Shown whatever the detail setting: carries no session text. */
90 isPlain?: boolean
91 fields?: { name: string; value: string }[]
92}
93
94/** Where a message came from, for its header and footer. */
95export type NotifyPlace = {
96 host: string | null
97 repo: string | null
98 branch: string | null
99 tmux: string | null
100 account: string | null
101}
102
103export type NotifyStyle = { mention: string | null; isBrief: boolean; now: number }
104
105/** The webhook body for one event. */
106export function discordBody(event: NotifyEvent, place: NotifyPlace, style: NotifyStyle): Record<string, unknown> {
107 const isPing = PINGS.includes(event.kind) && style.mention !== null
108 const fields = [
109 ...(place.branch === null ? [] : [{ name: 'Branch', value: place.branch }]),
110 ...(event.fields ?? []),
111 ]
112 .slice(0, 6)
113 .map(field => ({ name: field.name.slice(0, 256), value: (field.value || '—').slice(0, 1024), inline: true }))
114 const description = event.detail === undefined || (style.isBrief && event.isPlain !== true) ? undefined : event.detail.slice(0, 1500)
115 const footer = ['dr-cockpit', place.account, place.tmux === null ? null : `tmux ${place.tmux}`].filter(Boolean).join(' · ')
116 const author = [place.host, place.repo].filter(Boolean).join(' · ')
117 const embed: Record<string, unknown> = {
118 title: event.title.slice(0, 256),
119 color: COLORS[event.kind],
120 ...(author === '' ? {} : { author: { name: author.slice(0, 256) } }),
121 ...(description === undefined || description === '' ? {} : { description }),
122 ...(fields.length === 0 ? {} : { fields }),
123 footer: { text: footer.slice(0, 2048) },
124 timestamp: new Date(style.now).toISOString(),
125 }
126 return {
127 username: 'Cockpit',
128 ...(isPing ? { content: `<@${style.mention}>` } : { flags: SUPPRESS_NOTIFICATIONS }),
129 allowed_mentions: { parse: [], users: isPing ? [style.mention] : [] },
130 embeds: [embed],
131 }
132}
133
134/** Whether a kind mentions the person. */
135export function isPing(kind: NotifyKind): boolean {
136 return PINGS.includes(kind)
137}
138
139/** What a send came to: the message id when Discord gave one, else why not. */
140export type SendResult = { isSent: true; id: string | null } | { isSent: false; reason: string }
141types/index.d.ts 228 lines1/** The main session's context against its handoff budget, as last read. */
2export type CockpitBudget = {
3 /** Input tokens the last main-loop response was answered over. */
4 tokens: number
5 /** The handoff budget in tokens. */
6 limit: number
7 /** The model's context window in tokens. */
8 window: number
9 /** The auto-compaction window from the settings, when one is set. */
10 compactWindow: number | null
11 /** What the session has cost so far, in US dollars, when the host says. */
12 usd: number | null
13}
14
15/** One rate-limit window as the last response reported it. */
16export type CockpitLimit = {
17 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
18 kind: string
19 percent: number
20 /** When the window resets, in milliseconds since the epoch. */
21 resetsAt: number | null
22}
23
24/** The account's limits and the session's spend, from the last measurement. */
25export type CockpitUsage = {
26 limits: CockpitLimit[]
27 /** When the session began, in milliseconds since the epoch. */
28 startedAt: number
29 /** When these figures were read, in milliseconds since the epoch. */
30 readAt: number
31}
32
33/** One row of the /context breakdown. */
34export type CockpitContextRow = {
35 name: string
36 tokens: number
37}
38
39/** The /context breakdown, estimated locally. */
40export type CockpitBreakdown = {
41 rows: CockpitContextRow[]
42 /** The token count at which auto-compaction runs, when it is on. */
43 compactAt: number | null
44}
45
46/** One subagent type's runs and tokens this session. */
47export type CockpitSeat = {
48 seat: string
49 runs: number
50 input: number
51 cacheRead: number
52 cacheWrite: number
53 output: number
54}
55
56/** One subagent the session started. */
57export type CockpitSpawn = {
58 agentId: string
59 seat: string
60 model: string
61 description: string
62 isDone: boolean
63 /** When it started, in milliseconds since the epoch. */
64 startedAt: number
65}
66
67/** One background shell the session started and has not heard the end of. */
68export type CockpitShell = {
69 /** The background task's id, as its notification names it. */
70 id: string
71 command: string
72 /** When it went to the background, in milliseconds since the epoch. */
73 startedAt: number
74}
75
76/** Remote Control as the cockpit sees it. */
77export type CockpitRemote = {
78 /** The remote clients attached now (a phone, the desktop app). */
79 clients: { id: string; surface: string }[]
80 /** The `/config` row "Enable Remote Control for all sessions": `true`, `false` or `default`. */
81 setting: string | null
82}
83
84/** One item of the session's todo or task list. */
85export type CockpitPlanItem = {
86 id: string
87 text: string
88 status: 'pending' | 'in_progress' | 'completed'
89}
90
91/** The working directory's git state. */
92export type CockpitRepo = {
93 branch: string
94 ahead: number
95 behind: number
96 changed: number
97 untracked: number
98}
99
100/** A git write the attribution guard refused. */
101export type CockpitRefusal = {
102 at: number
103 tool: string
104 line: string
105}
106
107/** The account the session runs as, from its .claude.json and dr-status' config. */
108export type CockpitAccount = {
109 email: string | null
110 name: string | null
111 organization: string | null
112 role: string | null
113 /** Claude's billing type, such as `stripe_subscription`. */
114 billing: string | null
115 /** The config directory as dr-status keys it: `~/.claude`, `~/.claude-work`. */
116 key: string
117 /** The frame color dr-status assigns this account, as configured. */
118 color: string | null
119}
120
121/** Where the session runs, for the status strip's path. */
122export type CockpitPlace = {
123 cwd: string
124 /** The repository's root, when the working directory is in one. */
125 root: string | null
126 home: string | null
127}
128
129/** The main loop's model, effort and cache hit rate, as its last request had them. */
130export type CockpitEngine = {
131 model: string | null
132 effort: string | null
133 /** The share of the last main turn's input served from the cache, 0 to 1. */
134 cache: number | null
135}
136
137/** What the session is doing now, for the Now row and the state file. */
138export type CockpitActivity = {
139 /** `waiting` on an approval, `asking` a question, `failed` once a turn ended in an error, `interrupted` once Esc stopped it. */
140 kind: 'idle' | 'running' | 'waiting' | 'asking' | 'failed' | 'interrupted'
141 /** When this state began, in milliseconds since the epoch. */
142 since: number
143 /** When the running turn began. */
144 turnStartedAt: number | null
145 /** The main loop's request count this turn. */
146 step: number
147 /** The tool in flight, and the subagent seat running it. */
148 tool: string | null
149 agent: string | null
150 /** What waits on the person, or why the turn failed. */
151 detail: string | null
152 lastTurnMs: number | null
153 endedAt: number | null
154 /** Whether a Discord message went out for this state. */
155 isSent: boolean
156}
157
158/** One task of a dr-superpowers run, from its plan and its ledger. */
159export type CockpitRunTask = {
160 n: number
161 title: string
162 state: 'pending' | 'assigned' | 'complete' | 'blocked'
163 /** The fix or review round in flight, "2/5". */
164 round: string | null
165 seat: string | null
166 isClean: boolean
167 reason: string | null
168}
169
170/** A dr-superpowers run as its SDD ledger tells it. */
171export type CockpitRun = {
172 plan: string | null
173 tasks: CockpitRunTask[]
174 done: number
175 total: number
176 isFinished: boolean
177}
178
179/** The Discord webhook's state, as the settings view shows it. */
180export type CockpitNotifier = {
181 /** The webhook URL; never drawn, only masked. */
182 url: string | null
183 isFromEnv: boolean
184 /** The Discord user id that ping messages mention. */
185 mention: string | null
186 /** The field being edited in the settings view. */
187 editing: 'url' | 'mention' | null
188 /** Why the last edit was refused. */
189 error: string | null
190 /** The last send's outcome. */
191 last: { at: number; text: string; isError: boolean } | null
192}
193
194declare module 'claude-code' {
195 interface PluginState {
196 'dr-cockpit': {
197 budget: CockpitBudget | null
198 usage: CockpitUsage | null
199 breakdown: CockpitBreakdown | null
200 seats: CockpitSeat[]
201 spawns: CockpitSpawn[]
202 shells: CockpitShell[]
203 remote: CockpitRemote
204 plan: CockpitPlanItem[]
205 repo: CockpitRepo | null
206 refusals: CockpitRefusal[]
207 account: CockpitAccount | null
208 place: CockpitPlace | null
209 engine: CockpitEngine
210 /** Rate-limit windows already alerted, as `<kind>@<resetsAt>`. */
211 alerted: string[]
212 /** The main session's context readings, oldest first, for the trend line. */
213 trend: number[]
214 /** Whether the pane shows the cockpit or its settings. */
215 view: 'cockpit' | 'settings'
216 activity: CockpitActivity
217 /** The main session's context at the end of each turn, oldest first, for the forecast. */
218 turnTokens: number[]
219 run: CockpitRun | null
220 /** When the newest handoff note was written, in milliseconds since the epoch. */
221 handoffAt: number | null
222 notifier: CockpitNotifier
223 isNudged: boolean
224 isPlanSession: boolean
225 }
226 }
227}
228