SLOPSHOPPER

dr-cockpit

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…

newpaneguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dr-cockpit
│ ┃ Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ ○ idle · last turn 42s · ended 0m ago │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ Account │ ⎿ Read 6 lines │ ┃ │ ● not signed in │ ⏺ Update(src/auth.ts) │ ┃ │ Opus 5.5 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Remote ○ not connected │ ⏺ Bash(bun test) │ ┃ │ config ~/.claude │ ⎿ 3 pass, 1 fail │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Context 212% of handoff │ ┃ │ ▰▰▰▰▰▰▰▰▰▰▰┃▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ▱▱▱▱▱▱▱▱▱▱▱▱ │ ┃ │ 97k of 200k ┃ handoff 46k › /cockpit │ ┃ │ ╭──────────────────────────────────────╮ ╭ ⎿ dr-cockpit: Cockpit pane opened. │ ┃ │ │ c: Compact │ │ │ ┃ │ ╰──────────────────────────────────────╯ ╰ │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Usage $0.42 · $0.84/h │ │ ┃ │ 5h │ │ ┃ │ ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ │ │ ┃ │ 31% │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Agents & shells │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cockpit
○ idle · last turn 42s · ended 0m ago ╭──────────────────────────────────────────────────────────╮ │ Account │ │ ● not signed in │ │ Opus 5.5 │ │ Remote ○ not connected │ │ config ~/.claude │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ Context 212% of handoff │ ▰▰▰▰▰▰▰▰▰▰▰┃▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ │ 97k of 200k ┃ handoff 46k │ ╭──────────────────────────────────────────────────────╮ ╭ │ │ c: Compact │ │ │ ╰──────────────────────────────────────────────────────╯ ╰ ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Usage $0.42 · $0.84/h │ │ 5h ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Agents & shells │ │ No subagents or background shells yet. │ ╰──────────────────────────────────────────────────────────╯
README

dr-cockpit

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.

What it does

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.

  • Now is the turn in flight: running with its time, step, tool and the subagent calling it; waiting for your approval; Claude asking you something; idle with the last turn's length; the last turn failed; or Esc interrupted it, with the background shells and agents still running after it (Esc stops the turn, not them). It adds Discord ✓ once a notification went out for what it shows.
  • Account is who the session runs as: the email, name, organization and role from the account's .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 is the main session against its handoff budget, the window and where auto-compaction runs, and the largest /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.
  • Usage is the account's 5-hour and 7-day limits with their resets, and the session's cost and cost per hour. Once a window is ten minutes old each meter says whether the limit lasts to its reset at the pace so far (on pace), or when it runs out first, in amber.
  • Agents & shells lists the background shells still running (a Bash call sent to the background, by Claude or with Ctrl+B) with how long each has run, until its notification or a stop ends it; then the subagents running and recently finished, then each subagent type (seat) with its runs, input tokens, the share served from the prompt cache and output tokens. Use it to see where a plan's tokens go.
  • Plan follows the main session's todo list or task list, centred on the item in progress. In a 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.
  • Repo is the working tree's branch, ahead and behind counts and changes.
  • Guard lists commits and pull requests the attribution guard refused.

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.

EventWhenDefaultPings
askA tool call waits for your approval past notifyAskAfter; the message is edited once you approve or deny itonyes
questionClaude asks you something or has a plan ready, likewiseonyes
doneA turn ends after running at least notifyAfter secondsonno
errorA turn failsonyes
blockedA dr-superpowers task is blockedonyes
budgetThe context crosses the handoff budgetonno
limitA usage limit crosses limitAlertAtonno
taskA run's task completes, or its final review is cleanoffno
sessionThe session ends, with its resume commandoffno
agentA subagent finishesoffno

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.

Settings

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 }
    }
  }
}
OptionDefaultEffect
handoffTokens0The 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.
nudgeModeltrueAdd the one note to the conversation at the budget, in dr-superpowers sessions.
guardAttributiontrueBlank the engine's attribution text and refuse git writes that carry it.
warnAt80Where the strip's handoff reading appears, as a percentage of the handoff budget (1-99).
layoutautoauto: 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.
sectionsemptyThe 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.
breakdownRows6How many /context categories the Context section lists, largest first, up to 12. 0 hides them.
openAtStarttrueOpen 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.
limitAlertAt90The usage-limit alert's line, in percent. 0 turns the alert off.
notifyOnemptyThe Discord events to send, comma-separated (see above). Empty sends the default set; none sends nothing.
notifyAfter60Seconds a turn must run before done sends. 0 sends every turn.
notifyAskAfter20Seconds an approval or question waits before it sends. 0 sends at once.
notifyDetailfullfull includes command and answer excerpts; brief sends titles and fields only.

Install

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

Development

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.

Source 4 files
hooks/register.tsx 2091 lines
1import { 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 lines
1// 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}
751
hooks/notify.ts 141 lines
1// 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 }
141
types/index.d.ts 228 lines
1/** 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