Brainstorm, plan and execute with scored tasks, tiered implementers, criteria-based review, UI/UX work proven in the running UI and an external Codex executor…

A standalone fork of superpowers 6.3.0 (MIT — see LICENSES/) for Claude Code and Codex, built around one idea: a strong model writes the plan, a Sonnet-class session or small tiered implementers execute it literally, and every judgment call goes to one strong ruling seat. Install dforge@darkraise from the repository-root marketplace, then disable the upstream superpowers plugin, dcc-superpower-companions and andrej-karpathy-skills: same-named skills in two enabled plugins can double-trigger.
Four findings from real transcripts drove the fork; the numbers are in docs/superpowers/specs/2026-09-11-dr-superpowers-fork-design.md.
Execution: line decides inline or subagent mode, and plan-lint checks the header, the scores and the mode before a plan is saved.scripts/plan-amend so the plan on disk stays immutable.Codex host. Read native-codex.md. Its codex-v4 policy routes scores 0–9 through Luna, Sol and Astra with the same four raw axes; scouts start at Sol medium, judges at Astra high; the Claude hooks are disabled in the native manifest. Native reviewers have independent contexts; enforce read-only restrictions where the host allows and disclose instruction-only restrictions where it does not.
External CLI lane. On Claude, a task scoring 2–4 at risk ≤ 1 may run on the Codex CLI instead of a Claude implementer. The lane needs an exclusively assigned linked worktree, stable task IDs, approved file paths and durable recovery; read external-task-recovery.md before offloading.
Twenty-one agents in four classes. Seven execution implementers - Sonnet 5.5 and Opus 5.5 at low, medium, and high, plus one Haiku 4.5 agent - are everything the assignment table and the escalation ladder can reach; no score assigns impl-opus-low or impl-opus-medium; both are reached only by escalation. Since 1.27.0 no score assigns impl-haiku either: total 0 runs on impl-sonnet-low, which cost less per pass in the tier evaluation, and Haiku is reached only by a human Implementer: override. Nine reserve implementers - the xhigh and max efforts, and every Fable 5.1 tier - are reachable only by a human override, or by a task that has already been split once and still exhausted impl-opus-high. Four read-only role agents - the judges judge-opus, judge-sonnet-high and judge-fable (an auto run's spec review and rulings, or a human override), and scout-sonnet - whose tools: frontmatter omits Edit, Write, and Agent, so a reviewer that cannot modify the tree or spawn subagents is a fact about the registry rather than a request in a prompt. One drafter, drafter-sonnet, writes a slice of 2-3 tasks of a plan whose header is written (writing-plans' Drafting the tasks); it may run code in a scratch worktree of its own, so it carries Bash, Write and Edit, but never Agent.
The reserve exists so that a human ruling, and a task that genuinely cannot be split any further, both have somewhere to go. It is not a way around the gate: above impl-opus-high the first answer to a hard task is still to split it, and the reserve is only entered when that split has already been spent.
A four-axis rubric that gates the plan. Files, spec completeness, coupling, and risk, each scored 0 to 3. Three of those four measure how the task was drawn, not how hard the change is, so Rule S sends a task scoring 4 or more across them back to be split rather than to a larger model. That cap is what makes the assignment table stop at 6.
An escalation ladder. Every execution implementer has exactly one successor, changing model before effort except at the two Opus effort rows, where Opus is already the top model and there is nowhere else to go. Walks terminate at a SPLIT action rather than an agent, and only a task that survives that split enters the reserve chain, which terminates at BLOCKED. Used at fix rounds 4 and 5, at the BLOCKED handler, and at round 3 when the re-review reports stalled progress.
Criteria-scored reviews. criteria/ holds narrow scored criteria adapted from LLM-as-a-Verifier (arXiv:2607.05391): a ground-truth note the judge sees on every evaluation, and 2 to 4 criteria that each say where to look, what scores high, what scores low, and what to ignore. Task reviews score four criteria - spec, scope, verification, quality - 1 to 20 each, alongside the spec and quality verdicts and never replacing them, because the fix loop keys on those verdicts. With Codex reviews on (codexReview), tasks at risk 3 are reviewed by Codex gpt-6-astra and then by judge-opus, which rules CONFIRMED or REJECTED on every Codex finding in the same pass.
Best-of-3 approach selection. selecting-approaches gates an open approach decision to inline, one advisory pass, or three scouts ranked by a judge in a ring pass that cancels positional bias. Five numbered skip conditions send most decisions to inline, including bug fixes with a located root cause - where ranking candidates generated before the root cause is known would launder guesses into a confident pick.
Two execution modes, chosen by the plan. The Execution: line decides whether a plan runs under subagent-driven-development, a dispatch and a scored review per task, or under executing-plans, where one session implements every task itself. Inline mode is the default unless more than half the tasks are heavy (total 5 or more, or risk 3). An inline plan delegates its heavy tasks, which are too large or risky to implement in the session, and every task carrying an Executor: line, which runs on that executor rather than in the session. Each delegated task runs through an implementer subagent — or, for an Executor line, that executor's wrapper — with the full per-task review loop, shared with subagent mode in reference/delegated-task.md; the session implements the rest itself, trading their per-task review for one whole-branch review at the end, which both modes share. plan-lint requires the session's effort to cover the highest score it implements, and --effort high once it delegates. A task that will not converge after three fix rounds escalates to subagent mode at the next task boundary, recorded in the ledger both modes write.
Parallel waves by default. The executionStrategy setting defaults to parallel: a plan runs its independent tasks in waves, and falls back to sequential when no two tasks can share a wave, on Codex, and in inline mode. Set it to sequential to turn waves off, or to ask to have brainstorming ask once the approach is settled (and writing-plans when a spec carries no answer). Brainstorming records the result in the spec as Execution strategy:. A parallel plan groups its tasks into waves, written Parallelism: waves — 1 | 2-4 | 5: contiguous task ranges whose tasks share no file and consume nothing from each other, with a Depends on: line on every task. Subagent mode runs a wave's tasks at once, each in its own git worktree, reviews each exactly as it would a sequential task, merges each reviewed task with scripts/wave merge (which refuses a task whose files overlap what the wave already merged), and runs the plan's Integration check: before the next wave starts. plan-lint keeps risk-3 tasks, executor tasks and dependency-manifest changes alone in their wave, the pre-flight ruling seat checks every same-wave pair for coupling the file lists cannot show, and context-size --wave W keeps room in the session for every task running at once. Running a wave one task after another is always correct, so every doubt serializes it; reference/parallel-waves.md holds the procedure. A plan without a Parallelism: line runs exactly as before.
An external executor lane. A task scoring 2 to 4 with risk <= 1 can run on the Codex CLI instead of a Claude implementer, for quota offload onto a separate ChatGPT subscription and for a second model family in the loop. The lane is a gate in front of the assignment table, never a rung on the escalation ladder: offload selects downward at the cheap end while the ladder only moves upward, and one total order cannot express both. The Claude ladder stays the sole backstop, so its termination proof is untouched.
The gate floors at score 2 on purpose. Rule S caps reducible at 3, so under risk <= 1 the eligible totals are exactly 2, 3, and 4; without the floor the gate would reduce to risk <= 1 and capture nearly every task by count, and it would offload score-0 work where the displaced agent is impl-sonnet-low and the wrapper costs more to orchestrate than it saves.
Implementer: still names the Claude agent for the score. Executor: is an override on a second line, which is what makes a machine without Codex, a cold session, and an executor whose auth has lapsed all degrade by reading a line that is already there rather than re-deriving the assignment at dispatch. Under dforge:executing-plans both lines are read only for the tasks an inline plan delegates; for the tasks the session implements itself they are inert, so nothing falls back.
revising-plans brings a plan written before the external executor lane up to the current format, scoring what is unscored and writing the Executor: and Execution: lines from scripts/plan-revise.
The following observations were made against the external CLI lane on Windows with ChatGPT-subscription auth on 2026-08-31. They are historical CLI policy evidence, not native Codex capability declarations:
gpt-5.5 and gpt-5.6-sol were available. luna and terra were rejected with HTTP 400 and Codex held no metadata for either.low, medium, high, xhigh, ultra. minimal is rejected. The CLI validates neither model nor effort locally - it echoes any string and fails at the API - so scripts/run-codex-task.sh validates both before spawning anything..git under -s workspace-write, which codex sandbox -- git add -A reproduces with no model call. The wrapper owns the commit, so the ledger's commit range is measured rather than reported. It also inlines the repository's CLAUDE.md into every prompt, because Codex natively reads only AGENTS.md, and passes the model's own conventional-commit subject to git commit unmodified.scripts/lib/codex-client.mjs passes resumeThreadId, and the runner re-sends the model and the effort on every resumed turn, because a resumed thread that fell back to the user's config defaults would run a fix round at a tier the ledger does not record.Every Codex interaction — the session gate, both runners and the executor roster — goes through the official codex@openai-codex plugin. scripts/codex-plugin locates it for the active profile and enforces a version allowlist; scripts/lib/codex-client.mjs runs one turn through the plugin's own client and reaps the broker it causes to exist. This plugin never names the codex executable and never reads Codex-owned state.
That probe is a dated observation, not a standing fact: the same two models were listed in the local catalog on 2026-09-14, and gpt-6-astra ran there on that date. This plugin reads no model catalog at all: the judge seats attempt the preferred rung and fall back once on a refusal, which is the only evidence of entitlement that has ever been reliable.
On 2026-09-23 the lane and the judge fallback moved to gpt-6-sol: gpt-5.5 retires from Codex with ChatGPT sign-in on 2026-10-14, and Codex 0.155.1's catalog lists gpt-6-sol as the upgrade for gpt-5.6-sol, with every lane effort advertised for a ChatGPT Pro sign-in.
On 2026-10-07 they moved again, to gpt-6.1-sol, Codex's default since 0.159.1, and the review seat for totals 4 to 6 moved from Astra to it. The move rests on OpenAI's description of 6.1 Sol as near-Astra on software work, at about a fifth of Astra's price; no calibration replay was run, because no Codex client was available to run one. Native Codex plans moved to codex-v4, which puts gpt-6.1-sol at ranks 3 to 6.
A different machine, account, or Codex version must verify the tables against its own account before relying on them. Do not make paid capability probes.
Cross-family review. Codex reviews are opt-in. By default every review runs on Claude judges: judge-sonnet-high for tasks totalling 0 to 3, judge-opus for 4 to 6 and at risk 3, plan review and the final review. For a one-off Codex review, run /codex:review or /codex:adversarial-review from the codex plugin. Turning the codexReview setting on gives Codex the review seats below for every run. With it on, Codex is the default task reviewer: gpt-6.1-sol for tasks totalling 0 to 6, and gpt-6-astra followed by judge-opus at risk 3. A task the executor lane implemented is always reviewed by a Claude judge, so Codex never reviews its own work there; when a Codex seat produces nothing, judge-sonnet-high takes totals 0 to 3 and judge-opus 4 to 6 and at risk 3. Plan review takes Astra for round 1 (judge-opus when Codex is off) and judge-opus for delta rounds, capped by the plan's highest task total. The final whole-branch review runs on judge-opus, its one fix subagent on the highest tier among the tasks the findings touch, and with codexReview on gains a Codex round. When both reviewers return findings, a fresh judge-opus dedupes and verifies them. No review route names judge-fable: on 2026-09-23 Opus 5.5 at high effort matched or beat Fable 5.1 on two replayed reviews with known defects, at about 40% of the cost, so Fable takes a review seat only when you ask for it. An auto run is the exception: it dispatches judge-fable for the spec review and for each batch of rulings (reference/auto-run.md). With one list there is no third seat: the fixer triages each finding against the code and the scoped re-review rules on every rejection. The Codex round is not self-review-free, because the branch contains whatever the executor lane produced, which is why no finding is acted on unchecked. scripts/review-route prints the review seat for a task or plan round, and every Codex seat runs through scripts/run-codex-review.sh, which checks availability on each run and falls back once to gpt-6.1-sol when Astra refuses the run. Selection, the run bound and the four outcomes live in those scripts rather than in prose, so a refused model is a recorded substitution instead of a silently missing seat.
The Codex session gate. Before any Codex use, scripts/codex-gate checks the official codex plugin once per session - enabled, installed at an allowed version, logged in, not on a free ChatGPT plan, and within quota - through the plugin's own client, and caches the answer per session. When Codex is unusable, every Codex seat, the final-review Codex round, the executor lane and plan-lint's lane probe are skipped for the session and Claude seats take over; a quota error mid-run turns Codex off the same way. A usable Codex is still used only on a surface whose shipping gate passed, recorded per surface in reference/codex-plugin.json: the calibration replay opens the review seats, and the executor-lane smoke test opens the lane.
Project state. docs/dforge/ holds what a project knows about itself, not just its specs and plans: gates.md declares the verification gates in order, plans/completed.md indexes merged plans, and distilled/ holds four durable files — constraints, gotchas, reference, rejected — collapsed from session notes. Three skills use it. project-status reports where the work stands and exactly one next step, and writes nothing. running-gates runs the declared gates in order and stops at the first red, judging each by its Evidence type so a gate whose proof is a picture is never passed off a metric. distilling-docs distils notes into the four files and deletes the tracked sources it absorbed, once an independent judge has confirmed every enumerated fact was carried. Every one of these files is optional; a project without them behaves exactly as before.
Test simplification. test-simplifier shrinks a suite that TDD has grown slow, in the order that risks the least. scripts/test-profile reads the JUnit XML reports a runner already emits and prints a short table of where the time goes. Then the skill speeds tests up without deleting any, tiers the run into an inner loop, a branch gate and a full sweep, and only then merges or removes tests, one module per wave. A wave changes test files only, and stands only when every mutant the old tests killed is still killed (or, with no mutation tool, every fault a judge named still turns the suite red) and judge-opus returns KEPT. Coverage alone never proves a test redundant. test-driven-development and writing-plans now place each test at the lowest layer that can fail for its change, so the suite stops regrowing.
darkmem mirror (optional, off by default). scripts/darkmem-sync keeps a repository's planning documents and progress in a darkmem instance over its keyed REST routes. It does nothing until you opt in: turn on Use darkmem (darkmemEnabled) in the cockpit's darkmem settings or with bash <plugin-root>/scripts/dforge-config set darkmemEnabled true, and map the repository's primary checkout in ~/.dforge/config.json — {"darkmem": {"url": "http://<host>:8000", "api_key_env": "DARKMEM_API_KEY", "repos": {"<primary checkout path>": {"project": "<name>"}}}} — and export a dmk_ key in that variable. The repository then syncs a mirror at ~/.dforge/mirror/<project>/, whose documents live under the darkmem uri prefix dforge/. The key needs the read and capture scopes, and import --replace also admin. Two settings choose each family's store while darkmem is on: documentStore (specs, plans, registers, notes) and progressSource (ledgers and handoffs), local or darkmem, both darkmem by default; a mapping entry may override either, for example {"project": "plugins", "documentStore": "local"}. ~/.dforge/stores.json records where each family actually lives, with its project and mirror, and the scripts and skills read and write there through scripts/lib/roots.sh and scripts/dforge-path. pull fetches what changed; push sends documents under their last synced hash and each new ledger line as its own entry, with task, state and commit properties a progress board can read; status lists each family's store and local changes; import files an existing docs/dforge/ and its ledgers once, verified byte for byte. A store that changes moves its family at the next session start with no plan in progress and no auto run active: reconcile copies the files across, verifies every hash, records the new store in stores.json, and leaves documents leaving the repository as a staged git rm for you to commit. Every move refuses, naming the reason, while a plan is in progress (a ledger whose plan completed.md does not record) or an auto run is active. A move into darkmem also refuses while darkmem is unreachable or the key is unset, while a linked worktree holds documents the primary checkout does not, or when a file cannot be carried (a dotfile, a symbolic link, an empty or non-UTF-8 file, a name some platform cannot hold, a case twin, an ignored file); it never overwrites a different darkmem copy, and moved progress files are kept under a dated moved-<stamp> backup. Falling back to the file system. While darkmem is on, the mirror is plain local files: without the key or the server, work goes on from it and pull and push say why they skipped. Turn darkmem off (darkmemEnabled false) or remove the mapping, and the next session start with no plan in progress moves every family back into the repository from the mirror alone, without asking darkmem, naming each file darkmem does not hold as the mirror does and keeping the mirror as a dated dforge-moved-<stamp> / work-moved-<stamp> backup; until then the scripts keep reading the mirror, and push and pull do nothing. A store set back to local while darkmem is on and reachable is pulled from darkmem first. A repository a 2.x release moved into darkmem stays there, unsynced, while darkmemEnabled is unset: each session start says so until you set it either way. A repository with no mapping but a record that still places a family in the mirror is read and reconciled the same way: the scripts read the mirror until a session start with no plan in progress moves the family back. Without the darkmem MCP tools, or with darkmem off, the durable facts the skills would file with memory_add go to <docs>/notes/<date>-facts.md instead. Session start runs reconcile and pull; the skills' stop points run push, and a background Stop hook pushes as a safety net in at most 140 seconds: its config lookup is bounded at 30 seconds and its push at 100, each sent SIGTERM at its bound and SIGKILL 5 seconds later. push waits up to 100 seconds for another run's mirror lock before refusing; DARKMEM_SYNC_LOCK_WAIT_MS sets that wait in milliseconds, and a value that is not a positive number means the 100 seconds. The Stop hook's push never waits: it refuses at once while another run holds the lock and the next stop sends what it missed. A move stopped part-way through filing a ledger resumes it at the next session start. A document deleted from the mirror is archived on darkmem under dforge/archive/<stamp>/ and restore <name> brings it back; revision <name> prints the <name>@r<id> a distilled Source: line cites. docs/superpowers/<rest> and docs/dforge/<rest> name the same document everywhere names are compared, so historical plans keep resolving. A conflict is reported, never merged. Without a mapping every command but import prints one line and does nothing.
UI/UX work proven in the running UI. dforge:ui-ux turns a design into a numbered UI/UX contract (UI, UX and an Output promise) in the spec, plan-lint makes every UI task name the items it delivers, task-brief hands them to the implementer verbatim, and a walkthrough proves each one on the running UI — web, Windows, Linux desktop, Android or terminal — with a judge seat reading the screensho
hooks/cockpit/register.tsx 4121 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register, RenderChildren, RenderInput, Timer, TurnUsage } from 'claude-code'
3
4import type {
5 CockpitAccount,
6 CockpitActivity,
7 CockpitAutoRun,
8 CockpitBreakdown,
9 CockpitBudget,
10 CockpitFinish,
11 CockpitPlanItem,
12 CockpitRefusal,
13 CockpitResumeOffer,
14 CockpitRoots,
15 CockpitRun,
16 CockpitSeat,
17 CockpitSettingsView,
18 CockpitShell,
19 CockpitSpawn,
20 CockpitUsage,
21} from '../../types'
22import {
23 accountColor,
24 accountKey,
25 attributionIn,
26 autoLimitFor,
27 autoRunFor,
28 agentGroups,
29 bar,
30 billingLabel,
31 budgetFor,
32 collapsedFrom,
33 collapsedToggled,
34 commandLine,
35 denseLine,
36 duration,
37 elapsed,
38 endsOnSentinel,
39 finalLimitFor,
40 finishLine,
41 fitBand,
42 folderRuns,
43 handedOff,
44 isAtLeast,
45 isGitWriteTool,
46 isDforge,
47 gitTextFiles,
48 isAbsolutePath,
49 isPlanTail,
50 isServiceCommand,
51 kTokens,
52 levelOf,
53 limitForecast,
54 limitLabel,
55 modelName,
56 nowLine,
57 parseFinish,
58 parseLedger,
59 parseModeLines,
60 planCandidates,
61 planTasks,
62 planFromTodos,
63 planWithUpdate,
64 positive,
65 rampColor,
66 reconcileStep,
67 remoteRuns,
68 repoFromPorcelain,
69 repoName,
70 resumeOfferOf,
71 promptRegisters,
72 hasUnresolvedRows,
73 runList,
74 shellAge,
75 runsCut,
76 runsWidth,
77 SECTIONS,
78 BAND_SEGMENTS,
79 FORK_DRAFTING,
80 seatDenial,
81 bandLines,
82 bandLinesFrom,
83 bandLineToggled,
84 sectionsFrom,
85 sectionsToggled,
86 sparkline,
87 stackedRuns,
88 tasksEnded,
89 termColor,
90 themeColor,
91 track,
92 turnsToBudget,
93 windowElapsed,
94 warnShare,
95 writesGitText,
96} from './lib'
97import type { AgentGroupKey, BandInput, BandSegment, DenseInput, Run, Section, TrackPart } from './lib'
98import { SCHEMA, claudeLocks, envLocks, importedSettings, rangeText, resolveSettings, rowsOf, settingProblems, settingWritten, steppedSetting, tabsOf, typedNumber } from './config'
99import type { SettingValue, SettingsFile } from './config'
100import { nativeLine, nativeRun } from './native'
101import { drawSettings } from './settings-view'
102import {
103 discordBody,
104 excerpt,
105 isDiscordWebhook,
106 maskWebhook,
107 mentionId,
108 NOTIFY_KINDS,
109 notifyKindsFrom,
110 notifyKindsToggled,
111} from './notify'
112import type { NotifyEvent, NotifyKind, NotifyPlace, SendResult } from './notify'
113import { CELL_PX, avatarSvg, bandMetersSvg, gaugeTileSvg, gaugeSvg, labelledMeterSvg, meterSvg, paint, seatSvg, segmentsSvg, stackSvg, svgWidth, trendSvg } from './svg'
114import type { BandMeter, PanelLine, PanelRun, Segment } from './svg'
115
116const PANE = 'dforge'
117const PANE_COLUMNS = 56
118const INDENT = 2
119// A button's rounded frame: the accent on the one that matters, gray at rest.
120const BUTTON_MAIN = 'permission'
121const BUTTON_REST = 'inactive'
122// The rows the band asks for: its frame, two lines and a warning.
123const COMPACT_ROWS = 5
124// The first Claude Code that takes styled children in a Button.
125const UNDERLINE_SINCE = '2.1.295'
126// The most /context categories the pane lists (breakdownRows caps it lower).
127const MAX_BREAKDOWN_ROWS = 12
128// Below this the band drops its frame.
129const FRAME_MIN_COLUMNS = 48
130const HANDOFF_PROMPT =
131 'Finish the task in flight through its completion line, then hand off with the dforge handoff skill. Start no new task.'
132function resumePrompt(): string {
133 return `Resume the work in ${S.latestName ?? '.dforge/handoff/latest.md'} with the dforge resume-execution skill.`
134}
135// A handoff older than this is not offered for resuming.
136const RESUME_WITHIN = 3 * 86_400_000
137// What an auto run's compaction keeps (reference/auto-run.md).
138const AUTO_COMPACT_INSTRUCTIONS =
139 'Preserve verbatim: the worktree path; the plan, ledger, handoff and auto-run state file paths; the current HEAD; every Ruling: line; and the auto run pending decisions. Name the task in progress and its fix round.'
140// What the switch's compaction keeps: once the plan is saved, the spec and the
141// plan hold what the design conversation decided.
142const AUTO_SWITCH_INSTRUCTIONS =
143 "Preserve verbatim: the spec, plan, register and auto-run state file paths; the base branch; the current HEAD. Drop the design conversation: the spec and the plan hold what it decided. No task has started and no execution worktree exists yet; execution begins at the execution skill's Setup."
144// A failed stage is tried again after this long; a live turn or background work defers it as long.
145const AUTO_RETRY_MS = 5_000
146// The restored default model is written this many times at most, each write
147// watched this long for the engine to read it back.
148const RESTORE_WRITES = 3
149const RESTORE_WAIT_MS = 3_000
150const RESTORE_POLL_MS = 500
151type AutoStage = 'compact' | 'continue' | 'clear' | 'resume' | 'switch' | 'switch-resume'
152// The pending action the state file holds while each stage is due.
153const AUTO_STAGE_PENDING: Record<AutoStage, string> = {
154 compact: 'compact',
155 continue: 'after-compact',
156 clear: 'clear',
157 resume: 'resume',
158 switch: 'switch',
159 'switch-resume': 'switch-resume',
160}
161function autoContinuePrompt(root: string, plan: string | null): string {
162 const where = plan === null ? '' : ` for plan ${plan}, through the After compaction steps of the execution skill its Execution line names`
163 return `Continue the auto run: run \`bash "${root}/scripts/auto-run" after-compact\` first, then continue the phase \`bash "${root}/scripts/auto-run" status\` names${where}, as reference/auto-run.md §After a compaction says.`
164}
165// The watchdog's period, and how long background work may defer a pending
166// stage before that books a failure. A bound run idle STALL_MS since its last
167// turn ended, stage ran or nudge went is stalled.
168const WATCHDOG_MS = 60_000
169const DEFER_CAP_MS = 1_800_000
170const STALL_MS = 180_000
171// How long a bound run may go with no live turn while only background work
172// holds it before the watchdog asks the session to look at that work, and how
173// many such prompts in a row may find the same launches running before the
174// pending stage's failure is booked in place of a further prompt.
175const RECONCILE_MS = 1_200_000
176const RECONCILE_CAP = 3
177// How long a Done held for background work waits, once the work ends, for the turn its notification wakes.
178const RELEASE_GRACE_MS = 5_000
179// The prompt.submit origins that are the person's own request, or auto run's.
180const NEW_CHAIN_ORIGINS = new Set<PromptOrigin['kind']>(['composer', 'bridge', 'sdk', 'channel', 'slack-ping', 'plugin'])
181// While the cockpit is drawn its files are checked every POLL_MS, git and the context breakdown every DETAIL_EVERY ticks.
182const POLL_MS = 2_000
183const DETAIL_EVERY = 5
184// The pending actions whose stage the watchdog arms again when no timer holds
185// it; a pending resume resubmits the run's first prompt.
186const WATCHED_STAGES: Readonly<Record<string, AutoStage>> = { compact: 'compact', clear: 'clear', resume: 'resume', switch: 'switch', 'switch-resume': 'switch-resume' }
187function stallPrompt(root: string): string {
188 return `The auto run stalled: the last turn ended without its sentinel line. Run \`bash "${root}/scripts/auto-run" status\`, continue the phase it names, and end the turn as reference/auto-run.md §Ending a turn says.`
189}
190function reconcilePrompt(root: string): string {
191 return `Background work has run 20 minutes since the last turn with no notification. Run \`bash "${root}/scripts/in-flight"\`. Stop with TaskStop each launch idle 30 minutes or more, and record a \`Ruling:\` line naming the task it served and that its result is void; leave the others running. Then continue the run, and end the turn again if only waiting remains.`
192}
193// The tools that wait on the person by their nature: their dialog is the question.
194const ASKING_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
195// Permission modes in which an "ask" is settled without the person.
196const UNATTENDED_MODES = ['auto', 'bypassPermissions', 'dontAsk']
197// The skills that run a plan's tasks in this session.
198const RUN_SKILLS = ['dforge:subagent-driven-development', 'dforge:executing-plans', 'dforge:resume-execution']
199// The tools that move the session into or out of a worktree.
200const WORKTREE_TOOLS = ['EnterWorktree', 'ExitWorktree']
201// How many main-turn context readings the forecast keeps.
202const TURN_READINGS = 12
203const STORE_URL = 'notifyUrl'
204const STORE_MENTION = 'notifyMention'
205const STORE_IMPORTED = 'webhookImported'
206// How many context readings the trend line keeps.
207const TREND_LENGTH = 40
208// The colors the context breakdown cycles through, largest category first.
209const BREAKDOWN_COLORS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red'] as const
210// The usage windows the desktop draws as gauges, in order.
211const GAUGED: readonly string[] = ['five_hour', 'seven_day']
212// The band segments the desktop band writes as text rather than draws as meters.
213const BAND_WORDS: readonly BandSegment[] = ['model', 'effort', 'style', 'turn', 'run', 'cost', 'time']
214// The desktop's panel fill: a faint gray that lifts a card off the pane in
215// the light theme and the dark alike.
216const CARD = '#8080800f'
217// How the desktop's run bar colors a task by its state.
218const RUN_SEGMENTS: Record<string, Segment> = { complete: 'done', assigned: 'active', blocked: 'blocked' }
219const GIT_STATUS = ['git', '--no-optional-locks', '-c', 'core.fsmonitor=false', 'status', '--porcelain=v2', '--branch']
220
221const budget = atom({ plugin: 'dforge', key: 'budget' } as const, null)
222const usage = atom({ plugin: 'dforge', key: 'usage' } as const, null)
223const breakdown = atom({ plugin: 'dforge', key: 'breakdown' } as const, null)
224const seats = atom({ plugin: 'dforge', key: 'seats' } as const, [])
225const spawns = atom({ plugin: 'dforge', key: 'spawns' } as const, [])
226const shells = atom({ plugin: 'dforge', key: 'shells' } as const, [])
227const remote = atom({ plugin: 'dforge', key: 'remote' } as const, { clients: [], setting: null })
228const plan = atom({ plugin: 'dforge', key: 'plan' } as const, [])
229const repo = atom({ plugin: 'dforge', key: 'repo' } as const, null)
230const refusals = atom({ plugin: 'dforge', key: 'refusals' } as const, [])
231const isNudged = atom({ plugin: 'dforge', key: 'isNudged' } as const, false)
232const isPlanSession = atom({ plugin: 'dforge', key: 'isPlanSession' } as const, false)
233const isRunSession = atom({ plugin: 'dforge', key: 'isRunSession' } as const, false)
234const autoRun = atom({ plugin: 'dforge', key: 'autoRun' } as const, null)
235const alerted = atom({ plugin: 'dforge', key: 'alerted' } as const, [])
236const trend = atom({ plugin: 'dforge', key: 'trend' } as const, [])
237const view = atom({ plugin: 'dforge', key: 'view' } as const, 'cockpit')
238const account = atom({ plugin: 'dforge', key: 'account' } as const, null)
239const place = atom({ plugin: 'dforge', key: 'place' } as const, null)
240const engine = atom({ plugin: 'dforge', key: 'engine' } as const, { model: null, effort: null, cache: null, style: null })
241const IDLE: CockpitActivity = { kind: 'idle', since: 0, turnStartedAt: null, step: 0, tool: null, agent: null, detail: null, lastTurnMs: null, endedAt: null, isSent: false }
242const activity = atom({ plugin: 'dforge', key: 'activity' } as const, IDLE)
243const turnTokens = atom({ plugin: 'dforge', key: 'turnTokens' } as const, [])
244const run = atom({ plugin: 'dforge', key: 'run' } as const, null)
245const finish = atom({ plugin: 'dforge', key: 'finish' } as const, null)
246const resumeOffer = atom({ plugin: 'dforge', key: 'resumeOffer' } as const, null)
247const resumeDismissed = atom({ plugin: 'dforge', key: 'resumeDismissed' } as const, null)
248const rootsNote = atom({ plugin: 'dforge', key: 'rootsNote' } as const, null)
249const settings = atom({ plugin: 'dforge', key: 'settings' } as const, null)
250const SETTINGS_VIEW: CockpitSettingsView = { tab: '', isHelpShown: true, editing: null, error: null, isConfirmingReset: false }
251const settingsView = atom({ plugin: 'dforge', key: 'settingsView' } as const, SETTINGS_VIEW)
252const notifier = atom({ plugin: 'dforge', key: 'notifier' } as const, {
253 url: null,
254 isFromEnv: false,
255 mention: null,
256 editing: null,
257 error: null,
258 last: null,
259})
260
261type Options = {
262 handoffTokens: number
263 handoffMode: string
264 nudgeModel: boolean
265 guardAttribution: boolean
266 warnAt: number
267 layout: string
268 density: string
269 sections: string
270 collapsed: string
271 breakdownRows: number
272 openAtStart: boolean
273 limitAlertAt: number
274 notifyOn: string
275 notifyAfter: number
276 notifyAskAfter: number
277 notifyDetail: string
278 bandLine1: string
279 bandLine2: string
280}
281
282/** What the budget reading is judged by. */
283type Tuning = { handoffTokens: number; nudgeModel: boolean; warnAt: number; isRelaxed: boolean }
284
285/** The options with their defaults filled in, and what the pane derives from them. */
286type Live = Omit<Options, 'layout' | 'density'> & {
287 layout: 'auto' | 'full' | 'compact'
288 density: 'detailed' | 'dense'
289 tuning: Tuning
290 shown: Section[]
291 shownCollapsed: Section[]
292 bandLayout: [BandSegment[], BandSegment[]]
293 isMinimized: boolean
294}
295
296function liveFrom(values: Partial<Options>): Live {
297 const {
298 handoffTokens = 0,
299 handoffMode = 'strict',
300 nudgeModel = true,
301 guardAttribution = true,
302 warnAt = 80,
303 layout: layoutSetting = 'auto',
304 density: densitySetting = 'detailed',
305 sections = '',
306 collapsed = '',
307 breakdownRows = 6,
308 openAtStart = true,
309 limitAlertAt = 90,
310 notifyOn = '',
311 notifyAfter = 60,
312 notifyAskAfter = 20,
313 notifyDetail = 'full',
314 bandLine1 = '',
315 bandLine2 = '',
316 } = values
317 // Anything but the three words reads as auto.
318 const layout = layoutSetting === 'full' || layoutSetting === 'compact' ? layoutSetting : 'auto'
319 return {
320 handoffTokens,
321 handoffMode,
322 nudgeModel,
323 guardAttribution,
324 warnAt,
325 layout,
326 density: densitySetting === 'dense' ? 'dense' : 'detailed',
327 sections,
328 collapsed,
329 breakdownRows,
330 openAtStart,
331 limitAlertAt,
332 notifyOn,
333 notifyAfter,
334 notifyAskAfter,
335 notifyDetail,
336 bandLine1,
337 bandLine2,
338 tuning: { handoffTokens, nudgeModel, warnAt: warnShare(warnAt), isRelaxed: handoffMode === 'relaxed' },
339 shown: sectionsFrom(sections),
340 shownCollapsed: collapsedFrom(collapsed),
341 bandLayout: bandLinesFrom(bandLine1, bandLine2),
342 isMinimized: layout === 'compact',
343 }
344}
345
346/** The options in force now: the settings atom once written, else the defaults. */
347async function live($: EngineInterface): Promise<Live> {
348 return liveFrom(((await read($, settings)) ?? {}) as Partial<Options>)
349}
350
351/** Points the notifier at the options' events, detail and wait. */
352function applyNotify(o: Live) {
353 S.kinds = notifyKindsFrom(o.notifyOn)
354 S.isBrief = o.notifyDetail === 'brief'
355 S.notifyAskAfter = o.notifyAskAfter
356}
357
358export const register: Register = on => {
359 // The settings' autoCompactWindow, read at the start and after each main
360 // turn rather than on every tool call.
361 let compactWindow: number | undefined
362
363 on('session.start', async ($, e, next) => {
364 await $.command.register({
365 name: 'cockpit',
366 description: 'Show context, usage, subagents, the plan and the repo in a pane',
367 argumentHint: '[settings | notify test | notify url <url> | notify mention <id>]',
368 })
369 await loadSettings($)
370 const o = await live($)
371 // Open first: the reads below run processes, and each fills its section as it lands.
372 if (o.isMinimized) {
373 if ((await read($, view)) !== 'settings') await shutPane($)
374 } else if (o.openAtStart) void $.ui.open(paneFor(o.layout))
375 compactWindow = await readCompactWindow($)
376 await onBudget($, await refresh($, o.tuning, compactWindow))
377 await readAccount($)
378 await readPlace($)
379 await readBreakdown($)
380 await readEngine($)
381 await readWhere($)
382 await readNotifier($)
383 S.runKey = ''
384 await readRun($)
385 await trackAutoRun($)
386 S.watchedSince = await $.clock.now()
387 S.reconciledAt = null
388 S.reconcileSeen = { key: '', count: 0 }
389 await beat($)
390 S.watchdog?.cancel()
391 S.watchdog = $.clock.every(WATCHDOG_MS, () => void watchdogTick($).catch(() => undefined))
392 S.poll?.cancel()
393 S.poll = $.clock.every(POLL_MS, () => void pollTick($).catch(() => undefined))
394 await readVersion($)
395 await readRemote($)
396
397 return next(e)
398 })
399
400 on('command.run', { command: 'cockpit' }, async ($, e) => {
401 const o = await live($)
402 const words = e.args.trim().split(/\s+/)
403 if (words[0]?.toLowerCase() === 'notify') return notifyCommand($, words.slice(1))
404 const wantsSettings = e.args.trim().toLowerCase() === 'settings'
405 if (!wantsSettings && e.args.trim() !== '') {
406 return { text: `Unknown argument "${e.args.trim()}". Usage: /cockpit [settings | notify test | notify url <url> | notify mention <id>]` }
407 }
408 if (wantsSettings) await showSettings($)
409 else {
410 await flushSettingNote($)
411 await update($, view, () => 'cockpit')
412 }
413 await readAccount($)
414 // The settings take the keyboard and as many rows as they need.
415 const opened = wantsSettings
416 ? await $.ui.open({ id: PANE, title: 'Cockpit', columns: PANE_COLUMNS, focus: true })
417 : o.isMinimized
418 ? await expand($, e.presentation.isFullscreen === true)
419 : await $.ui.open(paneFor(o.layout))
420 await refreshDetail($)
421
422 // A surface that seats no panes (an older desktop) holds it undrawn: say
423 // why rather than claim it opened.
424 return { text: opened.isPlaced ? 'Cockpit pane opened.' : `Cockpit pane is waiting: ${opened.reason}` }
425 })
426
427 on('session.measure', async ($, e, next) => {
428 const done = await next(e)
429 const o = await live($)
430 const now = await $.clock.now()
431 const startedAt = (await read($, usage))?.startedAt ?? (await $.session.usage()).startedAt
432 const reading: CockpitUsage = {
433 startedAt,
434 readAt: now,
435 limits: e.rateLimits.map(limit => ({
436 kind: limit.kind,
437 percent: limit.percentUsed,
438 resetsAt: limit.resetsAt === undefined ? null : Date.parse(limit.resetsAt) || null,
439 })),
440 }
441 await update($, usage, () => reading)
442 if (o.limitAlertAt > 0) {
443 for (const limit of await alertLimits($, reading, o.limitAlertAt)) {
444 await notify($, {
445 kind: 'limit',
446 title: `📊 ${limitLabel(limit.kind)} limit at ${Math.round(limit.percent)}%`,
447 detail: limit.resetsAt === null ? undefined : `Resets in ${duration(limit.resetsAt - now) || 'under a minute'}.`,
448 isPlain: true,
449 })
450 }
451 }
452 if (o.isMinimized || (await isPaneUp($))) await refreshDetail($)
453
454 return done
455 })
456
457 on('session.end', async ($, e, next) => {
458 if (e.reason !== 'clear') {
459 const head = await read($, budget)
460 const started = (await read($, usage))?.startedAt
461 const stamp = await $.clock.now()
462 await notify($, {
463 kind: 'session',
464 title: '🏁 Session ended',
465 detail: `claude --resume ${e.sessionId}`,
466 isPlain: true,
467 fields: [
468 ...(started === undefined ? [] : [{ name: 'Took', value: elapsed(stamp - started) }]),
469 ...(head?.usd == null ? [] : [{ name: 'Cost', value: `$${head.usd.toFixed(2)}` }]),
470 ],
471 })
472 }
473 S.ticker?.cancel()
474 S.ticker = null
475 S.watchdog?.cancel()
476 S.watchdog = null
477 S.releaseTimer?.cancel()
478 S.releaseTimer = null
479 S.heldDone = null
480 S.poll?.cancel()
481 S.poll = null
482 S.isMainTurn = false
483 await setActivity($, () => IDLE)
484 return next(e)
485 }).catch(($, e, next) => next(e))
486
487 // A compaction leaves no reading until the next response: drop the stale
488 // one and re-arm the note, so it does not stay latched.
489 on('session.compact', async ($, e, next) => {
490 const done = await next(e)
491 if (e.agentId === undefined && e.trigger !== 'precompute' && 'messages' in done && done.messages !== undefined) {
492 await update($, budget, () => null)
493 await update($, isNudged, () => false)
494 // The estimate stands in until the next response; the old one is the window before.
495 await readBreakdown($)
496 // The engine's or the person's compaction: the run's rule still applies after it.
497 if ((e.trigger === 'auto' || e.trigger === 'manual') && !S.isPaneCompacting) await bookAutoCompaction($)
498 }
499
500 return done
501 }).catch(($, e, next) => next(e))
502
503 on('agent.spawn', async ($, e, next) => {
504 const denial = seatDenial(e, await read($, isPlanSession), FORK_DRAFTING)
505 if (denial !== null) {
506 const refusal: CockpitRefusal = { at: await $.clock.now(), tool: 'Agent', line: denial }
507 await update($, refusals, list => [...list, refusal].slice(-20))
508 return { deny: denial }
509 }
510 const started = await next(e)
511 if (started.deny !== undefined || started.agentId === undefined) return started
512 if (isDforge(e.subagentType)) {
513 await update($, isPlanSession, () => true)
514 await flushSettingNote($)
515 if (e.subagentType.split(':')[1]?.startsWith('impl-')) await markRunSession($)
516 }
517 const spawn: CockpitSpawn = {
518 agentId: started.agentId,
519 seat: e.subagentType,
520 model: started.model,
521 description: e.description,
522 isDone: false,
523 outcome: null,
524 startedAt: await $.clock.now(),
525 }
526 await update($, spawns, list => [...list, spawn].slice(-50))
527 await syncTicker($)
528
529 return started
530 }).catch(($, e, next) => next(e))
531
532 // Remote Control clients joining and leaving; the terminal raises neither.
533 on('session.attach', async ($, e, next) => {
534 const joined = await next(e)
535 const o = await live($)
536 await update($, remote, now => ({ ...now, clients: [...now.clients.filter(one => one.id !== e.clientId), { id: e.clientId, surface: e.surface }] }))
537 // A desktop session's start can come before its window joins: show the
538 // pane once it has, unless the person closed it.
539 if (o.openAtStart && !o.isMinimized && e.surface === 'desktop' && !S.isClosedByPerson && !(await isPaneUp($))) void $.ui.open(paneFor(o.layout))
540 return joined
541 }).catch(($, e, next) => next(e))
542
543 // A skill typed as a slash command makes no Skill tool call; its prompt is expanded on every path.
544 on('skill.prompt', async ($, e, next) => {
545 const expanded = await next(e)
546 if (isDforge(e.skill)) {
547 await update($, isPlanSession, () => true)
548 await flushSettingNote($)
549 }
550 if (RUN_SKILLS.includes(e.skill)) await markRunSession($)
551 return expanded
552 }).catch(($, e, next) => next(e))
553
554 on('ui.close', { id: PANE }, async ($, e, next) => {
555 if (e.origin.kind === 'person') S.isClosedByPerson = true
556 await flushSettingNote($)
557 return next(e)
558 }).catch(($, e, next) => next(e))
559
560 on('session.detach', async ($, e, next) => {
561 const left = await next(e)
562 await update($, remote, now => ({ ...now, clients: now.clients.filter(one => one.id !== e.clientId) }))
563 return left
564 }).catch(($, e, next) => next(e))
565
566 // A background shell's end arrives as its task notification, kept as a row
567 // of the loop that started it: the main conversation's, or a subagent's,
568 // which no prompt.submit announces.
569 on('session.append', async ($, e, next) => {
570 const kept = await next(e)
571 if (e.message.role === 'user') {
572 const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('\n')
573 if (text.includes('<task-notification>')) await endShells($, text)
574 }
575 return kept
576 }).catch(($, e, next) => next(e))
577
578 // A prompt from the person, or auto run's, starts a new chain: the Done held
579 // for the last one is dropped. A wake-up keeps it, and so does an origin not
580 // listed, since a Done covering more work beats a lost one.
581 on('prompt.submit', async ($, e, next) => {
582 if (e.origin !== undefined && NEW_CHAIN_ORIGINS.has(e.origin.kind)) S.heldDone = null
583 return next(e)
584 }).catch(($, e, next) => next(e))
585
586 // A main turn begins: the Now row starts its clock.
587 on('turn.start', async ($, e, next) => {
588 if (e.agentId !== undefined) return next(e)
589 S.isMainTurn = true
590 S.releaseTimer?.cancel()
591 S.releaseTimer = null
592 const stamp = await $.clock.now()
593 for (const [id, wait] of [...S.waits]) if (wait.agentId === null) await settleWait($, id, '⏹ Turn ended')
594 await setActivity($, now => ({
595 ...IDLE,
596 kind: 'running',
597 since: stamp,
598 turnStartedAt: stamp,
599 lastTurnMs: now.lastTurnMs,
600 }))
601 return next(e)
602 })
603
604 // Watches the permission decision, never changes it: an "ask" on a call
605 // means the person is about to be asked, unless the mode settles it alone.
606 on('tool.check', async ($, e, next) => {
607 const decided = await next(e)
608 const id = e.tool_use_id
609 if (decided.decision !== 'ask' || id === undefined || S.isUnattended || ASKING_TOOLS.includes(e.tool) || S.waits.has(id)) return decided
610 const seat = e.agentId === undefined ? null : ((await read($, spawns)).find(one => one.agentId === e.agentId)?.seat ?? null)
611 await startWait($, id, 'waiting', {
612 kind: 'ask',
613 title: '⏳ Waiting for approval',
614 detail: `${e.tool}${callSummary(e.tool, e.input) === '' ? '' : `: ${excerpt(callSummary(e.tool, e.input), 200)}`}`,
615 fields: [...(seat === null ? [] : [{ name: 'Agent', value: shortSeat(seat) }]), ...(await turnFields($)).slice(0, 1)],
616 }, e.agentId ?? null)
617 return decided
618 }).catch(($, e, next) => next(e))
619
620 // The background pill under a Bash call is drawn only once the call runs,
621 // so an approval waiting on it was given. Drawing may not write state, so
622 // the wait settles once this draw is done.
623 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
624 const id = e.props.tool_use_id
625 if (S.waits.has(id)) $.clock.after(0, () => void endWait($, id, '✓ Approved'))
626 return next(e)
627 })
628
629 // The model and effort the main loop's requests go out with, as the status
630 // line shows them.
631 on('turn.step', async function* ($, e, next) {
632 if (e.agentId === undefined) {
633 const effort = typeof e.effort === 'string' ? e.effort : null
634 const before = await read($, engine)
635 if (before.model !== e.model || before.effort !== effort) await update($, engine, now => ({ ...now, model: e.model, effort }))
636 const step = e.index + 1
637 await update($, activity, now => (now.kind === 'idle' || now.kind === 'failed' || now.step === step ? now : { ...now, step }))
638 }
639 return yield* next(e)
640 })
641
642 on('turn.complete', async ($, e, next) => {
643 const done = await next(e)
644 if (e.agentId === undefined) {
645 S.isMainTurn = false
646 await trackAutoRun($)
647 compactWindow = await readCompactWindow($)
648 await readStyle($)
649 await readRun($)
650 await onBudget($, await refresh($, (await live($)).tuning, compactWindow))
651 const turnUsage = e.usage as TurnUsage | undefined
652 if (turnUsage !== undefined) {
653 const cache = cacheOf(turnUsage)
654 await update($, engine, now => ({ ...now, cache }))
655 }
656 const head = await read($, budget)
657 if (head !== null) await update($, turnTokens, list => [...list, head.tokens].slice(-TURN_READINGS))
658 for (const [id, wait] of [...S.waits]) if (wait.agentId === null) await settleWait($, id, '⏹ Turn ended')
659 const stamp = await $.clock.now()
660 const failed = e.reason === 'error' || e.reason === 'refusal'
661 const why = failed ? failureOf(e) : null
662 const isHeld = e.reason === 'answer' && (await holdsDone($))
663 const turnStartedAt = (await read($, activity)).turnStartedAt ?? stamp - e.durationMs
664 if (e.reason !== 'answer') S.heldDone = null
665 // A later turn that says nothing keeps the answer the chain last gave.
666 else if (isHeld) S.heldDone = { startedAt: S.heldDone?.startedAt ?? turnStartedAt, heldAt: stamp, answer: e.answer.trim() === '' ? (S.heldDone?.answer ?? e.answer) : e.answer }
667 await readRemote($)
668 await setActivity($, now => ({
669 ...IDLE,
670 kind: failed ? 'failed' : e.reason === 'aborted' ? 'interrupted' : isHeld ? 'background' : 'idle',
671 since: now.since,
672 lastTurnMs: e.durationMs,
673 endedAt: stamp,
674 detail: why,
675 }))
676 if (failed) {
677 const messageId = await notify($, {
678 kind: 'error',
679 title: `❌ Turn failed${why === null ? '' : `: ${why}`}`,
680 detail: `After ${duration(e.durationMs) || '<1m'}. Send a prompt to continue.`,
681 isPlain: true,
682 fields: await turnFields($),
683 })
684 if (messageId !== null) await update($, activity, now => ({ ...now, isSent: true }))
685 } else if (e.reason === 'answer' && !isHeld) {
686 await sendDone($, e.answer, e.durationMs)
687 }
688 if (e.reason === 'answer') await armAutoRun($, e.answer)
689 return done
690 }
691 const agentId = e.agentId
692 // A notification for a finished subagent's loop may never arrive: its
693 // shells end with it, recorded spawn or not.
694 await update($, shells, list => list.filter(one => one.agentId !== agentId))
695 const list = await read($, spawns)
696 const spawn = list.find(one => one.agentId === agentId)
697 if (spawn === undefined) {
698 await syncTicker($)
699 return done
700 }
701 const outcome: 'done' | 'failed' = e.reason === 'answer' ? 'done' : 'failed'
702 await update($, spawns, all => all.map(one => (one.agentId === agentId ? { ...one, isDone: true, outcome } : one)))
703 for (const [id, wait] of [...S.waits]) if (wait.agentId === agentId) await endWait($, id, '⏹ Agent ended')
704 await syncTicker($)
705 await settleBackground($)
706 if (e.usage !== undefined) await update($, seats, all => addRun(all, spawn.seat, e.usage as TurnUsage))
707 await notify($, {
708 kind: 'agent',
709 title: `🤖 ${shortSeat(spawn.seat)} finished`,
710 detail: spawn.description,
711 fields: [{ name: 'Took', value: elapsed(e.durationMs) }],
712 })
713
714 return done
715 })
716
717 on('attribution.text', { kind: 'commit' }, async ($, e, next) => ((await live($)).guardAttribution ? { text: '' } : next(e)))
718 on('attribution.text', { kind: 'pr' }, async ($, e, next) => ((await live($)).guardAttribution ? { text: '' } : next(e)))
719
720 // One hook for every tool: the attribution guard judges before anything
721 // runs, and the main loop's budget and plan are read after each call, the
722 // only point its context grows between turns.
723 on('tool.call', async ($, e, next) => {
724 const tool = String(e.tool)
725 const line = (await live($)).guardAttribution ? await attributionLine($, tool, e) : undefined
726 if (line !== undefined) {
727 const refusal: CockpitRefusal = { at: await $.clock.now(), tool, line }
728 await update($, refusals, list => [...list, refusal].slice(-20))
729 return { deny: `dforge: this project keeps AI attribution out of git. Remove "${line}" and try again.` }
730 }
731 const id = e.tool_use_id
732 const seat = e.agentId === undefined ? null : ((await read($, spawns)).find(one => one.agentId === e.agentId)?.seat ?? null)
733 if ((await read($, activity)).kind === 'running') await setActivity($, now => ({ ...now, tool, agent: seat === null ? null : shortSeat(seat) }))
734 if (id !== undefined && e.agentId === undefined && ASKING_TOOLS.includes(tool)) {
735 await startWait($, id, 'asking', {
736 kind: 'question',
737 title: tool === 'ExitPlanMode' ? '📋 Plan ready for review' : '❓ Claude is asking',
738 detail: questionOf(e) || undefined,
739 fields: (await turnFields($)).slice(0, 1),
740 }, null)
741 }
742 // The response asking for this call already measured the window: show it while the tool
743 // runs. Only shown: the note and the alert wait for the ledger this call may write.
744 if (e.agentId === undefined) await refresh($, (await live($)).tuning, compactWindow, { isShownOnly: true })
745 let ran: Awaited<ReturnType<typeof next>>
746 try {
747 ran = await next(e)
748 } catch (error) {
749 if (id !== undefined && S.waits.has(id)) await endWait($, id, '⏹ Interrupted')
750 throw error
751 }
752 if (id !== undefined && S.waits.has(id)) {
753 await endWait($, id, ran.deny !== undefined ? '✗ Denied' : ASKING_TOOLS.includes(tool) ? '✓ Answered' : '✓ Approved')
754 }
755 if ((await read($, activity)).tool === tool) await setActivity($, now => (now.kind === 'running' ? { ...now, tool: null, agent: null } : now))
756 const skill = tool === 'Skill' ? String((e as { skill?: unknown }).skill ?? '') : ''
757 if (isDforge(skill)) {
758 await update($, isPlanSession, () => true)
759 await flushSettingNote($)
760 }
761 if (RUN_SKILLS.includes(skill)) await markRunSession($)
762 if (ran.deny === undefined && !ran.isError) await trackShells($, tool, e, ran.result)
763 if (e.agentId === undefined) {
764 if (ran.deny === undefined && !ran.isError) await trackPlan($, tool, e, ran.result)
765 // A run starts, stops and changes regime through the model's own calls.
766 await trackAutoRun($)
767 // A worktree move takes the session's ledger with it.
768 // A shell cd moves it too, as using-git-worktrees' fallback does.
769 const isMove = ran.deny === undefined && !ran.isError && (WORKTREE_TOOLS.includes(tool) || (tool === 'Bash' && (await hasMoved($))))
770 if (isMove) await readPlace($)
771 // The controller writes its ledger with its own tool calls; read it
772 // before the budget, which measures a plan's tail by it.
773 if (isMove || touchesRun(e)) await readRun($)
774 await onBudget($, await refresh($, (await live($)).tuning, compactWindow))
775 }
776
777 return ran
778 }).catch(async ($, e, next) =>
779 next.called || !(await live($)).guardAttribution ? next(e) : { deny: 'dforge: its attribution guard failed.' },
780 )
781
782 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
783 const table = $.ui.resolve(e)
784 const o = await live($)
785 const { Box, Button, Text } = table
786 // The desktop draws vectors: its cards carry real meters and charts where
787 // the terminal spells them in glyphs.
788 const Svg = e.surface === 'desktop' && 'Svg' in table ? table.Svg : undefined
789 const isDesktop = Svg !== undefined
790 const now = await $.clock.now()
791 const room = e.props.bodyColumns
792 const isCompact = o.isMinimized || (o.layout === 'auto' && e.props.placement === 'inline')
793 // Only these two surfaces draw the band above the prompt.
794 const canMinimize = e.surface === 'terminal' || e.surface === 'desktop'
795 const minimize = async () => {
796 if (!(await setOption($, 'layout', 'compact'))) return
797 // Under claude plugin test this plugin's own $.ui.close did not run its ui.close hook, so the waiting note goes here.
798 await flushSettingNote($)
799 await $.ui.close({ id: PANE })
800 }
801 const head = await read($, budget)
802 const spend = await read($, usage)
803 const rows = await read($, breakdown)
804 const estimate = head === null ? (rows?.total ?? null) : null
805 const list = await read($, seats)
806 const all = await read($, spawns)
807 const background = await read($, shells)
808 const remoteNow = await read($, remote)
809 const items = await read($, plan)
810 const git = await read($, repo)
811 const placeNow = await read($, place)
812 const refused = await read($, refusals)
813 const canHandOff = await read($, isPlanSession)
814 const act = await read($, activity)
815 const summary = await read($, run)
816 const finishNow = await read($, finish)
817 const rootsNow = await read($, rootsNote)
818 const offer = await offerNow($, now)
819 const drawRuns = (key: string, runs: Run[]) =>
820 runs.map((one, index) => (
821 <Text key={`${key}-${index}`} color={one.color} bold={one.bold} dimColor={one.dim}>
822 {one.text}
823 </Text>
824 ))
825
826 // Every control is a button in a rounded box: gray at rest, the accent on
827 // the one that matters (`isMain`), dim when it reads as off.
828 if (S.canUnderline === null) await readVersion($)
829 const button = (key: string, label: string, onPress: () => unknown, look: { hotkey?: string; isMain?: boolean; isOff?: boolean; isCompact?: boolean } = {}) => {
830 // The desktop draws its own buttons, the main one in its primary look. A compact
831 // button is that one on the terminal too: a single line, not a three-line box.
832 if (isDesktop || look.isCompact) {
833 return <Button key={key} label={label} hotkey={look.hotkey} variant={look.isMain ? 'primary' : undefined} dimColor={look.isOff} onPress={onPress} />
834 }
835 // The hotkey's letter is underlined in the label, as a desktop app marks
836 // one; a hidden twin holds the hotkey, since a Button with one draws "c: "
837 // before its label. Older Claude Code draws that prefix instead.
838 if (!S.canUnderline) {
839 return (
840 <Box key={`${key}-box`} borderStyle="round" borderColor={look.isMain ? BUTTON_MAIN : BUTTON_REST} paddingX={1}>
841 <Button key={key} label={label} hotkey={look.hotkey} plain dimColor={look.isOff} hover={{ bold: true }} onPress={onPress} />
842 </Box>
843 )
844 }
845 const at = look.hotkey === undefined ? -1 : label.toLowerCase().indexOf(look.hotkey)
846 return (
847 <Box key={`${key}-box`} borderStyle="round" borderColor={look.isMain ? BUTTON_MAIN : BUTTON_REST} paddingX={1}>
848 {at < 0 ? (
849 <Button key={key} label={label} plain dimColor={look.isOff} hover={{ bold: true }} onPress={onPress} />
850 ) : (
851 <Button key={key} label={label} plain dimColor={look.isOff} hover={{ bold: true }} onPress={onPress}>
852 {label.slice(0, at)}
853 <Text underline>{label.slice(at, at + 1)}</Text>
854 {label.slice(at + 1)}
855 </Button>
856 )}
857 {at >= 0 && (
858 <Box key={`${key}-hotkey-box`} display="none">
859 <Button key={`${key}-hotkey`} label={label} hotkey={look.hotkey} plain onPress={onPress} />
860 </Box>
861 )}
862 </Box>
863 )
864 }
865 const isNearHandoff = head !== null && levelOf(head.tokens, head.limit, o.tuning.warnAt) !== 'quiet'
866 const resumeCard = offer !== null && (
867 <Box key="resume-card" flexDirection="column" borderStyle="round" borderColor={BUTTON_MAIN} paddingX={1}>
868 <Text key="resume-title" color={BUTTON_MAIN} bold wrap="truncate-end">
869 {`↻ Resumable work · ${handedOff(now - offer.at)}`}
870 </Text>
871 <Text key="resume-next" wrap="truncate-end">{`Next: ${offer.next}`}</Text>
872 <Box key="resume-actions" flexDirection="row">
873 {button('pane-resume', 'Resume', () => resume($, offer), { hotkey: 'r', isMain: true })}
874 </Box>
875 </Box>
876 )
877 const autoNow = await read($, autoRun)
878 const isDense = o.density === 'dense'
879 const actions = (
880 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1}>
881 {autoNow?.active && (
882 <Text key="auto-line" color="magenta">
883 {`Auto run · ${autoNow.phase} · ${autoNow.regime} · ${autoNow.compactions} ${autoNow.compactions === 1 ? 'compaction' : 'compactions'} · ${autoNow.resumes} ${autoNow.resumes === 1 ? 'resume' : 'resumes'}`}
884 </Text>
885 )}
886 {autoNow?.active && button('pane-stop-auto', '■ Stop auto', () => void stopAutoRun($), { isCompact: isDense })}
887 {canHandOff && !autoNow?.active && button('pane-handoff', '↪ Hand off', () => $.prompt.submit({ text: HANDOFF_PROMPT, asUser: true }), { hotkey: 'h', isMain: isNearHandoff, isCompact: isDense })}
888 {button('pane-compact', '⇣ Compact', () => compactFromPane($), { hotkey: 'c', isCompact: isDense })}
889 </Box>
890 )
891 // The pane's own buttons, in a row below the account: they act on the pane, not the context.
892 const controls = (
893 <Box key="pane-controls" flexDirection="row" flexWrap="wrap" columnGap={1}>
894 {button('pane-density', isDense ? '☰ Detailed' : '≡ Dense', () => setOption($, 'density', isDense ? 'detailed' : 'dense'), { hotkey: 'd', isCompact: isDense })}
895 {button('pane-settings', '⚙ Settings', () => showSettings($), { hotkey: 's', isCompact: isDense })}
896 {canMinimize && (isDesktop || !isCompact) && button('pane-minimize', '▁ Minimize', minimize, { hotkey: 'm', isCompact: isDense })}
897 </Box>
898 )
899
900 if ((await read($, view)) === 'settings') {
901 // Each press saves to ~/.dforge/config.json and puts the value in force, so the rows below always show what is saved.
902 const set = (key: string, value: SettingValue) => setOption($, key, value)
903 const compact = (key: string, label: string, onPress: () => unknown, look: { hotkey?: string; isMain?: boolean; isOff?: boolean } = {}) =>
904 button(
905 key,
906 label,
907 async () => {
908 if (state.editing !== null || state.isConfirmingReset) await show({})
909 return onPress()
910 },
911 { ...look, isCompact: true },
912 )
913 // The desktop spaces these rows by its own scale, as the settings view does.
914 const row = (key: string, label: string, ...controls: RenderChildren[]) => (
915 <Box key={key} flexDirection="row" columnGap={isDesktop ? 'sm' : 1}>
916 <Box key={`${key}-label`} width={16} flexShrink={0}>
917 <Text>{label}</Text>
918 </Box>
919 <Box key={`${key}-controls`} flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={isDesktop ? 'sm' : 1} rowGap={isDesktop ? 'xs' : undefined} flexShrink={1}>
920 {controls}
921 </Box>
922 </Box>
923 )
924 const chips = (key: string, names: readonly string[], isOn: (name: string) => boolean, label: (name: string) => string, onPress: (name: string) => unknown) => (
925 <Box key={key} flexDirection="row" flexWrap="wrap" columnGap={isDesktop ? 'xs' : 1} rowGap={isDesktop ? 'xs' : undefined} flexShrink={1}>
926 {names.map(name => compact(`${key}-${name}`, `${isOn(name) ? '✓' : '○'} ${label(name)}`, () => onPress(name), { isOff: !isOn(name) }))}
927 </Box>
928 )
929 // A surface without text fields (the mobile app) sets the webhook from a terminal.
930 const elements = $.ui.resolve(e)
931 const Input = 'Input' in elements ? elements.Input : undefined
932 const canType = Input !== undefined
933 const notes = await read($, notifier)
934 const field = (key: 'url' | 'mention', placeholder: string, onSave: (value: string) => Promise<string | null>) =>
935 Input !== undefined && (
936 <Box key={`set-notify-${key}-field`} flexDirection="column" flexGrow={1}>
937 <Input
938 key={`set-notify-${key}-input`}
939 placeholder={placeholder}
940 submitLabel="save"
941 autoFocus
942 onSubmit={async (value: string) => {
943 // A save ends a pending reset like any other press, but the
944 // field stays mounted: closing it here would drop the text of
945 // an entry that is then refused.
946 await update($, settingsView, now => ({ ...now, isConfirmingReset: false }))
947 const problem = await onSave(value)
948 await update($, notifier, now => ({ ...now, editing: problem === null ? null : key, error: problem }))
949 }}
950 />
951 {notes.error !== null && <Text color="red">✗ {notes.error}</Text>}
952 <Box key={`set-notify-${key}-cancel`}>
953 {compact(`set-notify-${key}-cancel-button`, 'Cancel', () => update($, notifier, now => ({ ...now, editing: null, error: null })))}
954 </Box>
955 </Box>
956 )
957 const edit = (key: 'url' | 'mention') => openField($, `set-notify-${key}-input`, () => update($, notifier, now => ({ ...now, editing: key, error: null })))
958 const webhookRows = (
959 <Box key="set-notify-webhook" flexDirection="column">
960 {notes.editing === 'url' && canType
961 ? row('set-notify-url', 'Webhook', field('url', 'https://discord.com/api/webhooks/…', value => saveWebhook($, value)))
962 : row(
963 'set-notify-url',
964 'Webhook',
965 notes.url === null ? <Text key="set-notify-url-none" dimColor>not set </Text> : <Text key="set-notify-url-set" wrap="truncate-end"><Text color="green">✓ </Text>{maskWebhook(notes.url)} </Text>,
966 notes.isFromEnv && <Text key="set-notify-url-env" dimColor>from the environment</Text>,
967 notes.url !== null && compact('set-notify-test', 'Send test', () => sendTest($), { isMain: true }),
968 !notes.isFromEnv && canType && compact('set-notify-url-edit', notes.url === null ? 'Set' : 'Change', () => edit('url'), { isMain: notes.url === null }),
969 !notes.isFromEnv && notes.url !== null && compact('set-notify-url-remove', 'Remove', () => saveWebhook($, '')),
970 !canType && notes.url === null && <Text key="set-notify-url-cli" dimColor>set it from a terminal: /cockpit notify url</Text>,
971 )}
972 {notes.last !== null && (
973 <Text key="set-notify-last" color={notes.last.isError ? 'red' : 'green'} wrap="truncate-end">
974 {' '.repeat(17)}
975 {notes.last.isError ? '✗ ' : '✓ '}
976 {notes.last.text}
977 </Text>
978 )}
979 {notes.editing === 'mention' && canType
980 ? row('set-notify-mention', 'Mention', field('mention', 'your Discord user id', value => saveMention($, value)))
981 : row(
982 'set-notify-mention',
983 'Mention',
984 <Text key="set-notify-mention-value" dimColor={notes.mention === null}>
985 {notes.mention === null ? 'nobody ' : `${notes.mention.slice(0, 10)}… `}
986 </Text>,
987 canType && compact('set-notify-mention-edit', notes.mention === null ? 'Set' : 'Change', () => edit('mention')),
988 )}
989 </Box>
990 )
991
992 const state = await read($, settingsView)
993 const values = (await read($, settings)) ?? {}
994 const tabs = tabsOf(SCHEMA)
995 const tab = tabs.includes(state.tab) ? state.tab : (tabs[0] ?? '')
996 const rows = rowsOf(SCHEMA, values, tab, S.locks)
997 // Every press but the one that asked for it ends an edit, a webhook or
998 // mention field's included, or a pending reset.
999 const show = async (change: Partial<CockpitSettingsView>) => {
1000 if ((await read($, notifier)).editing !== null) await update($, notifier, now => ({ ...now, editing: null, error: null }))
1001 await update($, settingsView, now => ({ ...now, editing: null, error: null, isConfirmingReset: false, ...change }))
1002 }
1003 const save = async (key: string, value: SettingValue) => {
1004 if (state.editing !== null || state.isConfirmingReset) await show({})
1005 return set(key, value)
1006 }
1007 // A segment's chip on either line: on adds it last there and moves it off the other.
1008 const bandChips = (index: 0 | 1) => {
1009 const key = `set-bandLine${index + 1}`
1010 const line = o.bandLayout[index]
1011 return (
1012 <Box key={`${key}-chips`} flexDirection="column" flexShrink={1}>
1013 <Text key={`${key}-order`} dimColor wrap="truncate-end">
1014 {line.length === 0 ? 'hidden' : line.join(' · ')}
1015 </Text>
1016 {chips(
1017 key,
1018 BAND_SEGMENTS,
1019 name => line.includes(name as BandSegment),
1020 name => name,
1021 async name => {
1022 const [one, two] = bandLineToggled(o.bandLayout, index, name as BandSegment)
1023 if (one !== o.bandLine1) await save('bandLine1', one)
1024 if (two !== o.bandLine2) await save('bandLine2', two)
1025 },
1026 )}
1027 </Box>
1028 )
1029 }
1030 const custom: Record<string, RenderChildren> = {
1031 bandLine1: bandChips(0),
1032 bandLine2: bandChips(1),
1033 sections: chips(
1034 'set-section',
1035 SECTIONS,
1036 name => o.shown.includes(name as Section),
1037 name => name,
1038 name => save('sections', sectionsToggled(o.shown, name as Section)),
1039 ),
1040 collapsed: chips(
1041 'set-collapsed',
1042 SECTIONS,
1043 name => o.shownCollapsed.includes(name as Section),
1044 name => name,
1045 name => save('collapsed', collapsedToggled(o.shownCollapsed, name as Section)),
1046 ),
1047 notifyOn: chips(
1048 'set-notify-kind',
1049 NOTIFY_KINDS,
1050 kind => S.kinds.includes(kind as NotifyKind),
1051 kind => kindLabel(kind as NotifyKind),
1052 kind => save('notifyOn', notifyKindsToggled(S.kinds, kind as NotifyKind)),
1053 ),
1054 }
1055
1056 return drawSettings(
1057 elements,
1058 {
1059 tabs,
1060 tab,
1061 hint: tab === 'Notifications' ? '(Discord)' : null,
1062 rows,
1063 isHelpShown: state.isHelpShown,
1064 editing: state.editing,
1065 error: state.error,
1066 isConfirmingReset: state.isConfirmingReset,
1067 canType,
1068 isDesktop,
1069 problems: S.settingProblems,
1070 custom,
1071 top: tab === 'Notifications' ? webhookRows : null,
1072 },
1073 {
1074 openTab: next => show({ tab: next }),
1075 set: save,
1076 step: (key, direction) => {
1077 const spec = SCHEMA[key]
1078 return spec === undefined ? undefined : save(key, steppedSetting(spec, Number(values[key] ?? spec.default), direction))
1079 },
1080 edit: key => (key === null ? show({ editing: null }) : openField($, `set-${key}-input`, () => show({ editing: key }))),
1081 submit: async (key, text) => {
1082 const spec = SCHEMA[key]
1083 if (spec === undefined) return
1084 const value = spec.type === 'number' ? typedNumber(spec, text) : text
1085 if (value === undefined) {
1086 await update($, settingsView, now => ({ ...now, editing: key, error: rangeText(spec) }))
1087 return
1088 }
1089 if (await set(key, value)) await show({})
1090 },
1091 reset: key => {
1092 const spec = SCHEMA[key]
1093 return spec === undefined ? undefined : save(key, spec.default)
1094 },
1095 toggleHelp: () => show({ isHelpShown: !state.isHelpShown }),
1096 askResetGroup: () => show({ isConfirmingReset: true }),
1097 resetGroup: async () => {
1098 for (const one of rows) {
1099 const spec = SCHEMA[one.key]
1100 if (one.isChanged && spec !== undefined) await set(one.key, spec.default)
1101 }
1102 await show({})
1103 },
1104 cancelReset: () => show({}),
1105 back: async () => {
1106 await show({})
1107 await flushSettingNote($)
1108 await update($, view, () => 'cockpit')
1109 // Minimized, the settings were all the pane was open for.
1110 if (o.isMinimized) await $.ui.close({ id: PANE })
1111 },
1112 },
1113 )
1114 }
1115 const running = all.filter(one => !one.isDone)
1116 // After Esc, what kept going: background shells and agents survive it.
1117 const kept = [
1118 background.length > 0 ? `${background.length} ${background.length === 1 ? 'shell' : 'shells'}` : '',
1119 running.length > 0 ? `${running.length} ${running.length === 1 ? 'agent' : 'agents'}` : '',
1120 ].filter(Boolean)
1121 const actNow = act.kind === 'interrupted' ? { ...act, detail: kept.length === 0 ? 'nothing left running' : `${kept.join(' and ')} still running` } : act
1122 const work = { shells: background.filter(one => (one.agentId ?? null) === null), agents: running.length }
1123 const finished = all.filter(one => one.isDone)
1124 const failedCount = finished.filter(one => one.outcome === 'failed').length
1125 const doneCount = items.filter(item => item.status === 'completed').length
1126 const limits = spend?.limits ?? []
1127 const cost = head?.usd == null ? null : `$${head.usd.toFixed(2)}`
1128
1129 const who = await read($, account)
1130 const tint = termColor(who?.color)
1131 const billing = billingLabel(who?.billing ?? null)
1132 const picture = (key: string, source: string, alt: string) => Svg !== undefined && <Svg key={key} source={source} alt={alt} />
1133
1134 // Minimize shuts the pane for the band above the prompt and Expand opens
1135 // it again; each writes the layout setting, so the next session opens the
1136 // same way. A band drawn in the pane itself only changes the layout.
1137 // Saving the layout does not reload the mod, so an inline pane, opened for the
1138 // band's rows, has to be asked for the full pane.
1139 if (isCompact) {
1140 const isInline = e.props.placement === 'inline'
1141 return drawBand(
1142 $,
1143 e,
1144 async () => {
1145 if ((await setOption($, 'layout', isInline ? 'full' : 'auto')) && isInline) await $.ui.open(paneFor('full'))
1146 },
1147 e.props.bodyColumns,
1148 )
1149 }
1150
1151 if (isDense) {
1152 const inFlight = summary?.tasks.find(task => task.state === 'assigned') ?? null
1153 const dense: DenseInput = {
1154 email: who?.email ?? null,
1155 billing,
1156 remote: remoteRuns(remoteNow.clients, remoteNow.setting),
1157 context: head === null ? null : { tokens: head.tokens, limit: head.limit },
1158 estimate,
1159 turnsLeft: head === null ? null : turnsToBudget(await read($, turnTokens), head.limit),
1160 limits,
1161 shells: background,
1162 agents: running.map(one => shortSeat(one.seat)),
1163 run: summary === null || summary.total === 0 ? null : { done: summary.done, total: summary.total, current: inFlight === null ? null : `Task ${inFlight.n}${inFlight.title === '' ? '' : `: ${inFlight.title}`}` },
1164 todos: items.length === 0 ? null : { done: doneCount, total: items.length, current: items.find(item => item.status === 'in_progress')?.text ?? null },
1165 repo: git,
1166 refused: refused.length,
1167 now,
1168 }
1169 const rows = o.shown.flatMap(name => {
1170 const runs = denseLine(name, dense)
1171 return runs === null ? [] : [{ key: `dense-${name}`, runs: runsCut(runs, room) }]
1172 })
1173 return (
1174 <Box flexDirection="column">
1175 {resumeCard}
1176 <Box key="dense-now" flexDirection="row" alignItems="center" columnGap={1}>
1177 <Box key="dense-now-text" flexGrow={1} flexShrink={1}>
1178 <Text key="now" wrap="truncate-end">
1179 {drawRuns('now', nowLine(actNow, now, work))}
1180 </Text>
1181 </Box>
1182 </Box>
1183 {!rows.some(row => row.key === 'dense-account') && controls}
1184 {rows.map(row => [
1185 <Box key={row.key}>
1186 <Text wrap="truncate-end">{drawRuns(row.key, row.runs)}</Text>
1187 </Box>,
1188 row.key === 'dense-account' && controls,
1189 ])}
1190 {actions}
1191 </Box>
1192 )
1193 }
1194
1195 // Each section is a card: a rounded frame in its own color, its title and
1196 // headline on the first line, its rows below, and a row's details two cells in.
1197 // Its title collapses it to that line, saved in the collapsed setting; a
1198 // collapsed Context card keeps its buttons.
1199 const inner = Math.max(20, room - 4)
1200 const wide = Math.max(10, inner - 2 * INDENT)hooks/cockpit/lib.ts 1413 lines1import type { CockpitAutoRun, CockpitFinish, CockpitRoots } from '../../types'
2
3// Pure helpers, kept apart from the hooks so tests can reach them directly.
4
5/** The strip's handoff reading shows from this share of the budget. */
6export const WARN_AT = 0.8
7
8/**
9 * The handoff budget: `configured`, else `env`, else dforge's own rule
10 * (reference/session-budget.md): the compaction point minus one task's worst
11 * growth, min(autoCompactWindow, window) × 93% − 140,000. Small windows, where
12 * that comes out near zero, hold a fifth of the window. `configured` is
13 * handoffTokens as the settings resolve it, where DFORGE_BUDGET already
14 * overrides the file; `env` is a raw DFORGE_BUDGET for a caller without them.
15 */
16export function budgetFor(configured: number, env: string | undefined, window: number, compactWindow?: number): number {
17 if (configured > 0) return Math.round(configured)
18 const pinned = Number(env)
19 if (Number.isFinite(pinned) && pinned > 0) return Math.round(pinned)
20 const room = compactWindow !== undefined && compactWindow > 0 ? Math.min(window, compactWindow) : window
21 return Math.max(Math.floor(room * 0.93) - 140_000, Math.floor(room / 5))
22}
23
24/**
25 * The final-phase limit (reference/session-budget.md): 85% of min(autoCompactWindow, window).
26 * Relaxed handoff measures every task against it.
27 */
28export function finalLimitFor(window: number, compactWindow?: number): number {
29 const room = compactWindow !== undefined && compactWindow > 0 ? Math.min(window, compactWindow) : window
30 return Math.floor(room * 0.85)
31}
32
33/** A positive whole number from a settings value, else undefined. */
34export function positive(value: unknown): number | undefined {
35 const n = Number(value)
36 return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined
37}
38
39export type Level = 'quiet' | 'warn' | 'handoff'
40
41export function levelOf(tokens: number, limit: number, warnAt: number = WARN_AT): Level {
42 if (tokens >= limit) return 'handoff'
43 return tokens >= limit * warnAt ? 'warn' : 'quiet'
44}
45
46/** The warnAt setting, a percentage, as a share: 80 → 0.8, held to 1-99. */
47export function warnShare(percent: number): number {
48 return Number.isFinite(percent) && percent > 0 ? Math.min(Math.max(Math.round(percent), 1), 99) / 100 : WARN_AT
49}
50
51export const SECTIONS = ['account', 'context', 'usage', 'shells', 'agents', 'plan', 'repo', 'guard'] as const
52export type Section = (typeof SECTIONS)[number]
53
54/** The sections setting, "plan, context" → ['plan', 'context']: known names in the order given, each once. */
55export function sectionsFrom(text: string): Section[] {
56 const names = text
57 .split(/[\s,]+/)
58 .map(name => name.trim().toLowerCase())
59 .filter((name): name is Section => (SECTIONS as readonly string[]).includes(name))
60 const once = [...new Set(names)]
61 return once.length === 0 ? [...SECTIONS] : once
62}
63
64/**
65 * The sections setting after turning one section on or off: the shown order
66 * kept, a section turned on added last, and the last one left never turned
67 * off. All of them in their own order is the empty setting.
68 */
69export function sectionsToggled(shown: readonly Section[], name: Section): string {
70 const next = shown.includes(name)
71 ? shown.length === 1
72 ? [...shown]
73 : shown.filter(one => one !== name)
74 : [...shown, name]
75 const isDefault = next.length === SECTIONS.length && next.every((one, index) => one === SECTIONS[index])
76 return isDefault ? '' : next.join(',')
77}
78
79/** The collapsed setting, "repo, usage" → ['usage', 'repo']: known names, each once, in SECTIONS order. */
80export function collapsedFrom(text: string): Section[] {
81 const names = text.split(/[\s,]+/).map(name => name.trim().toLowerCase())
82 return SECTIONS.filter(name => names.includes(name))
83}
84
85/** The collapsed setting after collapsing or expanding one section, in SECTIONS order; empty when none is collapsed. */
86export function collapsedToggled(collapsed: readonly Section[], name: Section): string {
87 const next = collapsed.includes(name) ? collapsed.filter(one => one !== name) : [...collapsed, name]
88 return SECTIONS.filter(one => next.includes(one)).join(',')
89}
90
91export type AgentGroupKey = 'waiting' | 'working' | 'failed' | 'done'
92const AGENT_GROUPS: readonly { key: AgentGroupKey; name: string }[] = [
93 { key: 'waiting', name: 'Waiting on you' },
94 { key: 'working', name: 'Working' },
95 { key: 'failed', name: 'Failed' },
96 { key: 'done', name: 'Done' },
97]
98
99/**
100 * The Agents section's groups in order, each left out while empty: the
101 * running agents an open approval names, the other running ones, then the
102 * recent ended ones (up to `shown` rows with the running) as failed or done.
103 * A record without an outcome that ended reads as done. `spawns` must run
104 * oldest first: the ended ones kept are the last ones.
105 */
106export function agentGroups<T extends { agentId: string; isDone: boolean; outcome?: 'done' | 'failed' | null }>(
107 spawns: readonly T[],
108 waiting: readonly string[],
109 shown = 6,
110): { key: AgentGroupKey; name: string; spawns: T[] }[] {
111 const running = spawns.filter(one => !one.isDone)
112 const room = Math.max(0, shown - running.length)
113 // slice(-0) would keep every ended agent.
114 const ended = room === 0 ? [] : spawns.filter(one => one.isDone).slice(-room)
115 const members: Record<AgentGroupKey, T[]> = {
116 waiting: running.filter(one => waiting.includes(one.agentId)),
117 working: running.filter(one => !waiting.includes(one.agentId)),
118 failed: ended.filter(one => one.outcome === 'failed'),
119 done: ended.filter(one => one.outcome !== 'failed'),
120 }
121 return AGENT_GROUPS.map(group => ({ ...group, spawns: members[group.key] })).filter(group => group.spawns.length > 0)
122}
123
124/** 465000 → "465k"; 1234567 → "1.2M"; 999500, which rounds to 1000k, → "1.0M". */
125export function kTokens(n: number): string {
126 if (n >= 999_500) return `${(n / 1_000_000).toFixed(1)}M`
127 return `${Math.round(n / 1000)}k`
128}
129
130const ATTRIBUTION = [
131 /co-authored-by:[^\n]*(claude|anthropic|codex|openai|copilot)/i,
132 /claude-session:/i,
133 /generated with \[?claude code/i,
134 /claude\.ai\/code\/session_/i,
135]
136
137/**
138 * The first AI attribution line in a text, if any, without the quoting around
139 * it: a shell command's closing quote, or a JSON string's escapes.
140 */
141export function attributionIn(text: string): string | undefined {
142 for (const pattern of ATTRIBUTION) {
143 const match = pattern.exec(text)
144 if (match) {
145 const line = text.slice(match.index).split(/\n|\\n/)[0] ?? ''
146 return line.replace(/[\\"'`}\],\s]+$/, '').trim()
147 }
148 }
149 return undefined
150}
151
152/** Whether a shell command writes a commit or a pull request. */
153export function writesGitText(command: string): boolean {
154 return /\bgit\b[^\n|;&]*\bcommit\b/.test(command) || /\bgh\s+pr\s+(create|edit)\b/.test(command)
155}
156
157/** The files a commit or pull request command takes its text from (`git commit -F`, `gh pr create --body-file`); stdin's `-` is the command's own text. */
158export function gitTextFiles(command: string): string[] {
159 const files: string[] = []
160 for (const part of command.split(/[;&|\n]+/)) {
161 if (!writesGitText(part)) continue
162 for (const match of part.matchAll(/(?:^|\s)(?:-F[ \t]*|--(?:body-)?file(?:=|[ \t]+))("[^"]*"|'[^']*'|[^\s"';&|]+)/g)) {
163 const file = (match[1] ?? '').replace(/^(["'])(.*)\1$/, '$2')
164 if (file !== '' && file !== '-') files.push(file)
165 }
166 }
167 return files
168}
169
170/** Whether a tool by this name writes a pull request or commits files. */
171export function isGitWriteTool(tool: string): boolean {
172 return /pull_request|create_or_update_file|push_files/.test(tool) && !/read|list|search|get_/.test(tool)
173}
174
175/** Whether a subagent type or skill name belongs to dforge. */
176export function isDforge(name: string): boolean {
177 return name.startsWith('dforge:')
178}
179
180/** The agent.spawn fields the seat check reads. */
181export type SeatSpawn = {
182 subagentType: string
183 description: string
184 fork: boolean
185 model?: string
186 parentAgentId?: string
187 workflow?: unknown
188 isTeammate?: true
189 resume?: unknown
190}
191
192/**
193 * Whether a fork described `Draft plan tasks …` may run in a dforge session:
194 * docs/dforge/notes/2026-10-25-drafter-measurement.md picked the drafter.
195 * Changing it takes a plugin release.
196 */
197export const FORK_DRAFTING = false
198
199/** The engine's catch-all agent types: what a dispatch naming no type resolves to. */
200const CATCH_ALL = ['general-purpose', 'claude']
201
202/**
203 * Why a dforge session's main loop may not start this agent, or null. A fork
204 * inherits the session's context and model, and an untyped agent given no
205 * model inherits the model: the costs typed seats exist to avoid. Workflow
206 * agents, teammates, resumes and spawns inside a subagent are not the main
207 * loop's dispatch, and nothing is checked before a dforge skill or agent ran.
208 */
209export function seatDenial(spawn: SeatSpawn, isPlanSession: boolean, forkDrafting: boolean): string | null {
210 if (!isPlanSession || spawn.parentAgentId !== undefined || spawn.workflow !== undefined || spawn.isTeammate === true || spawn.resume !== undefined) return null
211 if (spawn.fork) {
212 if (forkDrafting && spawn.description.startsWith('Draft plan tasks')) return null
213 return "dforge: forks are kept out of dforge sessions. Use dforge:judge-sonnet-high for a read-only check and writing-plans' drafting seat for plan drafting; see using-dforge §Seats."
214 }
215 if (CATCH_ALL.includes(spawn.subagentType) && spawn.model === undefined) {
216 return 'dforge: name a seat for this dispatch: dforge:judge-sonnet-high or dforge:judge-opus for a review or read-only check, the task\'s **Implementer:** agent (such as dforge:impl-sonnet-medium) for implementation, or pass an explicit model (model: "sonnet") to general-purpose; see using-dforge §Seats.'
217 }
218 return null
219}
220
221/** 0.8 → "▰▰▰▰▰▰▰▰▱▱" at width 10; never full below 100%. */
222export function bar(share: number, width: number): string {
223 const clamped = Math.min(Math.max(share, 0), 1)
224 let on = Math.round(clamped * width)
225 if (on === width && clamped < 1) on = width - 1
226 return '▰'.repeat(on) + '▱'.repeat(width - on)
227}
228
229/** The usage ramp: green, yellow, orange, then red. */
230export function rampColor(percent: number): string {
231 if (percent >= 90) return 'error'
232 if (percent >= 75) return '#ff8700'
233 return percent >= 50 ? 'warning' : 'success'
234}
235
236/** Milliseconds → "3h40m", "2d4h", "12m"; nothing under a minute, so a caller says "now" or "<1m" itself. */
237export function duration(ms: number): string {
238 if (!(ms >= 60_000)) return ''
239 const minutes = Math.floor(ms / 60_000)
240 const days = Math.floor(minutes / 1440)
241 const hours = Math.floor((minutes % 1440) / 60)
242 if (days > 0) return `${days}d${hours}h`
243 return hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
244}
245
246/** A handoff's age as the resume offer says it: "handed off 2h5m ago", "handed off just now". */
247export function handedOff(ms: number): string {
248 return ms < 60_000 ? 'handed off just now' : `handed off ${duration(ms)} ago`
249}
250
251/** five_hour → "5h", seven_day → "7d", spend_limit → "spend". */
252export function limitLabel(kind: string): string {
253 if (kind === 'five_hour') return '5h'
254 if (kind === 'seven_day') return '7d'
255 return kind.replace(/_limit$/, '').replace(/_/g, ' ')
256}
257
258export type PlanStatus = 'pending' | 'in_progress' | 'completed'
259export type PlanItem = { id: string; text: string; status: PlanStatus }
260
261/** A TodoWrite call replaces the whole list. */
262export function planFromTodos(todos: readonly { content: string; status: PlanStatus }[]): PlanItem[] {
263 return todos.map((todo, index) => ({ id: `todo-${index}`, text: todo.content, status: todo.status }))
264}
265
266/** A TaskUpdate call moves or retitles one task; `deleted` drops it. */
267export function planWithUpdate(
268 items: readonly PlanItem[],
269 id: string,
270 change: { subject?: string; status?: PlanStatus | 'deleted' },
271): PlanItem[] {
272 const { subject, status } = change
273 if (status === 'deleted') return items.filter(item => item.id !== id)
274 return items.map(item =>
275 item.id === id ? { ...item, text: subject ?? item.text, status: status ?? item.status } : item,
276 )
277}
278
279export type RepoState = { branch: string; ahead: number; behind: number; changed: number; untracked: number }
280
281/** Reads `git status --porcelain=v2 --branch`. */
282export function repoFromPorcelain(text: string): RepoState | null {
283 const state: RepoState = { branch: '', ahead: 0, behind: 0, changed: 0, untracked: 0 }
284 let oid = ''
285 for (const line of text.split('\n')) {
286 if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
287 else if (line.startsWith('# branch.head ')) state.branch = line.slice(14).trim()
288 else if (line.startsWith('# branch.ab ')) {
289 const match = /\+(\d+) -(\d+)/.exec(line)
290 if (match) {
291 state.ahead = Number(match[1])
292 state.behind = Number(match[2])
293 }
294 } else if (line.startsWith('? ')) state.untracked += 1
295 else if (/^[12u] /.test(line)) state.changed += 1
296 }
297 if (state.branch === '') return null
298 if (state.branch === '(detached)') state.branch = oid === '' ? 'detached' : `@${oid.slice(0, 7)}`
299 return state
300}
301
302const SPARKS = '▁▂▃▄▅▆▇█'
303
304/** Readings → "▁▃▅█" scaled between their least and most; flat readings sit mid-height. */
305export function sparkline(values: readonly number[]): string {
306 if (values.length === 0) return ''
307 const low = Math.min(...values)
308 const span = Math.max(...values) - low
309 return values
310 .map(value => SPARKS[span === 0 ? 3 : Math.round(((value - low) / span) * (SPARKS.length - 1))])
311 .join('')
312}
313
314/** One run of a meter: filled, empty, or a mark standing at a share of the width. */
315export type TrackPart = { text: string; kind: 'fill' | 'empty' | 'mark'; mark?: string }
316
317/**
318 * A meter `width` cells wide filled to `share`, with each mark's glyph in the
319 * cell its share lands on (a later mark wins a shared cell). Never full below 100%.
320 */
321export function track(share: number, width: number, marks: readonly { at: number; glyph: string; name: string }[]): TrackPart[] {
322 const filled = bar(share, width)
323 const cells: TrackPart[] = [...filled].map(cell => ({ text: cell, kind: cell === '▰' ? 'fill' : 'empty' }))
324 for (const mark of marks) {
325 if (!(mark.at >= 0 && mark.at <= 1)) continue
326 const index = Math.min(width - 1, Math.floor(mark.at * width))
327 cells[index] = { text: mark.glyph, kind: 'mark', mark: mark.name }
328 }
329 // Neighbouring cells of one kind join into one run.
330 return cells.reduce<TrackPart[]>((runs, cell) => {
331 const last = runs[runs.length - 1]
332 if (last !== undefined && last.kind === cell.kind && cell.kind !== 'mark') last.text += cell.text
333 else runs.push({ ...cell })
334 return runs
335 }, [])
336}
337
338/**
339 * Splits `width` cells among values in proportion, largest remainders first,
340 * every nonzero value at least one cell while the width lasts.
341 */
342export function stackedRuns(values: readonly number[], width: number): number[] {
343 const total = values.reduce((sum, value) => sum + Math.max(0, value), 0)
344 if (total === 0) return values.map(() => 0)
345 const exact = values.map(value => (Math.max(0, value) / total) * width)
346 const runs = exact.map(Math.floor)
347 let left = width - runs.reduce((sum, run) => sum + run, 0)
348 const order = exact.map((value, index) => ({ index, rest: value - Math.floor(value) })).sort((a, b) => b.rest - a.rest)
349 for (const { index } of order) {
350 if (left === 0) break
351 runs[index] = (runs[index] ?? 0) + 1
352 left--
353 }
354 for (const [index, run] of runs.entries()) {
355 if (run > 0 || (values[index] ?? 0) <= 0) continue
356 const donor = runs.indexOf(Math.max(...runs))
357 if ((runs[donor] ?? 0) > 1) {
358 runs[donor] = (runs[donor] ?? 0) - 1
359 runs[index] = 1
360 }
361 }
362 return runs
363}
364
365const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
366
367/** How far through its window a rate limit is, 0 to 1, or null for a window of unknown length. */
368export function windowElapsed(kind: string, resetsAt: number | null, now: number): number | null {
369 const length = WINDOW_MS[kind]
370 if (length === undefined || resetsAt === null) return null
371 return Math.min(1, Math.max(0, 1 - (resetsAt - now) / length))
372}
373
374/** One stretch of text in the status strip, with how it is painted. */
375export type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
376
377/** What the band can show, as the bandLine1 and bandLine2 settings name them. */
378export const BAND_SEGMENTS = ['dir', 'git', 'model', 'effort', 'style', 'turn', 'run', 'agents', 'shells', 'ctx', 'cache', 'cost', '5h', '7d', 'time'] as const
379export type BandSegment = (typeof BAND_SEGMENTS)[number]
380export type BandLayout = readonly [readonly BandSegment[], readonly BandSegment[]]
381
382export const BAND_DEFAULT_LINES: BandLayout = [
383 ['dir', 'git', 'model', 'effort', 'style', 'turn', 'run', 'agents', 'shells'],
384 ['ctx', 'cache', 'cost', '5h', '7d'],
385]
386
387/**
388 * The band's lines from the bandLine1 and bandLine2 settings: known names in
389 * the order given, each once. Empty is the line's default and none hides it;
390 * a segment both lines name stays on the first.
391 */
392export function bandLinesFrom(one: string, two: string): [BandSegment[], BandSegment[]] {
393 const line = (text: string, index: 0 | 1): BandSegment[] => {
394 const names = text
395 .split(/[\s,]+/)
396 .map(name => name.trim().toLowerCase())
397 .filter(name => name !== '')
398 if (names.length === 0) return [...BAND_DEFAULT_LINES[index]]
399 return [...new Set(names.filter((name): name is BandSegment => (BAND_SEGMENTS as readonly string[]).includes(name)))]
400 }
401 const first = line(one, 0)
402 return [first, line(two, 1).filter(name => !first.includes(name))]
403}
404
405/**
406 * The bandLine1 and bandLine2 settings after turning one segment on or off
407 * on a line: turned on, it goes last there and leaves the other line. A line
408 * left empty is none; a line in its default order is the empty setting.
409 */
410export function bandLineToggled(lines: BandLayout, index: 0 | 1, name: BandSegment): [string, string] {
411 const next = lines.map((line, at) =>
412 at === index && !line.includes(name) ? [...line, name] : line.filter(one => one !== name),
413 )
414 const setting = (at: 0 | 1): string => {
415 const line = next[at] ?? []
416 const fallback = BAND_DEFAULT_LINES[at]
417 if (line.length === fallback.length && line.every((one, position) => one === fallback[position])) return ''
418 return line.length === 0 ? 'none' : line.join(',')
419 }
420 return [setting(0), setting(1)]
421}
422
423/** What the minimized cockpit's band draws. */
424export type BandInput = {
425 /** The segments each line shows, in order; the defaults when absent. */
426 lines?: BandLayout
427 cwd: string | null
428 /** The repository's root, when the working directory is in one. */
429 root: string | null
430 home: string | null
431 repo: RepoState | null
432 /** The context against the handoff budget, over the model's window. */
433 context: { tokens: number; limit: number; window: number } | null
434 turnsLeft: number | null
435 limits: readonly { kind: string; percent: number; resetsAt: number | null }[]
436 agents: number
437 /** Background shells running, and how long the oldest has run. */
438 shells: { count: number; longest: number }
439 now: number
440 model?: string | null
441 effort?: string | null
442 /** The output style's name; null for the default style. */
443 style?: string | null
444 /** The share of the last main turn's input served from the cache, 0 to 1. */
445 cache?: number | null
446 usd?: number | null
447 /** The main turn while it runs, waits on the person or has failed. */
448 turn?: { kind: 'running' | 'waiting' | 'asking' | 'failed'; since: number; tool: string | null } | null
449 /** A dforge run's tasks, and the review round of the task in flight. */
450 run?: { done: number; total: number; round: string | null; blocked: number } | null
451 /** True while the main conversation's background work runs: the line holding agents, shells or the turn leads with ◐. */
452 isBackground?: boolean
453}
454
455const DIR = 'blue'
456const GIT = 'magenta'
457const AMBER_RUN = '#ff8700'
458const COST = '#af87ff'
459// The effort's hue rises with it, away from the meters' green-to-red ramp, so
460// a color never means both near a limit and set to max.
461const EFFORT_COLORS: Record<string, string> = { low: 'gray', medium: 'blue', high: 'cyan', xhigh: '#af87ff', max: 'magenta' }
462// Bar widths per tier; 0 draws no bar.
463const BAND_WIDTHS = [
464 { ctx: 10, limit: 8 },
465 { ctx: 6, limit: 5 },
466 { ctx: 0, limit: 0 },
467 { ctx: 0, limit: 0 },
468] as const
469export const BAND_TIERS = BAND_WIDTHS.length
470
471/** A shell's age: seconds under a minute, then minutes, hours and days. */
472export function shellAge(ms: number): string {
473 return ms < 60_000 ? elapsed(ms) : duration(ms)
474}
475
476/** The band's two lines at one tier: 0 is the fullest, 3 the most compact. */
477export function bandLines(input: BandInput, tier: number): [Run[], Run[]] {
478 const sep: Run = { text: tier < 2 ? ' · ' : ' · ', dim: true }
479 const join = (segments: (Run[] | null)[]) =>
480 segments.filter((one): one is Run[] => one !== null && one.length > 0).flatMap((one, index) => (index === 0 ? one : [sep, ...one]))
481 const widths = BAND_WIDTHS[Math.min(tier, BAND_TIERS - 1)] ?? BAND_WIDTHS[0]
482 const meter = (parts: TrackPart[], color: string, marks: Record<string, string>): Run[] =>
483 parts.map(part => ({ text: part.text, color: part.kind === 'fill' ? color : part.kind === 'mark' ? (marks[part.mark ?? ''] ?? color) : undefined, dim: part.kind === 'empty', bold: part.kind === 'mark' }))
484
485 let ctx: Run[] | null = null
486 if (input.context !== null) {
487 const { tokens, limit, window } = input.context
488 const percent = (tokens / limit) * 100
489 const color = rampColor(percent)
490 const turns = input.turnsLeft
491 ctx = [
492 { text: 'ctx ', dim: true },
493 ...(widths.ctx > 0 ? [...meter(track(tokens / window, widths.ctx, [{ at: limit / window, glyph: '┃', name: 'handoff' }]), color, { handoff: color }), { text: ' ' }] : []),
494 { text: `${Math.round(percent)}%`, color, bold: true },
495 ...(turns === null || tier >= 3 ? [] : [{ text: tier < 2 ? ` · ≈${turns} ${turns === 1 ? 'turn' : 'turns'}` : ` ≈${turns}t`, color: turns <= 3 ? AMBER_RUN : undefined, dim: turns > 3 }]),
496 ]
497 }
498
499 const limitOf = (kind: string): Run[] | null => {
500 const limit = input.limits.find(one => one.kind === kind)
501 if (limit === undefined) return null
502 const color = rampColor(limit.percent)
503 const gone = windowElapsed(limit.kind, limit.resetsAt, input.now)
504 const forecast = limitForecast(limit.kind, limit.percent, limit.resetsAt, input.now)
505 const pace: Run[] =
506 tier >= 3
507 ? []
508 : forecast === 'pace'
509 ? [{ text: tier < 2 ? ' on pace' : ' ✓', color: 'success' }]
510 : typeof forecast === 'number'
511 ? [{ text: tier < 2 ? ` out in ${duration(forecast) || '<1m'}` : ` ${duration(forecast) || '<1m'}`, color: AMBER_RUN }]
512 : []
513 const reset = tier === 0 && limit.resetsAt !== null && forecast !== null && typeof forecast !== 'number' ? [{ text: ` · ${duration(limit.resetsAt - input.now) || 'now'}`, dim: true }] : []
514 return [
515 { text: `${limitLabel(limit.kind)} `, dim: true },
516 ...(widths.limit > 0 ? [...meter(track(limit.percent / 100, widths.limit, gone === null ? [] : [{ at: gone, glyph: '┊', name: 'pace' }]), color, { pace: 'white' }), { text: ' ' }] : []),
517 { text: `${Math.round(limit.percent)}%`, color, bold: true },
518 ...pace,
519 ...reset,
520 ]
521 }
522
523 const agents: Run[] | null =
524 input.agents === 0 ? null : [{ text: '● ', color: 'cyan' }, { text: tier < 2 ? `${input.agents} ${input.agents === 1 ? 'agent' : 'agents'}` : `${input.agents}`, color: 'cyan' }]
525 const { count, longest } = input.shells
526 const shells: Run[] | null =
527 count === 0
528 ? null
529 : [
530 { text: '$ ', color: 'yellow', bold: true },
531 { text: tier < 2 ? `${count} ${count === 1 ? 'shell' : 'shells'}` : `${count}`, color: 'yellow' },
532 ...(tier >= 3 ? [] : [{ text: tier < 1 ? ' · longest ' : ' · ', dim: true }, { text: shellAge(longest), color: 'yellow' }]),
533 ]
534
535 let dir: Run[] | null = null
536 if (input.cwd !== null) {
537 const { lead, anchor, inner } = pathParts(input.cwd, input.root, input.home)
538 const parts = inner.split('/').filter(Boolean)
539 const leaf = parts[parts.length - 1] ?? anchor
540 dir = (
541 tier === 0
542 ? [{ text: lead, color: DIR, dim: true }, { text: anchor, color: DIR, bold: true }, { text: inner, color: DIR }]
543 : tier === 1
544 ? [{ text: anchor, color: DIR, bold: true }, { text: parts.length > 1 ? `/…/${leaf}` : inner, color: DIR }]
545 : [{ text: input.root === null ? leaf : anchor, color: DIR, bold: true }]
546 ).filter(run => run.text !== '')
547 }
548 let git: Run[] = []
549 if (input.repo !== null) {
550 const r = input.repo
551 const branch = tier >= 2 && r.branch.length > 16 ? `${r.branch.slice(0, 15)}…` : r.branch
552 const counters = [
553 r.ahead > 0 ? `↑${r.ahead}` : '',
554 tier < 3 && r.behind > 0 ? `↓${r.behind}` : '',
555 tier < 2 && r.changed > 0 ? `~${r.changed}` : '',
556 tier < 2 && r.untracked > 0 ? `?${r.untracked}` : '',
557 ].filter((text, index) => text !== '' && (tier < 3 || index === 0))
558 git = [
559 { text: '⎇ ', color: GIT },
560 { text: branch, color: GIT, bold: true },
561 ...(r.changed > 0 || r.untracked > 0 ? [{ text: '*', color: GIT }] : []),
562 ...(counters.length > 0 && tier < 3 ? [{ text: ` ${counters.join(' ')}`, color: GIT }] : []),
563 ]
564 }
565 const model: Run[] | null = input.model == null ? null : [{ text: modelName(input.model, tier >= 3), color: 'cyan', bold: true }]
566 const effort: Run[] | null = input.effort == null ? null : [{ text: input.effort, color: EFFORT_COLORS[input.effort] ?? 'gray' }]
567 const style: Run[] | null = input.style == null ? null : [{ text: tier >= 2 ? clipped(input.style, 12) : input.style, dim: true }]
568 const cost: Run[] | null = input.usd == null ? null : [{ text: `$${input.usd.toFixed(2)}`, color: COST, bold: true }]
569 const clock = new Date(input.now)
570 const time: Run[] = [{ text: `${String(clock.getHours()).padStart(2, '0')}:${String(clock.getMinutes()).padStart(2, '0')}`, dim: true }]
571
572 let cache: Run[] | null = null
573 if (input.cache != null) {
574 const percent = input.cache * 100
575 // A high hit rate is the good reading, so the ramp runs on what missed.
576 const color = rampColor(100 - percent)
577 cache = [
578 { text: 'cache ', dim: true },
579 ...(widths.limit > 0 ? [...meter(track(input.cache, widths.limit, []), color, {}), { text: ' ' }] : []),
580 { text: `${Math.round(percent)}%`, color, bold: true },
581 ]
582 }
583
584 let turn: Run[] | null = null
585 if (input.turn != null) {
586 const { kind, since, tool } = input.turn
587 const age = elapsed(input.now - since)
588 turn =
589 kind === 'running'
590 ? [{ text: '▸ ', color: 'cyan', bold: true }, { text: age, color: 'cyan' }, ...(tier < 2 && tool !== null ? [{ text: ` · ${clipped(tool, 20)}`, dim: true }] : [])]
591 : kind === 'failed'
592 ? [{ text: 'turn failed', color: 'red', bold: true }]
593 : [{ text: kind === 'waiting' ? 'approve? ' : 'asking ', color: 'yellow', bold: true }, { text: age, color: 'yellow' }]
594 }
595
596 let run: Run[] | null = null
597 if (input.run != null && input.run.total > 0) {
598 const { done, total, round, blocked } = input.run
599 run =
600 blocked > 0
601 ? [{ text: `run ${done}/${total}`, color: 'red', bold: true }, ...(tier < 2 ? [{ text: ' blocked', color: 'red' }] : [])]
602 : [{ text: `run ${done}/${total}`, color: 'yellow' }, ...(tier < 2 && round !== null ? [{ text: ` r${round}`, color: 'yellow' }] : [])]
603 }
604
605 const pieces: Record<BandSegment, Run[] | null> = {
606 dir,
607 git,
608 model,
609 effort,
610 style,
611 turn,
612 run,
613 agents,
614 shells,
615 ctx,
616 cache,
617 cost,
618 '5h': limitOf('five_hour'),
619 '7d': limitOf('seven_day'),
620 time,
621 }
622 const lineOf = (names: readonly BandSegment[]): Run[] => {
623 const parts: Run[][] = []
624 let last: BandSegment | null = null
625 for (const name of names) {
626 const runs = pieces[name]
627 if (runs === null || runs.length === 0) continue
628 // The branch rides on the path it belongs to, without a separator.
629 if (name === 'git' && last === 'dir') parts.push([...(parts.pop() ?? []), { text: ' ' }, ...runs])
630 else parts.push(runs)
631 last = name
632 }
633 return join(parts)
634 }
635 const layout = input.lines ?? BAND_DEFAULT_LINES
636 const out: [Run[], Run[]] = [lineOf(layout[0]), lineOf(layout[1])]
637 if (input.isBackground === true) {
638 const at = Math.max(0, layout.findIndex(names => names.some(name => name === 'agents' || name === 'shells' || name === 'turn')))
639 out[at] = [{ text: '◐ ', color: AMBER_RUN, bold: true }, ...out[at]]
640 }
641 return out
642}
643
644function clipped(text: string, cells: number): string {
645 return text.length > cells ? `${text.slice(0, cells - 1)}…` : text
646}
647
648/**
649 * Each band line at the fullest tier that fits `width`, stepping down on its
650 * own; a line too wide even at the last tier is cut.
651 */
652export function fitBand(input: BandInput, width: number): [Run[], Run[]] {
653 const fit = (which: 0 | 1): Run[] => {
654 for (let tier = 0; tier < BAND_TIERS; tier++) {
655 const line = bandLines(input, tier)[which]
656 if (runsWidth(line) <= width) return line
657 }
658 return runsCut(bandLines(input, BAND_TIERS - 1)[which], width)
659 }
660 return [fit(0), fit(1)]
661}
662
663// A terminal draws a combining mark, a joiner or a variation selector in no
664// cell of its own, and an emoji or an East Asian wide or fullwidth character in
665// two. An emoji drawn as text by default (⚠) takes two once U+FE0F asks for its
666// emoji presentation (⚠️).
667const ZERO_CELLS = /[\p{Mn}\p{Me}\u200b-\u200f\ufe00-\ufe0f]/u
668const TWO_CELLS = /[\p{Emoji_Presentation}\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}]/u
669const EMOJI = /\p{Emoji}/u
670
671/** The terminal cells a text takes. */
672export function cellWidth(text: string): number {
673 let cells = 0
674 let previous = ''
675 for (const char of text) {
676 const isWidened = char === '\ufe0f' && EMOJI.test(previous) && !TWO_CELLS.test(previous)
677 cells += isWidened ? 1 : ZERO_CELLS.test(char) ? 0 : TWO_CELLS.test(char) ? 2 : 1
678 previous = char
679 }
680 return cells
681}
682
683/** The cells a row of runs takes. */
684export function runsWidth(runs: readonly Run[]): number {
685 return runs.reduce((sum, run) => sum + cellWidth(run.text), 0)
686}
687
688/** A row cut to `width` cells, ending in an ellipsis when anything was cut. */
689export function runsCut(runs: readonly Run[], width: number): Run[] {
690 if (runsWidth(runs) <= width) return [...runs]
691 const out: Run[] = []
692 let left = Math.max(0, width - 1)
693 for (const run of runs) {
694 const cells = cellWidth(run.text)
695 if (cells <= left) {
696 out.push(run)
697 left -= cells
698 continue
699 }
700 let text = ''
701 for (const char of run.text) {
702 if (cellWidth(text + char) > left) break
703 text += char
704 }
705 if (text !== '') out.push({ ...run, text })
706 break
707 }
708 if (width > 0) out.push({ text: '…', dim: true })
709 return out
710}
711
712// The named colors the rows use, as the theme keys that mean the same.
713const THEME_KEYS: Record<string, string> = { green: 'success', red: 'error', yellow: 'warning', cyan: 'suggestion', blue: 'suggestion', magenta: 'planMode', gray: 'subtle', white: 'text' }
714
715/** A named color as the theme key that means the same, so the desktop's theme draws it; a theme key, a hex or any other name passes through. */
716export function themeColor(name: string | undefined): string | undefined {
717 return name === undefined ? undefined : (THEME_KEYS[name] ?? name)
718}
719
720/** A terminal color: a 256-color number becomes hex, orange its own hex, a name stays. */
721export function termColor(name: string | null | undefined): string | undefined {
722 if (name === null || name === undefined || name === '' || name === 'default') return undefined
723 if (name === 'orange') return '#ff8700'
724 if (!/^\d{1,3}$/.test(name)) return name
725 const n = Number(name)
726 if (n > 255) return undefined
727 const base = ['#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0',
728 '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff']
729 if (n < 16) return base[n]
730 const hex = (v: number) => v.toString(16).padStart(2, '0')
731 if (n >= 232) {
732 const v = 8 + (n - 232) * 10
733 return `#${hex(v)}${hex(v)}${hex(v)}`
734 }
735 const steps = [0, 95, 135, 175, 215, 255]
736 const i = n - 16
737 return `#${hex(steps[Math.floor(i / 36)] ?? 0)}${hex(steps[Math.floor(i / 6) % 6] ?? 0)}${hex(steps[i % 6] ?? 0)}`
738}
739
740/** "claude-opus-5-5[1m]" → "Opus 5.5", or "Opus" when short. */
741export function modelName(id: string, isShort = false): string {
742 const bare = id.replace(/\[.*?\]$/, '').replace(/^claude-/, '').replace(/-\d{8}$/, '')
743 const [family = bare, ...rest] = bare.split('-')
744 const name = family.charAt(0).toUpperCase() + family.slice(1)
745 const version = rest.filter(part => /^\d+$/.test(part)).join('.')
746 return isShort || version === '' ? name : `${name} ${version}`
747}
748
749/** The account's key: its config directory, "~"-relative under home. */
750export function accountKey(configDir: string, home: string): string {
751 const dir = slashes(configDir)
752 const base = slashes(home)
753 if (dir === base) return '~'
754 return base !== '' && dir.startsWith(`${base}/`) ? `~${dir.slice(base.length)}` : dir
755}
756
757// Frame colors that read on light and dark terminals alike.
758const ACCOUNT_COLORS = ['#af87ff', '#5fafff', '#5fd7af', '#ffaf5f', '#ff87af', '#87d75f', '#d787ff', '#5fd7ff'] as const
759
760/** The account's frame color: one of eight, picked from its key, so each account keeps its own. */
761export function accountColor(key: string): string {
762 let hash = 0
763 for (const ch of key) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0
764 return ACCOUNT_COLORS[hash % ACCOUNT_COLORS.length] ?? ACCOUNT_COLORS[0]
765}
766
767/** Claude's billing type, "stripe_subscription" → "subscription". */
768export function billingLabel(billing: string | null): string | null {
769 if (billing === null || billing === '') return null
770 return billing.replace(/^(stripe|apple|google)_/, '').replace(/_/g, ' ')
771}
772
773/** Whether a path is absolute on any platform: rooted at a slash or a drive letter. */
774export function isAbsolutePath(path: string): boolean {
775 return /^([\\/]|[a-z]:[\\/])/i.test(path)
776}
777
778/**
779 * Where a ledger's plan name may be read, in order (darkmem store options spec
780 * §7.1 and §7.3): an absolute name as written; a document name under the docs
781 * root while documents are in darkmem, else under each checkout's own docs
782 * directory; any other name under each checkout. The session's checkout comes
783 * first. A ledger written before 4.0.0 cites its plan as docs/forge/<rest>,
784 * or before 3.0.0 as docs/superpowers/<rest>; both still name
785 * docs/dforge/<rest>: ledgers are never rewritten.
786 */
787export function planCandidates(plan: string, docs: CockpitRoots['docs'] | null, checkouts: readonly { root: string; docsDir: string }[]): string[] {
788 if (isAbsolutePath(plan)) return [plan]
789 const name = plan.replace(/^\.\//, '')
790 const doc = /^docs\/(?:dforge|forge|superpowers)(?:\/(.*))?$/.exec(name)
791 const under = (dir: string) => (doc?.[1] ? `${dir}/${doc[1]}` : dir)
792 if (doc && docs?.store === 'darkmem') return [under(docs.root)]
793 const seen = new Set<string>()
794 return checkouts.filter(one => !seen.has(one.root) && Boolean(seen.add(one.root))).map(one => (doc ? under(one.docsDir) : `${one.root}/${name}`))
795}
796
797function slashes(path: string): string {
798 return path.replace(/\\/g, '/').replace(/\/+$/, '')
799}
800
801/**
802 * The working directory in three tones: what leads to the
803 * repository, the repository's name, and the path inside it.
804 */
805export function pathParts(cwd: string, root: string | null, home: string | null): { lead: string; anchor: string; inner: string } {
806 const tilde = (path: string) => {
807 const h = home === null ? '' : slashes(home)
808 return h !== '' && (path === h || path.startsWith(`${h}/`)) ? `~${path.slice(h.length)}` : path
809 }
810 const at = slashes(cwd)
811 const top = root === null ? null : slashes(root)
812 const anchorPath = top !== null && (at === top || at.startsWith(`${top}/`)) ? top : at
813 const cut = anchorPath.lastIndexOf('/')
814 const lead = cut <= 0 ? '' : tilde(anchorPath.slice(0, cut + 1))
815 return { lead, anchor: anchorPath.slice(cut + 1) || anchorPath, inner: at.slice(anchorPath.length) }
816}
817
818/**
819 * The repository's name: `owner/name` from its origin remote (scp-style or
820 * URL, `.git` dropped), else the root folder's name.
821 */
822export function repoName(remote: string | null, root: string): string {
823 const path = (remote ?? '')
824 .trim()
825 .replace(/\.git\/?$/, '')
826 .replace(/\/+$/, '')
827 .replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*\//i, '')
828 .replace(/^[^@/]+@[^:/]+:/, '')
829 const parts = path.split('/').filter(Boolean)
830 if (parts.length >= 2) return parts.slice(-2).join('/')
831 if (parts.length === 1) return parts[0] ?? ''
832 const top = slashes(root).replace(/\/+$/, '')
833 return top.slice(top.lastIndexOf('/') + 1) || top
834}
835
836/** The working folder: the way there dim, the repository bold, the rest plain. */
837export function folderRuns(cwd: string, root: string | null, home: string | null): Run[] {
838 const { lead, anchor, inner } = pathParts(cwd, root, home)
839 return [
840 ...(lead === '' ? [] : [{ text: lead, color: DIR, dim: true }]),
841 { text: anchor, color: DIR, bold: true },
842 ...(inner === '' ? [] : [{ text: inner, color: DIR }]),
843 ]
844}
845
846/**
847 * How many main turns until the context reaches the budget, at the average
848 * growth of the last few turns; null with too little to go on, no growth, or
849 * already past it.
850 */
851export function turnsToBudget(perTurn: readonly number[], limit: number): number | null {
852 const recent = perTurn.slice(-6)
853 const last = recent[recent.length - 1]
854 if (recent.length < 3 || last === undefined || last >= limit) return null
855 const steps = recent.slice(1).map((value, index) => value - (recent[index] ?? value)).filter(step => step > 0)
856 if (steps.length === 0) return null
857 const growth = steps.reduce((sum, step) => sum + step, 0) / steps.length
858 return Math.max(1, Math.ceil((limit - last) / growth))
859}
860
861/**
862 * Where a rate limit is headed at the pace spent so far in its window: the
863 * milliseconds until it runs out when that comes before the reset, 'pace' when
864 * it lasts, null when the window is too young or its length unknown.
865 */
866export function limitForecast(kind: string, percent: number, resetsAt: number | null, now: number): number | 'pace' | null {
867 const length = WINDOW_MS[kind]
868 if (length === undefined || resetsAt === null || percent <= 0 || percent >= 100) return null
869 const spent = length - (resetsAt - now)
870 if (spent < 10 * 60_000) return null
871 const left = ((100 - percent) / percent) * spent
872 return now + left < resetsAt ? left : 'pace'
873}
874
875/** One task's state in a dforge SDD ledger. */
876export type LedgerTask = {
877 n: number
878 state: 'assigned' | 'complete' | 'blocked'
879 /** The fix or review round in flight, "2/5". */
880 round: string | null
881 /** The seat the task was assigned to. */
882 seat: string | null
883 isClean: boolean
884 reason: string | null
885}
886
887/** What a run's ledger says: its plan, each task it names, and whether the final review came back clean. */
888export type Ledger = { plan: string | null; tasks: LedgerTask[]; isFinished: boolean }
889
890/** Reads the subagent-driven-development ledger (progress.md) as its grammar writes it. */
891export function parseLedger(text: string): Ledger {
892 const tasks = new Map<number, LedgerTask>()
893 const task = (n: number) => {
894 const found = tasks.get(n) ?? { n, state: 'assigned' as const, round: null, seat: null, isClean: false, reason: null }
895 tasks.set(n, found)
896 return found
897 }
898 let plan: string | null = null
899 let isFinished = false
900 for (const raw of text.split(/\r?\n/)) {
901 const line = raw.trim()
902 const header = /^#\s*SDD ledger\s*[—-]+\s*plan:\s*(.+)$/.exec(line)
903 if (header) {
904 plan = header[1]?.trim() ?? null
905 continue
906 }
907 if (/^Final review: clean/.test(line)) {
908 isFinished = true
909 continue
910 }
911 const group = /^Group (\d+)-(\d+): review round (\d+\/\d+)/.exec(line)
912 if (group) {
913 for (let n = Number(group[1]); n <= Number(group[2]); n++) {
914 const one = task(n)
915 if (one.state === 'assigned') one.round = group[3] ?? null
916 }
917 continue
918 }
919 const match = /^Task (\d+): (.*)$/.exec(line)
920 if (!match) continue
921 // A Ruling, parked or deferred line names a task without starting it.
922 const n = Number(match[1])
923 const rest = match[2] ?? ''
924 const assigned = /^implementer (\S+) \(assigned/.exec(rest)
925 if (assigned) {
926 const one = task(n)
927 one.seat = assigned[1] ?? null
928 if (one.state !== 'complete') one.state = 'assigned'
929 one.reason = null
930 continue
931 }
932 const round = /^fix round (\d+\/\d+)/.exec(rest)
933 if (round) {
934 task(n).round = round[1] ?? null
935 continue
936 }
937 if (/^complete\b/.test(rest)) {
938 const one = task(n)
939 one.state = 'complete'
940 one.round = null
941 one.isClean = /review clean/.test(rest)
942 continue
943 }
944 const blocked = /^BLOCKED\s*[—-]+\s*(.*)$/.exec(rest)
945 if (blocked) {
946 const one = task(n)
947 one.state = 'blocked'
948 one.reason = blocked[1]?.trim() || null
949 }
950 }
951 return { plan, tasks: [...tasks.values()].sort((a, b) => a.n - b.n), isFinished }
952}
953
954/** A plan file's tasks, from its "### Task N: title" headings outside fenced blocks, as plan_tasks in scripts/lib/plan.sh reads them. */
955export function planTasks(text: string): { n: number; title: string }[] {
956 const out: { n: number; title: string }[] = []
957 // CommonMark's rule, as plan.sh has it: a fence closes only at a bare marker of its character at least as long.
958 let fence = ''
959 for (const line of text.split(/\r?\n/)) {
960 if (fence !== '') {
961 const marker = line.replace(/^ {0,3}/, '').trimEnd()
962 if (/^(`+|~+)$/.test(marker) && marker[0] === fence[0] && marker.length >= fence.length) fence = ''
963 continue
964 }
965 const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)
966 if (open) {
967 fence = open[1] ?? ''
968 continue
969 }
970 const match = /^#+[ \t]+Task[ \t]+(\d+)(?=[ \t:.-]|$)[ \t]*[:.\-—]?[ \t]*(.*)$/.exec(line)
971 if (match && !out.some(one => one.n === Number(match[1]))) out.push({ n: Number(match[1]), title: (match[2] ?? '').trim() })
972 }
973 return out
974}
975
976// A plan's last tasks join its final phase once this many or fewer remain
977// (CTX_TAIL_TASKS in scripts/lib/context.sh).
978const TAIL_TASKS = 2
979
980/**
981 * Whether a run is at its plan's tail or in its final phase: at most
982 * TAIL_TASKS tasks left by its ledger, or its final review clean. The budget
983 * line measures both against the final-phase limit.
984 */
985export function isPlanTail(run: { done: number; total: number; isFinished: boolean }): boolean {
986 return run.isFinished || (run.total > 0 && run.total - run.done <= TAIL_TASKS)
987}
988
989const RUN_ORDER: Readonly<Record<string, number>> = { assigned: 0, blocked: 1, pending: 2, complete: 3 }
990
991/** The first `size` of a run's tasks as the Run card lists them: in flight, blocked, not started, then done, each in plan order. */
992export function runList<T extends { n: number; state: string }>(tasks: readonly T[], size: number): T[] {
993 return [...tasks].sort((a, b) => (RUN_ORDER[a.state] ?? 2) - (RUN_ORDER[b.state] ?? 2) || a.n - b.n).slice(0, size)
994}
995
996/** Milliseconds → "42s", "2m14s", "1h05m": a turn's clock, to the second under an hour. */
997export function elapsed(ms: number): string {
998 const seconds = Math.max(0, Math.floor(ms / 1000))
999 if (seconds < 60) return `${seconds}s`
1000 const minutes = Math.floor(seconds / 60)
1001 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
1002 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
1003}
1004
1005/** What the session is doing, as the Now row reads it. */
1006export type ActivityInput = {
1007 kind: 'idle' | 'running' | 'waiting' | 'asking' | 'failed' | 'interrupted' | 'background'
1008 since: number
1009 turnStartedAt: number | null
1010 step: number
1011 tool: string | null
1012 agent: string | null
1013 detail: string | null
1014 lastTurnMs: number | null
1015 endedAt: number | null
1016 isSent: boolean
1017}
1018
1019const AMBER = '#ffaf5f'
1020
1021/** The main conversation's background work, as the Now row names it. */
1022export type ActivityWork = { shells: readonly { command: string; startedAt: number }[]; agents: number }
1023
1024/** A command's first line that is not blank, trimmed; empty when every line is. */
1025export function commandLine(command: string): string {
1026 return (command.split('\n').find(line => line.trim() !== '') ?? '').trim()
1027}
1028
1029/** A command's first line, cut to `width` characters with an ellipsis. */
1030function commandExcerpt(command: string, width: number): string {
1031 const chars = [...commandLine(command)]
1032 return chars.length <= width ? chars.join('') : `${chars.slice(0, width - 1).join('')}…`
1033}
1034
1035/** The Now row: one line, whatever the state, so the pane never jumps. */
1036export function nowLine(now: ActivityInput, at: number, work: ActivityWork = { shells: [], agents: 0 }): Run[] {
1037 const sep: Run = { text: ' · ', dim: true }
1038 const sent: Run[] = now.isSent ? [sep, { text: 'Discord ✓', dim: true }] : []
1039 switch (now.kind) {
1040 case 'running':
1041 return [
1042 { text: '● ', color: 'cyan', bold: true },
1043 { text: 'running ' },
1044 { text: elapsed(at - (now.turnStartedAt ?? now.since)), color: 'cyan', bold: true },
1045 ...(now.step > 0 ? [sep, { text: `step ${now.step}`, dim: true }] : []),
1046 ...(now.tool === null ? [] : [sep, { text: now.tool }]),
1047 ...(now.tool !== null && now.agent !== null ? [{ text: ' in ', dim: true }, { text: now.agent, color: 'magenta' }] : []),
1048 ]
1049 case 'waiting':
1050 return [
1051 { text: '⏳ approve? ', color: AMBER, bold: true },
1052 ...(now.detail === null ? [] : [{ text: now.detail }, sep]),
1053 { text: elapsed(at - now.since), color: AMBER, bold: true },
1054 ...sent,
1055 ]
1056 case 'asking':
1057 return [
1058 { text: '❓ Claude is asking ', color: AMBER, bold: true },
1059 { text: elapsed(at - now.since), color: AMBER, bold: true },
1060 ...(now.detail === null ? [] : [sep, { text: now.detail }]),
1061 ...sent,
1062 ]
1063 case 'failed':
1064 return [
1065 { text: '✗ last turn failed', color: 'error', bold: true },
1066 ...(now.detail === null ? [] : [{ text: ` ${now.detail}` }]),
1067 ...(now.lastTurnMs === null ? [] : [sep, { text: elapsed(now.lastTurnMs), dim: true }]),
1068 ...sent,
1069 ]
1070 case 'interrupted':
1071 return [
1072 { text: '■ interrupted', color: AMBER, bold: true },
1073 ...(now.detail === null ? [] : [sep, { text: now.detail }]),
1074 ...(now.endedAt === null ? [] : [sep, { text: at - now.endedAt < 60_000 ? 'just now' : `${duration(at - now.endedAt)} ago`, dim: true }]),
1075 ]
1076 case 'background': {
1077 const count = work.shells.length
1078 const newest = work.shells.reduce<{ command: string; startedAt: number } | null>((last, shell) => (last === null || shell.startedAt > last.startedAt ? shell : last), null)
1079 const oldest = count === 0 ? null : Math.min(...work.shells.map(shell => shell.startedAt))
1080 const line = newest === null ? '' : commandExcerpt(newest.command, 40)
1081 return [
1082 { text: '◐ background', color: AMBER, bold: true },
1083 ...(count === 0 ? [] : [sep, { text: `${count} ${count === 1 ? 'shell' : 'shells'}` }]),
1084 ...(line === '' ? [] : [sep, { text: line, dim: true }]),
1085 ...(oldest === null ? [] : [sep, { text: shellAge(at - oldest), color: AMBER, bold: true }]),
1086 ...(work.agents === 0 ? [] : [sep, { text: `${work.agents} ${work.agents === 1 ? 'agent' : 'agents'}`, color: 'cyan' }]),
1087 ...sent,
1088 ]
1089 }
1090 default:
1091 return [
1092 { text: '○ idle', dim: true },
1093 ...(now.lastTurnMs === null ? [] : [sep, { text: 'last turn ', dim: true }, { text: elapsed(now.lastTurnMs) }]),
1094 ...(now.endedAt === null ? [] : [sep, { text: `ended ${duration(at - now.endedAt) || '<1m'} ago`, dim: true }]),
1095 ]
1096 }
1097}
1098
1099/** What the dense pane's rows draw from. */
1100export type DenseInput = {
1101 email: string | null
1102 billing: string | null
1103 remote: Run[]
1104 /** The context against the handoff budget. */
1105 context: { tokens: number; limit: number } | null
1106 /** The breakdown's estimate, shown while there is no context reading. */
1107 estimate: number | null
1108 turnsLeft: number | null
1109 limits: readonly { kind: string; percent: number }[]
1110 shells: readonly { command: string; startedAt: number }[]
1111 /** The running subagents' seats, short names. */
1112 agents: readonly string[]
1113 run: { done: number; total: number; current: string | null } | null
1114 todos: { done: number; total: number; current: string | null } | null
1115 repo: RepoState | null
1116 refused: number
1117 now: number
1118}
1119
1120/** A dense row's label, padded to five cells so the rows line up. */
1121export function denseLabel(label: string, color?: string): Run {
1122 return { text: label + ' '.repeat(Math.max(0, 5 - cellWidth(label))), color, dim: color === undefined }
1123}
1124
1125/** One dense pane row for a section, or null when the section has nothing to show. */
1126export function denseLine(section: Section, input: DenseInput): Run[] | null {
1127 const sep: Run = { text: ' · ', dim: true }
1128 const join = (parts: Run[][]): Run[] => parts.filter(part => part.length > 0).flatMap((part, index) => (index === 0 ? part : [sep, ...part]))
1129 switch (section) {
1130 case 'account':
1131 if (input.email === null) return null
1132 return [denseLabel('acct'), ...join([[{ text: input.email, bold: true }], input.billing === null ? [] : [{ text: input.billing, dim: true }], input.remote.length === 0 ? [] : [{ text: 'Remote ', dim: true }, ...input.remote]])]
1133 case 'context': {
1134 if (input.context === null) return [denseLabel('ctx'), { text: input.estimate === null ? 'no reading yet' : `≈${kTokens(input.estimate)} estimated`, dim: true }]
1135 const { tokens, limit } = input.context
1136 const percent = (tokens / limit) * 100
1137 const color = rampColor(percent)
1138 const turns = input.turnsLeft
1139 return [
1140 denseLabel('ctx'),
1141 { text: bar(tokens / limit, 10), color },
1142 { text: ` ${Math.round(percent)}%`, color, bold: true },
1143 { text: ` ${kTokens(tokens)}/${kTokens(limit)}`, dim: true },
1144 ...(turns === null ? [] : [sep, { text: `≈${turns} ${turns === 1 ? 'turn' : 'turns'}`, color: turns <= 3 ? AMBER : undefined, dim: turns > 3 }]),
1145 ]
1146 }
1147 case 'usage': {
1148 const shown = input.limits.filter(limit => limit.kind === 'five_hour' || limit.kind === 'seven_day')
1149 if (shown.length === 0) return [denseLabel('lim'), { text: 'no reading yet', dim: true }]
1150 return [
1151 denseLabel('lim'),
1152 ...join(
1153 shown.map(limit => {
1154 const color = rampColor(limit.percent)
1155 return [{ text: `${limitLabel(limit.kind)} `, dim: true }, { text: bar(limit.percent / 100, 6), color }, { text: ` ${Math.round(limit.percent)}%`, color, bold: true }]
1156 }),
1157 ),
1158 ]
1159 }
1160 case 'shells': {
1161 if (input.shells.length === 0) return null
1162 const newest = input.shells.reduce((last, shell) => (shell.startedAt > last.startedAt ? shell : last))
1163 const count = input.shells.length
1164 return [
1165 denseLabel('$', 'yellow'),
1166 ...join([
1167 [{ text: `${count} ${count === 1 ? 'shell' : 'shells'}`, color: 'yellow' }],
1168 [{ text: commandExcerpt(newest.command, 40) }].filter(run => run.text !== ''),
1169 [{ text: shellAge(input.now - newest.startedAt), dim: true }],
1170 ]),
1171 ]
1172 }
1173 case 'agents':
1174 if (input.agents.length === 0) return null
1175 return [denseLabel('agt', 'cyan'), ...join([[{ text: `${input.agents.length} running`, color: 'cyan' }], [{ text: input.agents.join(', ') }]])]
1176 case 'plan': {
1177 const plan = input.run ?? input.todos
1178 if (plan === null || plan.total === 0) return null
1179 return [
1180 denseLabel('plan', 'magenta'),
1181 ...join([[{ text: bar(plan.done / plan.total, Math.min(plan.total, 10)), color: 'magenta' }, { text: ` ${plan.done}/${plan.total}`, bold: true }], plan.current === null ? [] : [{ text: plan.current }]]),
1182 ]
1183 }
1184 case 'repo': {
1185 if (input.repo === null) return null
1186 const r = input.repo
1187 const branch: Run[] = [{ text: r.branch, color: 'magenta', bold: true }, ...(r.ahead > 0 ? [{ text: ` ↑${r.ahead}`, color: 'magenta' }] : []), ...(r.behind > 0 ? [{ text: ` ↓${r.behind}`, color: 'magenta' }] : [])]
1188 return [denseLabel('⎇', 'magenta'), ...join([branch, r.changed > 0 ? [{ text: `~${r.changed} changed`, color: 'yellow' }] : [], r.untracked > 0 ? [{ text: `?${r.untracked}`, dim: true }] : []])]
1189 }
1190 case 'guard':
1191 return input.refused === 0 ? null : [denseLabel('grd'), { text: `✗ ${input.refused} refused`, color: 'red' }]
1192 }
1193}
1194
1195// Commands that start something meant to run until stopped: a dev server, a
1196// watcher, a followed log.
1197const SERVICES = [
1198 /^(npm|pnpm|yarn|bun)( run)? (dev|start|serve|preview|watch)(:(?!build\b)|\s|$)/,
1199 /^((npx|bunx|pnpm exec) )?(vite(?! build)(\s|$)|next (dev|start)|nuxt dev|astro dev|webpack serve|ng serve|nodemon)/,
1200 /^python3? -m http\.server/,hooks/cockpit/config.ts 282 lines1import RAW, { isValidValue } from '../../config/schema.mjs'
2
3/** A setting's value as the settings file holds it. */
4export type SettingValue = boolean | number | string
5
6/** One setting as config/schema.mjs declares it. */
7export type SettingSpec = {
8 type: 'boolean' | 'number' | 'enum' | 'string'
9 default: SettingValue
10 group: string
11 /** The row's label in the settings view. */
12 label: string
13 title: string
14 description: string
15 target: 'dforge' | 'claude'
16 env?: string
17 min?: number
18 max?: number
19 step?: number
20 values?: readonly string[]
21 /** How a number reads: thousands of tokens, a percentage, seconds or minutes. */
22 unit?: 'k' | '%' | 's' | 'min'
23 /** The word shown at 0. */
24 zero?: string
25 /** The smallest value allowed other than 0, on a number where 0 means unset. */
26 floor?: number
27 enforcedBy?: 'script' | 'skill'
28 /** Written even at its default, because an absent key means something else. */
29 keepDefault?: boolean
30}
31
32export type Schema = Readonly<Record<string, SettingSpec>>
33
34/** ~/.dforge/config.json as parsed: schema keys and the reserved darkmem object. */
35export type SettingsFile = Record<string, unknown>
36
37export const SCHEMA: Schema = RAW as unknown as Schema
38
39/** Whether a value fits a setting exactly as the file must hold it. */
40export function isValidSetting(spec: SettingSpec, value: unknown): value is SettingValue {
41 return isValidValue(spec, value)
42}
43
44/** A value as an environment variable spells it; undefined when it does not fit. */
45export function settingFromText(spec: SettingSpec, text: string): SettingValue | undefined {
46 const value =
47 spec.type === 'boolean'
48 ? text === 'true'
49 ? true
50 : text === 'false'
51 ? false
52 : undefined
53 : spec.type === 'number'
54 ? text.trim() === ''
55 ? undefined
56 : Number(text)
57 : text
58 return value !== undefined && isValidSetting(spec, value) ? value : undefined
59}
60
61/**
62 * Every setting in force. A dforge key: the environment variable it names, then the file, then the default.
63 * A Claude key: Claude's own settings as the engine merged them, then the default.
64 */
65export function resolveSettings(
66 schema: Schema,
67 file: SettingsFile,
68 env: Readonly<Record<string, string | undefined>>,
69 claude: Readonly<Record<string, unknown>> = {},
70): Record<string, SettingValue> {
71 const values: Record<string, SettingValue> = {}
72 for (const [key, spec] of Object.entries(schema)) {
73 if (spec.target === 'claude') {
74 const held = claude[key]
75 values[key] = isValidSetting(spec, held) ? held : spec.default
76 continue
77 }
78 const raw = spec.env === undefined ? undefined : env[spec.env]
79 const fromEnv = raw === undefined || raw === '' ? undefined : settingFromText(spec, raw)
80 values[key] = fromEnv ?? storedSetting(key, spec, file)
81 }
82 return values
83}
84
85function storedSetting(key: string, spec: SettingSpec, file: SettingsFile): SettingValue {
86 const stored = file[key]
87 if (isValidSetting(spec, stored)) return stored
88 if (!Object.prototype.hasOwnProperty.call(file, key)) return spec.default
89 const list = listNames(spec, stored)
90 return list !== undefined && list.known.length > 0 ? list.known.join(',') : invalidStored(key, spec)
91}
92
93// A list naming one unknown name keeps the names it knows: falling back to
94// the default would bring back every name the empty list stands for.
95function listNames(spec: SettingSpec, value: unknown): { known: string[]; unknown: string[] } | undefined {
96 const allowed = spec.values
97 if (spec.type !== 'string' || allowed === undefined || typeof value !== 'string') return undefined
98 const names = value.split(/[\s,]+/).filter(name => name !== '')
99 return {
100 known: names.map(name => name.toLowerCase()).filter(name => allowed.includes(name)),
101 unknown: names.filter(name => !allowed.includes(name.toLowerCase())),
102 }
103}
104
105// A notifyOn the person wrote but mistyped ("off") sends nothing: the default
106// set would post to Discord what they meant to keep private.
107function invalidStored(key: string, spec: SettingSpec): SettingValue {
108 return key === 'notifyOn' ? 'none' : spec.default
109}
110
111/** One line for each environment or file value that does not fit its setting and is therefore ignored. */
112export function settingProblems(
113 schema: Schema,
114 file: SettingsFile,
115 env: Readonly<Record<string, string | undefined>>,
116): string[] {
117 const problems: string[] = []
118 for (const [key, spec] of Object.entries(schema)) {
119 const raw = spec.env === undefined ? undefined : env[spec.env]
120 if (raw !== undefined && raw !== '' && settingFromText(spec, raw) === undefined) {
121 problems.push(`${spec.env}=${raw} is not a valid ${key}; ignored`)
122 }
123 if (spec.target === 'dforge' && Object.prototype.hasOwnProperty.call(file, key) && !isValidSetting(spec, file[key])) {
124 const list = listNames(spec, file[key])
125 if (list !== undefined && list.known.length > 0) {
126 problems.push(`${key} in ~/.dforge/config.json: unknown ${list.unknown.join(', ')} ignored`)
127 continue
128 }
129 const reading = invalidStored(key, spec)
130 problems.push(`${key} in ~/.dforge/config.json is not valid; ${reading === spec.default ? 'ignored' : `read as ${String(reading)}`}`)
131 }
132 }
133 return problems
134}
135
136/** The file after one setting is saved: a value equal to its default is dropped, everything else kept. */
137export function settingWritten(schema: Schema, file: SettingsFile, key: string, value: SettingValue): SettingsFile {
138 const spec = schema[key]
139 if (spec === undefined) throw new Error(`unknown setting ${key}`)
140 if (spec.target === 'claude') throw new Error(`${key} is stored in Claude's settings file`)
141 if (!isValidSetting(spec, value)) throw new Error(`${String(value)} is not a valid ${key}`)
142 const next: SettingsFile = { ...file }
143 if (value === spec.default && !spec.keepDefault) delete next[key]
144 else next[key] = value
145 return next
146}
147
148/** The first run's file: dr-cockpit's options that differ from their defaults. */
149export function importedSettings(schema: Schema, claudeSettings: unknown): SettingsFile {
150 const file: SettingsFile = {}
151 const options = objectAt(objectAt(objectAt(claudeSettings, 'pluginConfigs'), 'dr-cockpit@darkraise'), 'options')
152 for (const [key, spec] of Object.entries(schema)) {
153 const value = options?.[key]
154 if (spec.target === 'dforge' && isValidSetting(spec, value) && value !== spec.default) file[key] = value
155 }
156 return file
157}
158
159/** One settings-view row, drawn from the schema and the value in force. */
160export type ViewRow = {
161 key: string
162 group: string
163 label: string
164 description: string
165 kind: 'toggle' | 'choices' | 'stepper' | 'text'
166 value: SettingValue
167 /** The value as its row reads, through the unit and the zero word. */
168 shown: string
169 /** The default the same way, for the line under a changed row. */
170 shownDefault: string
171 values: readonly string[]
172 isChanged: boolean
173 /** What forces the value from outside the file; such a row draws no controls. */
174 lockedBy: string | null
175}
176
177/** The view's tabs: the schema's groups in the order they first appear. */
178export function tabsOf(schema: Schema): string[] {
179 return [...new Set(Object.values(schema).map(spec => spec.group))]
180}
181
182/** A value as its row reads: on or off, a number through its unit and zero word, anything else as text. */
183export function shownValue(spec: SettingSpec, value: SettingValue): string {
184 if (spec.type === 'boolean') return value === true ? 'on' : 'off'
185 if (spec.type !== 'number' || typeof value !== 'number') return String(value)
186 if (value === 0 && spec.zero !== undefined) return spec.zero
187 if (spec.unit === 'k') return `${value / 1000}k`
188 if (spec.unit === 'min') return `${value} min`
189 return `${value}${spec.unit ?? ''}`
190}
191
192/** A number typed into a row: whole, with a trailing k for thousands; undefined when it does not fit the setting. */
193export function typedNumber(spec: SettingSpec, text: string): number | undefined {
194 const typed = text.trim().toLowerCase()
195 // At most three significant decimals before k, so rounding only removes floating-point error (1.001 * 1000 is 1000.9999999999999).
196 const value = /^\d+(\.\d{1,3}0*)?k$/.test(typed) ? Math.round(Number(typed.slice(0, -1)) * 1000) : /^\d+$/.test(typed) ? Number(typed) : Number.NaN
197 return Number.isInteger(value) && isValidSetting(spec, value) ? value : undefined
198}
199
200// The mod's runtime is not promised a locale, so the digits are grouped by hand.
201function grouped(n: number): string {
202 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
203}
204
205/** What a typed number must be, for the row's refusal. */
206export function rangeText(spec: SettingSpec): string {
207 const to = grouped(spec.max ?? Number.MAX_SAFE_INTEGER)
208 return spec.floor === undefined
209 ? `Enter a whole number from ${grouped(spec.min ?? 0)} to ${to}`
210 : `Enter 0 or a whole number from ${grouped(spec.floor)} to ${to}`
211}
212
213/** One press of − (-1) or + (1): the next multiple of the step, kept in range; a floor is entered from 0 and left to 0. */
214export function steppedSetting(spec: SettingSpec, value: number, direction: 1 | -1): number {
215 const step = spec.step ?? 1
216 const next = direction > 0 ? Math.floor(value / step) * step + step : Math.ceil(value / step) * step - step
217 if (spec.floor !== undefined) {
218 if (direction > 0 && value < spec.floor) return spec.floor
219 if (direction < 0 && next < spec.floor) return 0
220 }
221 return Math.min(spec.max ?? Number.MAX_SAFE_INTEGER, Math.max(spec.min ?? 0, next))
222}
223
224/** A setting's row. A locked row is never "changed": the file is not what holds its value. */
225export function rowOf(key: string, spec: SettingSpec, value: SettingValue, lockedBy: string | null = null): ViewRow {
226 return {
227 key,
228 group: spec.group,
229 label: spec.label,
230 description: spec.description,
231 kind: spec.type === 'boolean' ? 'toggle' : spec.type === 'enum' ? 'choices' : spec.type === 'number' ? 'stepper' : 'text',
232 value,
233 shown: shownValue(spec, value),
234 shownDefault: shownValue(spec, spec.default),
235 values: spec.values ?? [],
236 isChanged: lockedBy === null && value !== spec.default,
237 lockedBy,
238 }
239}
240
241/** A group's rows in schema order. */
242export function rowsOf(
243 schema: Schema,
244 values: Readonly<Record<string, SettingValue>>,
245 group: string,
246 locks: Readonly<Record<string, string>> = {},
247): ViewRow[] {
248 return Object.entries(schema)
249 .filter(([, spec]) => spec.group === group)
250 .map(([key, spec]) => rowOf(key, spec, values[key] ?? spec.default, locks[key] ?? null))
251}
252
253/** The keys an environment variable forces, each with the words its row shows. */
254export function envLocks(schema: Schema, env: Readonly<Record<string, string | undefined>>): Record<string, string> {
255 const locks: Record<string, string> = {}
256 for (const [key, spec] of Object.entries(schema)) {
257 const raw = spec.env === undefined ? undefined : env[spec.env]
258 if (spec.env !== undefined && raw !== undefined && raw !== '' && settingFromText(spec, raw) !== undefined) locks[key] = `set by ${spec.env}`
259 }
260 return locks
261}
262
263/**
264 * The Claude keys a project or managed settings file overrides: the merged
265 * value is not the user file's. A merged value that does not fit its setting
266 * is named, since the row shows the default in its place.
267 */
268export function claudeLocks(schema: Schema, merged: Readonly<Record<string, unknown>>, user: Readonly<Record<string, unknown>>): Record<string, string> {
269 const locks: Record<string, string> = {}
270 for (const [key, spec] of Object.entries(schema)) {
271 if (spec.target !== 'claude' || merged[key] === undefined || merged[key] === user[key]) continue
272 locks[key] = isValidSetting(spec, merged[key]) ? 'set by a project or managed settings file' : `${JSON.stringify(merged[key])} from a project or managed file, not valid`
273 }
274 return locks
275}
276
277function objectAt(value: unknown, key: string): Record<string, unknown> | undefined {
278 if (value === null || typeof value !== 'object') return undefined
279 const inner = (value as Record<string, unknown>)[key]
280 return inner !== null && typeof inner === 'object' && !Array.isArray(inner) ? (inner as Record<string, unknown>) : undefined
281}
282hooks/cockpit/native.tsx 73 lines1import type { EngineInterface, RenderChildren } from 'claude-code'
2
3import { themeColor } from './lib'
4import { CELL_PX } from './svg'
5import type { PanelLine, PanelRun } from './svg'
6
7// The engine refuses to pass `$` across an import, so this module draws from
8// the surface's element table, as settings-view.tsx does.
9type Elements = ReturnType<EngineInterface['ui']['resolve']>
10
11/** One run as the desktop's own text: a theme color, dim or faint dimmed, bold semibold, a chip on a soft pill. */
12export function nativeRun(table: Elements, key: string, run: PanelRun): RenderChildren {
13 const { Text } = table
14 return (
15 <Text
16 key={key}
17 color={themeColor(run.color)}
18 dimColor={run.dim || run.faint ? true : undefined}
19 weight={run.bold ? 'semibold' : undefined}
20 mono={run.mono ? true : undefined}
21 size={run.chip ? 'caption' : undefined}
22 backgroundColor={run.chip ? 'subtle' : undefined}
23 >
24 {run.text}
25 </Text>
26 )
27}
28
29/** One panel row as the desktop's own elements, keyed `key`; a row's pixel indent becomes cells. */
30export function nativeLine(table: Elements, key: string, line: PanelLine): RenderChildren {
31 const { Box, Text } = table
32 const Svg = 'Svg' in table ? table.Svg : undefined
33 const runs = (part: string, list: readonly PanelRun[]) => list.map((run, index) => nativeRun(table, `${key}-${part}-${index}`, run))
34 if (line.kind === 'picture') return Svg !== undefined && <Svg key={key} source={line.svg} alt={line.alt ?? ''} />
35 if (line.kind === 'chips') {
36 return (
37 <Box key={key} flexDirection="row" flexWrap="wrap" gap="xs">
38 {line.chips.map((chip, index) => nativeRun(table, `${key}-${index}`, { ...chip, chip: true }))}
39 </Box>
40 )
41 }
42 if (line.kind === 'badge') {
43 return (
44 <Box key={key} flexDirection="row" alignItems="center" columnGap="sm">
45 {Svg !== undefined && <Svg key={`${key}-svg`} source={line.svg} alt="" />}
46 <Box key={`${key}-lines`} flexDirection="column" flexShrink={1}>
47 {line.lines.map((one, row) => (
48 <Text key={`${key}-${row}`} size={row === 0 ? undefined : 'footnote'} wrap="truncate-end">
49 {runs(String(row), one)}
50 </Text>
51 ))}
52 </Box>
53 </Box>
54 )
55 }
56 const size = line.small ? 'footnote' : undefined
57 const right = line.right ?? []
58 return (
59 <Box key={key} flexDirection="row" justifyContent="space-between" columnGap="sm" paddingLeft={line.indent === undefined ? undefined : Math.round(line.indent / CELL_PX)}>
60 <Box key={`${key}-left`} flexGrow={1} flexShrink={1}>
61 <Text size={size} wrap={line.wrap ? 'wrap' : 'truncate-end'}>
62 {runs('left', line.left)}
63 </Text>
64 </Box>
65 {right.length > 0 && (
66 <Box key={`${key}-right`} flexShrink={0}>
67 <Text size={size}>{runs('right', right)}</Text>
68 </Box>
69 )}
70 </Box>
71 )
72}
73hooks/cockpit/settings-view.tsx 267 lines1import type { EngineInterface, RenderChildren, RenderElement } from 'claude-code'
2
3import type { SettingValue, ViewRow } from './config'
4
5// The engine refuses to pass `$` across an import, so this module draws from
6// the surface's element table and reports every press through a callback.
7type Elements = ReturnType<EngineInterface['ui']['resolve']>
8
9const LABEL_WIDTH = 16
10const INDENT = 2
11const CARD = '#8080800f'
12const ACCENT = 'permission'
13
14/** What the settings view shows. */
15export type SettingsModel = {
16 tabs: readonly string[]
17 tab: string
18 /** Drawn dim beside the open group's name. */
19 hint: string | null
20 rows: readonly ViewRow[]
21 isHelpShown: boolean
22 /** The key whose value is being typed, and why the last entry was refused. */
23 editing: string | null
24 error: string | null
25 isConfirmingReset: boolean
26 /** Whether the surface has a text field. */
27 canType: boolean
28 /** The desktop app: its own dropdowns and fields, spaced by its own scale. */
29 isDesktop: boolean
30 /** Saved or environment values the settings in force ignored. */
31 problems: readonly string[]
32 /** Controls the caller draws itself, by key, in place of the schema's. */
33 custom: Readonly<Record<string, RenderChildren>>
34 /** Drawn above the open group's rows. */
35 top: RenderChildren
36}
37
38/** What each press does. */
39export type SettingsActions = {
40 openTab: (tab: string) => unknown
41 set: (key: string, value: SettingValue) => unknown
42 step: (key: string, direction: 1 | -1) => unknown
43 edit: (key: string | null) => unknown
44 submit: (key: string, text: string) => unknown
45 reset: (key: string) => unknown
46 toggleHelp: () => unknown
47 askResetGroup: () => unknown
48 resetGroup: () => unknown
49 cancelReset: () => unknown
50 back: () => unknown
51}
52
53/** The cockpit's settings: a tab for each group, the open group's rows, and the footer. */
54export function drawSettings(table: Elements, model: SettingsModel, act: SettingsActions): RenderElement {
55 const { Box, Button, Text } = table
56 const Input = 'Input' in table ? table.Input : undefined
57 const Select = model.isDesktop && 'Select' in table ? table.Select : undefined
58 const isNative = Select !== undefined
59 const press = (key: string, label: string, onPress: () => unknown, look: { hotkey?: string; isMain?: boolean; isOff?: boolean } = {}) => (
60 <Button key={key} label={label} hotkey={look.hotkey} variant={look.isMain ? 'primary' : undefined} dimColor={look.isOff} onPress={onPress} />
61 )
62
63 const control = (row: ViewRow): RenderChildren => {
64 if (row.lockedBy !== null) {
65 return [
66 <Text key={`set-${row.key}-value`} bold>{`${row.shown} `}</Text>,
67 <Text key={`set-${row.key}-lock`} dimColor>
68 {row.lockedBy}
69 </Text>,
70 ]
71 }
72 if (row.kind === 'toggle') {
73 const isOn = row.value === true
74 return press(`set-${row.key}-toggle`, isOn ? '● On' : '○ Off', () => act.set(row.key, !isOn), { isMain: isOn, isOff: !isOn })
75 }
76 if (row.kind === 'choices') {
77 return row.values.map(choice =>
78 press(`set-${row.key}-${choice}`, choice, () => act.set(row.key, choice), { isMain: row.value === choice, isOff: row.value !== choice }),
79 )
80 }
81 if (model.editing === row.key && Input !== undefined) {
82 return [
83 <Input key={`set-${row.key}-input`} placeholder={String(row.value)} submitLabel="save" autoFocus onSubmit={(text: string) => act.submit(row.key, text)} />,
84 press(`set-${row.key}-cancel`, 'Cancel', () => act.edit(null)),
85 ]
86 }
87 const edit = model.canType && press(`set-${row.key}-edit`, 'Edit', () => act.edit(row.key), { isOff: true })
88 if (row.kind === 'stepper') {
89 return [
90 <Text key={`set-${row.key}-value`} bold>
91 {row.shown.padEnd(7)}
92 </Text>,
93 press(`set-${row.key}-less`, '−', () => act.step(row.key, -1)),
94 press(`set-${row.key}-more`, '+', () => act.step(row.key, 1)),
95 edit,
96 ]
97 }
98 return [
99 <Text key={`set-${row.key}-value`} dimColor={row.shown === ''}>{`${row.shown || '(empty)'} `}</Text>,
100 edit || (
101 <Text key={`set-${row.key}-cli`} dimColor>
102 {`dforge-config set ${row.key}`}
103 </Text>
104 ),
105 ]
106 }
107
108 // Under a row: its description while help is shown, and its default with a Reset once it differs.
109 const help = (row: ViewRow): RenderChildren =>
110 model.isHelpShown && (
111 <Box key={`set-${row.key}-help`} paddingLeft={isNative ? undefined : INDENT}>
112 <Text size={isNative ? 'footnote' : undefined} dimColor wrap="wrap">
113 {row.description}
114 </Text>
115 </Box>
116 )
117
118 const defaultLine = (row: ViewRow): RenderChildren =>
119 row.isChanged && (
120 <Box key={`set-${row.key}-default`} flexDirection="row" alignItems={isNative ? 'center' : undefined} columnGap={isNative ? 'sm' : 1} paddingLeft={isNative ? undefined : INDENT}>
121 <Text key={`set-${row.key}-default-text`} size={isNative ? 'footnote' : undefined} dimColor>
122 {row.shownDefault === '' ? 'changed from the default' : `default ${row.shownDefault}`}
123 </Text>
124 {press(`set-${row.key}-reset`, 'Reset', () => act.reset(row.key))}
125 </Box>
126 )
127
128 const mark = (row: ViewRow): RenderChildren =>
129 row.isChanged && (
130 <Box key={`set-${row.key}-mark`}>
131 <Text color="yellow" bold>*</Text>
132 </Box>
133 )
134
135 const drawRow = (row: ViewRow): RenderChildren => (
136 <Box key={`set-${row.key}-block`} flexDirection="column">
137 {/* Controls that overflow wrap in the value column, not under the label. */}
138 <Box key={`set-${row.key}`} flexDirection="row" columnGap={1}>
139 <Box key={`set-${row.key}-label`} width={LABEL_WIDTH} flexShrink={0}>
140 <Text>{row.label}</Text>
141 </Box>
142 <Box key={`set-${row.key}-controls`} flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} flexShrink={1}>
143 {model.custom[row.key] ?? control(row)}
144 {mark(row)}
145 </Box>
146 </Box>
147 {model.editing === row.key && model.error !== null && (
148 <Text key={`set-${row.key}-error`} color="red">{`${' '.repeat(INDENT)}✗ ${model.error}`}</Text>
149 )}
150 {help(row)}
151 {defaultLine(row)}
152 </Box>
153 )
154
155 // The desktop's own controls: a dropdown for a switch or a choice, an open
156 // field for a number or text, a refused entry's reason below its field.
157 const nativeControl = (row: ViewRow): RenderChildren => {
158 if (Select === undefined || row.lockedBy !== null) return control(row)
159 if (row.kind === 'toggle') {
160 return (
161 <Select
162 key={`set-${row.key}-select`}
163 options={[
164 { value: 'true', label: 'On' },
165 { value: 'false', label: 'Off' },
166 ]}
167 value={String(row.value === true)}
168 onSelect={(value: string) => act.set(row.key, value === 'true')}
169 />
170 )
171 }
172 if (row.kind === 'choices') {
173 return <Select key={`set-${row.key}-select`} options={row.values.map(value => ({ value }))} value={String(row.value)} onSelect={(value: string) => act.set(row.key, value)} />
174 }
175 const field = Input !== undefined && (
176 <Input key={`set-${row.key}-input`} label={row.label} value={String(row.value)} submitLabel="save" onSubmit={(text: string) => act.submit(row.key, text)} />
177 )
178 if (row.kind === 'stepper') return [field, press(`set-${row.key}-less`, '−', () => act.step(row.key, -1)), press(`set-${row.key}-more`, '+', () => act.step(row.key, 1))]
179 return field
180 }
181
182 // A desktop row: its label over its description and its control.
183 const drawNativeRow = (row: ViewRow): RenderChildren => (
184 <Box key={`set-${row.key}-block`} flexDirection="column" gap="xs">
185 <Box key={`set-${row.key}-label`} flexDirection="row" columnGap="sm">
186 <Text weight="medium">{row.label}</Text>
187 {mark(row)}
188 </Box>
189 {help(row)}
190 <Box key={`set-${row.key}-controls`} flexDirection="row" flexWrap="wrap" alignItems="center" columnGap="sm">
191 {model.custom[row.key] ?? nativeControl(row)}
192 </Box>
193 {model.editing === row.key && model.error !== null && (
194 <Box key={`set-${row.key}-error`}>
195 <Text color="error">{`✗ ${model.error}`}</Text>
196 </Box>
197 )}
198 {defaultLine(row)}
199 </Box>
200 )
201
202 const changed = model.rows.filter(row => row.isChanged).length
203 return (
204 <Box flexDirection="column" gap={isNative ? 'md' : undefined}>
205 <Text bold>Settings</Text>
206 <Box key="settings-tabs" flexDirection="row" flexWrap="wrap" columnGap={isNative ? 'sm' : 1} rowGap={isNative ? 'sm' : undefined}>
207 {/* The terminal marks the open tab by more than its color. */}
208 {model.tabs.map((tab, index) =>
209 press(`settings-tab-${tab}`, tab === model.tab && !isNative ? `▸ ${tab}` : tab, () => act.openTab(tab), { hotkey: index < 9 ? String(index + 1) : undefined, isMain: tab === model.tab, isOff: tab !== model.tab }),
210 )}
211 </Box>
212 <Box
213 key={`set-group-${model.tab}`}
214 flexDirection="column"
215 borderStyle="round"
216 borderColor="subtle"
217 // Some terminals drop the fill's alpha and draw it solid grey, under which dim text is lost.
218 backgroundColor={isNative ? CARD : undefined}
219 paddingX={isNative ? 'md' : 1}
220 paddingY={isNative ? 'sm' : undefined}
221 gap={isNative ? 'md' : model.isHelpShown ? 1 : undefined}
222 >
223 <Box key="set-group-head" flexDirection="row" alignItems="center" columnGap={isNative ? 'sm' : 1}>
224 {isNative ? (
225 <Text size="title" weight="semibold" color={ACCENT}>
226 {model.tab}
227 </Text>
228 ) : (
229 <Text bold color={ACCENT}>
230 {model.tab}
231 </Text>
232 )}
233 {model.hint !== null && [
234 <Text key="set-group-hint-dot" dimColor>
235 ·
236 </Text>,
237 <Text key="set-group-hint" dimColor>
238 {model.hint}
239 </Text>,
240 ]}
241 <Text key="set-group-changed-dot" dimColor>
242 ·
243 </Text>
244 <Text key="set-group-changed" dimColor>
245 {changed === 0 ? 'all defaults' : `${changed} changed`}
246 </Text>
247 </Box>
248 {model.top}
249 {model.rows.map(isNative ? drawNativeRow : drawRow)}
250 </Box>
251 {model.problems.map((problem, index) => (
252 <Text key={`set-problem-${index}`} color="yellow">
253 ⚠ {problem}
254 </Text>
255 ))}
256 <Box key="settings-actions" flexDirection="row" flexWrap="wrap" columnGap={isNative ? 'sm' : 1} rowGap={isNative ? 'sm' : undefined}>
257 {press('settings-back', 'Back', act.back, { hotkey: 'b' })}
258 {press('settings-help', model.isHelpShown ? 'Hide help' : 'Show help', act.toggleHelp, { hotkey: 'i' })}
259 {model.isConfirmingReset
260 ? [press('settings-reset-confirm', 'Confirm reset', act.resetGroup, { hotkey: 'r', isMain: true }), press('settings-reset-cancel', 'Cancel', act.cancelReset)]
261 : changed > 0 && press('settings-reset', 'Reset group', act.askResetGroup, { hotkey: 'r' })}
262 </Box>
263 <Text dimColor>Saved to ~/.dforge/config.json{model.tab === 'Context' ? "; the auto-compact rows save to Claude's settings file" : ''}.</Text>
264 </Box>
265 )
266}
267hooks/cockpit/notify.ts 143 lines1// Discord notifications: what each event sends, kept pure so tests reach it.
2// register.tsx holds the sender, since only it may reach the host.
3export const NOTIFY_KINDS = ['ask', 'question', 'done', 'error', 'blocked', 'budget', 'limit', 'task', 'session', 'agent'] as const
4export type NotifyKind = (typeof NOTIFY_KINDS)[number]
5
6/** The kinds that send unless the settings say otherwise. */
7export const DEFAULT_NOTIFY_ON: readonly NotifyKind[] = ['ask', 'question', 'done', 'error', 'blocked', 'budget', 'limit']
8
9// The kinds that need the person: they mention them, the rest post silently.
10const PINGS: readonly NotifyKind[] = ['ask', 'question', 'error', 'blocked']
11const COLORS: Record<NotifyKind, number> = {
12 ask: 0xffaf00,
13 question: 0xffaf00,
14 done: 0x57d787,
15 task: 0x57d787,
16 error: 0xff5f5f,
17 blocked: 0xff5f5f,
18 budget: 0x5f87ff,
19 limit: 0x5f87ff,
20 session: 0x5f87ff,
21 agent: 0x5f87ff,
22}
23// Discord's flag for a message that notifies nobody (`@silent`).
24const SUPPRESS_NOTIFICATIONS = 4096
25const WEBHOOK = /^https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api\/webhooks\/(\d+)\/([\w-]+)\/?$/
26
27/** The notifyOn setting, "ask, done" → ['ask', 'done']: known kinds, each once; empty is the default set, and text naming no kind ("none") is none. */
28export function notifyKindsFrom(text: string): NotifyKind[] {
29 if (text.trim() === '') return [...DEFAULT_NOTIFY_ON]
30 const names = text
31 .split(/[\s,]+/)
32 .map(name => name.trim().toLowerCase())
33 .filter((name): name is NotifyKind => (NOTIFY_KINDS as readonly string[]).includes(name))
34 return [...new Set(names)]
35}
36
37/** The notifyOn setting after turning one kind on or off; the default set is the empty setting. */
38export function notifyKindsToggled(on: readonly NotifyKind[], kind: NotifyKind): string {
39 const next = on.includes(kind) ? on.filter(one => one !== kind) : [...on, kind]
40 const ordered = NOTIFY_KINDS.filter(one => next.includes(one))
41 const isDefault = ordered.length === DEFAULT_NOTIFY_ON.length && ordered.every(one => DEFAULT_NOTIFY_ON.includes(one))
42 // Every kind turned off is spelt out, so it does not read back as the default.
43 return isDefault ? '' : ordered.length === 0 ? 'none' : ordered.join(',')
44}
45
46/** Whether a URL is a Discord webhook, the only kind the cockpit posts to. */
47export function isDiscordWebhook(url: string): boolean {
48 return WEBHOOK.test(url.trim())
49}
50
51/** A webhook as the settings show it: the host, the id's start, the token hidden. */
52export function maskWebhook(url: string): string {
53 const match = WEBHOOK.exec(url.trim())
54 if (!match) return '••••'
55 return `discord.com/…/${(match[1] ?? '').slice(0, 4)}…/••••`
56}
57
58/** A Discord user id, digits only, as the mention setting takes it; else null. */
59export function mentionId(text: string): string | null {
60 const id = text.trim().replace(/^<@!?/, '').replace(/>$/, '')
61 return /^\d{5,25}$/.test(id) ? id : null
62}
63
64const SECRETS: [RegExp, string][] = [
65 [/\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, '[token]'],
66 [/\b(sk-[A-Za-z0-9_-]{16,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})/g, '[token]'],
67 [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [token]'],
68 // A key may end its own quoting (JSON's "password": or an escaped \"password\":) before the separator.
69 [/\b(\w*(?:api[_-]?key|token|secret|password|passwd|pwd)\\?["']?\s*[=:]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)/gi, '$1[hidden]'],
70 [/https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api\/webhooks\/\S+/g, '[webhook]'],
71]
72
73/** Masks the secrets text may carry before it leaves the machine. */
74export function redact(text: string): string {
75 return SECRETS.reduce((out, [pattern, mask]) => out.replace(pattern, mask), text)
76}
77
78/** Text cut to `max` characters on one line or a few, redacted. */
79export function excerpt(text: string, max: number): string {
80 const clean = redact(text.replace(/\r/g, '').trim())
81 const chars = [...clean]
82 return chars.length <= max ? clean : `${chars.slice(0, Math.max(0, max - 1)).join('').trimEnd()}…`
83}
84
85/** One event to send: its kind, a title, and what to say under it. */
86export type NotifyEvent = {
87 kind: NotifyKind
88 title: string
89 /** The one thing to read; full detail only, unless `isPlain`. */
90 detail?: string
91 /** Shown whatever the detail setting: carries no session text. */
92 isPlain?: boolean
93 fields?: { name: string; value: string }[]
94}
95
96/** Where a message came from, for its header and footer. */
97export type NotifyPlace = {
98 host: string | null
99 repo: string | null
100 branch: string | null
101 tmux: string | null
102 account: string | null
103}
104
105export type NotifyStyle = { mention: string | null; isBrief: boolean; now: number }
106
107/** The webhook body for one event, every text in it redacted. */
108export function discordBody(event: NotifyEvent, place: NotifyPlace, style: NotifyStyle): Record<string, unknown> {
109 const isPing = PINGS.includes(event.kind) && style.mention !== null
110 const fields = [
111 ...(place.branch === null ? [] : [{ name: 'Branch', value: place.branch }]),
112 ...(event.fields ?? []),
113 ]
114 .slice(0, 6)
115 .map(field => ({ name: field.name.slice(0, 256), value: redact(field.value || '—').slice(0, 1024), inline: true }))
116 const description = event.detail === undefined || (style.isBrief && event.isPlain !== true) ? undefined : redact(event.detail).slice(0, 1500)
117 const footer = ['dforge', place.account, place.tmux === null ? null : `tmux ${place.tmux}`].filter(Boolean).join(' · ')
118 const author = [place.host, place.repo].filter(Boolean).join(' · ')
119 const embed: Record<string, unknown> = {
120 title: redact(event.title).slice(0, 256),
121 color: COLORS[event.kind],
122 ...(author === '' ? {} : { author: { name: author.slice(0, 256) } }),
123 ...(description === undefined || description === '' ? {} : { description }),
124 ...(fields.length === 0 ? {} : { fields }),
125 footer: { text: footer.slice(0, 2048) },
126 timestamp: new Date(style.now).toISOString(),
127 }
128 return {
129 username: 'Cockpit',
130 ...(isPing ? { content: `<@${style.mention}>` } : { flags: SUPPRESS_NOTIFICATIONS }),
131 allowed_mentions: { parse: [], users: isPing ? [style.mention] : [] },
132 embeds: [embed],
133 }
134}
135
136/** Whether a kind mentions the person. */
137export function isPing(kind: NotifyKind): boolean {
138 return PINGS.includes(kind)
139}
140
141/** What a send came to: the message id when Discord gave one, else why not. */
142export type SendResult = { isSent: true; id: string | null } | { isSent: false; reason: string }
143hooks/cockpit/svg.ts 377 lines1// The desktop's drawings: meters, the context trend, the category stack, the
2// run's tasks and the account's badge, as SVG documents for its Svg element.
3// The desktop draws them as images, so they carry their own colors: mid tones
4// and a translucent track that read on its light and dark themes alike.
5
6const PALETTE = {
7 cyan: '#1fa8c9',
8 blue: '#4f7cff',
9 yellow: '#d4a017',
10 magenta: '#c056d6',
11 green: '#3fb36b',
12 red: '#e5534b',
13 gray: '#8a8a99',
14 white: '#8a8a99',
15 success: '#3fb36b',
16 warning: '#d4a017',
17 error: '#e5534b',
18}
19const PALETTE_BY_NAME: Record<string, string> = PALETTE
20const TRACK = 'rgba(128,128,140,0.24)'
21const MARK = '#8a8a99'
22
23/** A tree's color (a name, a theme key or hex) as one an SVG can paint. */
24export function paint(color: string | undefined, fallback = MARK): string {
25 if (color !== undefined && /^#[0-9a-f]{3,8}$/i.test(color)) return color
26 return (color === undefined ? undefined : PALETTE_BY_NAME[color]) ?? fallback
27}
28
29/** CSS pixels one terminal column spans in the desktop's text. */
30export const CELL_PX = 7.5
31
32/** The drawing's width in CSS pixels for a pane `columns` cells wide. */
33export function svgWidth(columns: number): number {
34 return Math.max(120, Math.min(640, Math.round(columns * CELL_PX)))
35}
36
37const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
38const SANS = "system-ui, -apple-system, 'Segoe UI', sans-serif"
39
40const n = (value: number) => String(Math.round(value * 10) / 10)
41const esc = (text: string) => text.replace(/[&<>"']/g, ch => `&#${ch.charCodeAt(0)};`)
42// A label in the drawings' small monospace type, about 6.3 pixels a character.
43const LABEL_EM = 6.3
44const label = (x: number, y: number, text: string, anchor: 'start' | 'middle' | 'end', color = MARK) =>
45 `<text x="${n(x)}" y="${y}" text-anchor="${anchor}" font-family="${MONO}" font-size="10.5" fill="${color}">${esc(text)}</text>`
46const clamp = (share: number) => Math.min(Math.max(Number.isFinite(share) ? share : 0, 0), 1)
47const svg = (width: number, height: number, body: string) =>
48 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
49
50export type SvgMark = { at: number; color?: string; isDashed?: boolean }
51
52/** A rounded meter filled to `share`, with upright marks across it. */
53export function meterSvg(share: number, width: number, color: string, marks: readonly SvgMark[] = [], height = 12): string {
54 const bar = Math.max(4, height - 4)
55 const y = (height - bar) / 2
56 const r = bar / 2
57 const filled = clamp(share) * width
58 const fill = filled <= 0 ? '' : `<rect x="0" y="${n(y)}" width="${n(Math.max(filled, bar))}" height="${n(bar)}" rx="${n(r)}" fill="${paint(color)}"/>`
59 const lines = marks
60 .filter(mark => Number.isFinite(mark.at))
61 .map(mark => {
62 const x = Math.min(Math.max(clamp(mark.at) * width, 1), width - 1)
63 const dash = mark.isDashed ? ' stroke-dasharray="2 2"' : ''
64 return `<line x1="${n(x)}" y1="0" x2="${n(x)}" y2="${height}" stroke="${paint(mark.color)}" stroke-width="2"${dash}/>`
65 })
66 .join('')
67 return svg(width, height, `<rect x="0" y="${n(y)}" width="${width}" height="${n(bar)}" rx="${n(r)}" fill="${TRACK}"/>${fill}${lines}`)
68}
69
70export type SvgLabel = { at: number; text: string; color?: string }
71
72/**
73 * A meter with its marks named beneath it: each label sits under its point,
74 * the first and last against the ends, and one that would run into a label
75 * already placed is left out.
76 */
77export function labelledMeterSvg(share: number, width: number, color: string, marks: readonly SvgMark[], labels: readonly SvgLabel[]): string {
78 const bar = meterSvg(share, width, color, marks, 16)
79 const inner = bar.slice(bar.indexOf('>') + 1, bar.lastIndexOf('</svg>'))
80 const taken: [number, number][] = []
81 const texts = labels
82 .map(one => {
83 const size = one.text.length * LABEL_EM
84 const x = clamp(one.at) * width
85 const anchor = x - size / 2 <= 0 ? 'start' : x + size / 2 >= width ? 'end' : 'middle'
86 const at = anchor === 'start' ? 0 : anchor === 'end' ? width - size : x - size / 2
87 const clashes = (from: number) => from < 0 || from + size > width || taken.some(([a, b]) => from < b + 8 && from + size > a - 8)
88 // A label that would run into one already placed slides clear of it,
89 // to the left first; with no room either side it is left out.
90 const from = [at, ...taken.flatMap(([a, b]) => [a - 8 - size, b + 8])].sort((p, q) => Math.abs(p - at) - Math.abs(q - at)).find(one => !clashes(one))
91 if (from === undefined) return ''
92 taken.push([from, from + size])
93 return label(from, 32, one.text, 'start', one.color === undefined ? MARK : paint(one.color))
94 })
95 .join('')
96 return svg(width, 38, inner + texts)
97}
98
99/** The readings as a line over a soft fill, the latest one marked, with the change and the span at its right. */
100export function trendSvg(values: readonly number[], width: number, color: string, height = 40, labels: { top?: string; bottom?: string } = {}): string {
101 const shown = values.filter(Number.isFinite)
102 if (shown.length < 2) return svg(width, height, '')
103 const low = Math.min(...shown)
104 const high = Math.max(...shown)
105 const span = high - low || 1
106 const pad = 4
107 // The change sits above the line, clear of its latest point.
108 const top = labels.top === undefined ? pad : 16
109 const step = (width - 2 * pad) / (shown.length - 1)
110 const points = shown.map((value, index) => [pad + index * step, height - pad - ((value - low) / span) * (height - pad - top)] as const)
111 const line = points.map(([x, y], index) => `${index === 0 ? 'M' : 'L'}${n(x)} ${n(y)}`).join(' ')
112 const [lastX, lastY] = points[points.length - 1] ?? [0, 0]
113 const hue = paint(color)
114 return svg(
115 width,
116 height,
117 `<line x1="0" y1="${height - pad}" x2="${width}" y2="${height - pad}" stroke="${TRACK}"/>` +
118 `<path d="${line} L${n(lastX)} ${height - pad} L${pad} ${height - pad} Z" fill="${hue}" fill-opacity="0.18"/>` +
119 `<path d="${line}" fill="none" stroke="${hue}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>` +
120 `<circle cx="${n(lastX)}" cy="${n(lastY)}" r="3" fill="${hue}"/>` +
121 (labels.top === undefined ? '' : label(width, 11, labels.top, 'end', hue)) +
122 (labels.bottom === undefined ? '' : label(width, height - pad - 4, labels.bottom, 'end')),
123 )
124}
125
126/** One bar split by the values' shares, each part in its color. */
127export function stackSvg(values: readonly number[], colors: readonly string[], width: number, height = 10): string {
128 const total = values.reduce((sum, value) => sum + Math.max(0, value), 0)
129 let x = 0
130 const parts = values
131 .map((value, index) => {
132 const part = total <= 0 ? 0 : (Math.max(0, value) / total) * width
133 const rect = part <= 0 ? '' : `<rect x="${n(x)}" y="0" width="${n(part)}" height="${height}" fill="${paint(colors[index % colors.length])}"/>`
134 x += part
135 return rect
136 })
137 .join('')
138 const r = height / 2
139 return svg(
140 width,
141 height,
142 `<defs><clipPath id="c"><rect x="0" y="0" width="${width}" height="${height}" rx="${n(r)}"/></clipPath></defs>` +
143 `<rect x="0" y="0" width="${width}" height="${height}" rx="${n(r)}" fill="${TRACK}"/><g clip-path="url(#c)">${parts}</g>`,
144 )
145}
146
147/**
148 * A seat's bar: its input against the busiest seat's, the share served from
149 * the prompt cache shaded.
150 */
151export function seatSvg(share: number, cached: number, width: number, color: string): string {
152 const hue = paint(color)
153 const filled = clamp(share) * width
154 const warm = filled * clamp(cached)
155 return svg(
156 width,
157 8,
158 `<rect x="0" y="0" width="${width}" height="8" rx="4" fill="${TRACK}"/>` +
159 (filled <= 0
160 ? ''
161 : `<defs><clipPath id="s"><rect x="0" y="0" width="${n(Math.max(filled, 8))}" height="8" rx="4"/></clipPath></defs>` +
162 `<g clip-path="url(#s)"><rect x="0" y="0" width="${n(warm)}" height="8" fill="${hue}" fill-opacity="0.45"/>` +
163 `<rect x="${n(warm)}" y="0" width="${n(Math.max(filled, 8) - warm)}" height="8" fill="${hue}"/></g>`),
164 )
165}
166
167/**
168 * A half-round gauge filled to `share`, its figure inside in the gauge's
169 * color (or, `isThemed`, the page's text tone where the drawing it sits in
170 * carries the theme), with an optional tick where the limit's window has run to.
171 */
172export function gaugeSvg(share: number, color: string, size: number, value: string, tick: number | null = null, isThemed = false): string {
173 const stroke = Math.max(6, Math.round(size * 0.085))
174 const r = size / 2 - stroke * 0.9 - 2
175 const cx = size / 2
176 // Room above the arc for the tick, which reaches past the stroke.
177 const cy = r + stroke * 0.9 + 2
178 const height = Math.ceil(cy + stroke / 2 + 2)
179 const at = (part: number) => {
180 const angle = Math.PI * (1 - clamp(part))
181 return [cx + r * Math.cos(angle), cy - r * Math.sin(angle)] as const
182 }
183 const hue = paint(color)
184 const arc = (part: number, paintWith: string) => {
185 const [x, y] = at(part)
186 return `<path d="M${n(cx - r)} ${n(cy)} A${n(r)} ${n(r)} 0 0 1 ${n(x)} ${n(y)}" fill="none" stroke="${paintWith}" stroke-width="${stroke}" stroke-linecap="round"/>`
187 }
188 let mark = ''
189 if (tick !== null && Number.isFinite(tick)) {
190 const angle = Math.PI * (1 - clamp(tick))
191 const inner = r - stroke * 0.9
192 const outer = r + stroke * 0.9
193 mark = `<line x1="${n(cx + inner * Math.cos(angle))}" y1="${n(cy - inner * Math.sin(angle))}" x2="${n(cx + outer * Math.cos(angle))}" y2="${n(cy - outer * Math.sin(angle))}" stroke="${MARK}" stroke-width="2" stroke-linecap="round"/>`
194 }
195 return svg(
196 size,
197 height,
198 arc(1, TRACK) +
199 (clamp(share) > 0 ? arc(share, hue) : '') +
200 mark +
201 `<text x="${n(cx)}" y="${n(cy - 2)}" text-anchor="middle" font-family="${SANS}" font-size="${n(size * 0.2)}" font-weight="700"${isThemed ? ' class="fg"' : ` fill="${hue}"`}>${esc(value)}</text>`,
202 )
203}
204
205export type Segment = 'done' | 'active' | 'blocked' | 'todo'
206const SEGMENT_COLORS: Record<Segment, string> = { done: PALETTE.green, active: PALETTE.blue, blocked: PALETTE.red, todo: TRACK }
207
208/** One segment per task, colored by where it stands. */
209export function segmentsSvg(states: readonly Segment[], width: number, height = 8): string {
210 if (states.length === 0) return svg(width, height, '')
211 const gap = states.length > 24 ? 1 : 4
212 const each = Math.max(1, (width - gap * (states.length - 1)) / states.length)
213 const body = states
214 .map((state, index) => `<rect x="${n(index * (each + gap))}" y="0" width="${n(each)}" height="${height}" rx="${n(Math.min(3, each / 2))}" fill="${SEGMENT_COLORS[state]}"/>`)
215 .join('')
216 return svg(width, height, body)
217}
218
219/** A round badge in the account's color with its first letter. */
220export function avatarSvg(name: string, color: string | undefined, size = 30): string {
221 const text = esc(([...name.trim()][0] ?? '?').toUpperCase())
222 const r = size / 2
223 return svg(
224 size,
225 size,
226 `<circle cx="${r}" cy="${r}" r="${r}" fill="${paint(color, PALETTE.blue)}"/>` +
227 `<text x="${r}" y="${r}" dy="0.35em" text-anchor="middle" font-family="system-ui, sans-serif" font-size="${n(size * 0.45)}" font-weight="600" fill="#ffffff">${text}</text>`,
228 )
229}
230
231// ---------------------------------------------------------------------------
232// The rows a panel describes, which native.tsx draws as the desktop's own
233// text, and the type the gauge tiles and the band's meters set in their
234// drawings: the page's light or dark tones from the system setting, the
235// accents the drawings' own mid tones.
236
237/** One stretch of text in a panel line; `chip` sets it on a soft pill. */
238export type PanelRun = { text: string; color?: string; bold?: boolean; dim?: boolean; faint?: boolean; mono?: boolean; chip?: boolean }
239
240export type PanelLine =
241 /** Text, its `left` cut to fit beside `right`; `wrap` breaks a long `left` onto more lines instead. */
242 | { kind: 'text'; left: PanelRun[]; right?: PanelRun[]; indent?: number; small?: boolean; wrap?: boolean }
243 /** One of the drawings above, placed whole; `alt` says what it shows. */
244 | { kind: 'picture'; svg: string; alt?: string; indent?: number }
245 /** Pills in a row that wraps. */
246 | { kind: 'chips'; chips: PanelRun[] }
247 /** A badge beside two lines, as the account's. */
248 | { kind: 'badge'; svg: string; lines: PanelRun[][] }
249
250const THEME =
251 '<style>' +
252 '.fg{fill:#1d1d22}.dim{fill:#6b6b76}.faint{fill:#9a9aa6}.pill{fill:rgba(128,128,140,0.16)}' +
253 '@media (prefers-color-scheme: dark){.fg{fill:#ececf1}.dim{fill:#a3a3ae}.faint{fill:#7a7a85}}' +
254 '</style>'
255const BODY = 12.5
256const SMALL = 11.5
257const PILL_X = 7
258
259// A glyph's advance in pixels, near enough to cut and place text: wide
260// scripts take a full em, monospace 0.6, proportional text about 0.56.
261function advance(ch: string, size: number, mono: boolean): number {
262 const code = ch.codePointAt(0) ?? 0
263 if (code >= 0x2e80) return size
264 if (mono) return size * 0.6
265 if (' .,:;\'|!il'.includes(ch)) return size * 0.3
266 if ('MWmw@'.includes(ch)) return size * 0.82
267 if (/[A-Z]/.test(ch)) return size * 0.64
268 return size * 0.55
269}
270const measure = (text: string, size: number, mono = false) => [...text].reduce((sum, ch) => sum + advance(ch, size, mono), 0)
271// A chip's text is set small, whatever the line's size.
272const sizeOf = (run: PanelRun, size: number) => (run.chip ? Math.min(size, SMALL) : size) - (run.mono ? 1 : 0)
273const runWidth = (run: PanelRun, size: number) => measure(run.text, sizeOf(run, size), run.mono) + (run.chip ? 2 * PILL_X : 0)
274
275/** Runs cut to `room` pixels, the last kept one ending in an ellipsis. */
276function cut(runs: readonly PanelRun[], size: number, room: number): PanelRun[] {
277 const total = runs.reduce((sum, run) => sum + runWidth(run, size), 0)
278 if (total <= room) return [...runs]
279 const kept: PanelRun[] = []
280 let left = room - measure('…', size)
281 for (const run of runs) {
282 const width = runWidth(run, size)
283 if (width <= left) {
284 kept.push(run)
285 left -= width
286 continue
287 }
288 let text = ''
289 for (const ch of run.text) {
290 const next = advance(ch, run.mono ? size - 1 : size, Boolean(run.mono))
291 if (next > left - (run.chip ? 2 * PILL_X : 0)) break
292 text += ch
293 left -= next
294 }
295 kept.push({ ...run, text: text.trimEnd() + '…' })
296 break
297 }
298 return kept
299}
300
301function toneOf(run: PanelRun): { cls: string; fill: string; opacity: string } {
302 if (run.color !== undefined && PALETTE_BY_NAME[run.color] === undefined && !/^#/.test(run.color)) {
303 // A theme key the drawings lack reads as body text.
304 return { cls: run.dim ? 'dim' : run.faint ? 'faint' : 'fg', fill: '', opacity: '' }
305 }
306 if (run.color !== undefined) return { cls: '', fill: ` fill="${paint(run.color)}"`, opacity: run.dim ? ' fill-opacity="0.7"' : '' }
307 return { cls: run.dim ? 'dim' : run.faint ? 'faint' : 'fg', fill: '', opacity: '' }
308}
309
310const tspan = (run: PanelRun, size: number) => {
311 const runSize = run.mono ? size - 1 : size
312 const tone = toneOf(run)
313 return (
314 `<tspan${tone.cls ? ` class="${tone.cls}"` : ''}${tone.fill}${tone.opacity} font-family="${run.mono ? MONO : SANS}" font-size="${n(runSize)}"` +
315 `${run.bold ? ' font-weight="600"' : ''}>${esc(run.text)}</tspan>`
316 )
317}
318
319let nested = 0
320/** A whole drawing placed at `x`, `y` inside another, its ids made its own. */
321function place(source: string, x: number, y: number): { svg: string; width: number; height: number } {
322 const width = Number(/width="([\d.]+)"/.exec(source)?.[1] ?? 0)
323 const height = Number(/height="([\d.]+)"/.exec(source)?.[1] ?? 0)
324 const tag = `p${++nested}`
325 const body = source
326 .replace(/ id="([^"]+)"/g, ` id="${tag}$1"`)
327 .replace(/url\(#([^)]+)\)/g, `url(#${tag}$1)`)
328 .replace('<svg xmlns="http://www.w3.org/2000/svg" ', `<svg x="${n(x)}" y="${n(y)}" `)
329 return { svg: body, width, height }
330}
331
332/**
333 * A gauge tile's face: the dial over its key and notes, centered in `width`.
334 * The tile's frame is the desktop's own, so it stretches with the pane as the
335 * panels do.
336 */
337export function gaugeTileSvg(tile: { dial: string; key: string; notes: readonly PanelRun[][] }, width: number): string {
338 nested = 0
339 const cx = width / 2
340 const dial = place(tile.dial, 0, 0)
341 let out = place(tile.dial, cx - dial.width / 2, 6).svg
342 let y = 6 + dial.height + 4
343 out += `<text x="${n(cx)}" y="${n(y + 9)}" text-anchor="middle" class="faint" font-family="${MONO}" font-size="10" font-weight="600" letter-spacing="0.8">${esc(tile.key.toUpperCase())}</text>`
344 y += 12
345 for (const note of tile.notes) {
346 const runs = cut(note, 11, width - 4)
347 out += `<text x="${n(cx)}" y="${n(y + 11)}" text-anchor="middle" xml:space="preserve">${runs.map(run => tspan({ ...run, dim: run.color === undefined ? true : run.dim }, 11)).join('')}</text>`
348 y += 15
349 }
350 return svg(width, Math.ceil(y + 6), THEME + out)
351}
352
353/** One meter in the minimized band: a key, its figure, a bar with an optional tick, and a note. */
354export type BandMeter = { key: string; percent: number; share: number; color: string; tick: number | null; note: PanelRun[] }
355
356/**
357 * The minimized band's meters side by side, each column as wide as the
358 * others with a gap between; a narrow drawing drops the notes' tails.
359 */
360export function bandMetersSvg(meters: readonly BandMeter[], width: number): string {
361 nested = 0
362 const gap = 12
363 const column = (width - gap * Math.max(0, meters.length - 1)) / Math.max(1, meters.length)
364 let out = ''
365 for (const [index, meter] of meters.entries()) {
366 const x = index * (column + gap)
367 out += `<text x="${n(x)}" y="11" class="faint" font-family="${MONO}" font-size="10" font-weight="600" letter-spacing="0.8">${esc(meter.key.toUpperCase())}</text>`
368 out += `<text x="${n(x + column)}" y="11" text-anchor="end" font-family="${SANS}" font-size="${BODY}" font-weight="600" fill="${paint(meter.color)}">${esc(`${Math.round(meter.percent)}%`)}</text>`
369 out += place(meterSvg(meter.share, column, meter.color, meter.tick === null ? [] : [{ at: meter.tick }], 10), x, 16).svg
370 if (meter.note.length > 0) {
371 const runs = cut(meter.note, 11, column)
372 out += `<text x="${n(x)}" y="41" xml:space="preserve">${runs.map(run => tspan({ ...run, dim: run.color === undefined ? true : run.dim }, 11)).join('')}</text>`
373 }
374 }
375 return svg(width, meters.some(meter => meter.note.length > 0) ? 46 : 30, THEME + out)
376}
377config/schema.mjs 216 lines1// Every dforge setting: its type, default, the settings-view group and text,
2// and where it is stored. An ES module rather than JSON because a Claude Code
3// mod imports only code files; the cockpit, scripts/lib/dforge-config.mjs and
4// scripts/validate-repository.mjs all import it. Values live in
5// ~/.dforge/config.json, whose `darkmem` key is reserved for the darkmem mapping.
6//
7// The groups are the settings view's tabs, in the order they first appear here.
8// label is the row's label there (at most 16 characters); unit and zero say how
9// a number reads (`k` is thousands of tokens, zero the word shown at 0).
10// enforcedBy names what obeys a workflow key: `script`, the script that already
11// makes the decision, or `skill`, through the `dforge-config workflow` line.
12// keepDefault writes the key even at its default, for a key whose absence
13// means something of its own. values on a string makes it a list: names from
14// values, in any case, separated by commas or spaces, as the cockpit reads it.
15// A value as ~/.dforge/config.json must hold it: exactly the setting's type.
16// scripts/lib/dforge-config.mjs and the cockpit's config.ts both call this, so
17// the two readers cannot disagree about what the file may contain.
18export function isValidValue(spec, value) {
19 switch (spec.type) {
20 case "boolean": return typeof value === "boolean";
21 case "number":
22 if (typeof value !== "number" || !Number.isInteger(value)) return false;
23 // A floor leaves 0 as the one value below it: "unset".
24 if (spec.floor !== undefined) return value === 0 || (value >= spec.floor && (spec.max === undefined || value <= spec.max));
25 return (spec.min === undefined || value >= spec.min) && (spec.max === undefined || value <= spec.max);
26 case "enum": return typeof value === "string" && (spec.values ?? []).includes(value);
27 case "string":
28 return typeof value === "string"
29 && (spec.values === undefined || value.split(/[\s,]+/).every(name => name === "" || spec.values.includes(name.toLowerCase())));
30 default: return false;
31 }
32}
33
34export default {
35 openAtStart: {
36 type: 'boolean', default: true, group: 'Cockpit', target: 'dforge',
37 label: 'Show at start', title: 'Open the pane at session start',
38 description: 'Open the cockpit when a session starts, once the terminal is 144 columns wide (110 after the first time). /cockpit opens it at any width.',
39 },
40 layout: {
41 type: 'enum', values: ['auto', 'full', 'compact'], default: 'auto', group: 'Cockpit', target: 'dforge',
42 label: 'Layout', title: 'Pane layout',
43 description: 'auto: the full pane beside the transcript, the band above the prompt. full: always the full pane. compact: the minimized box by the prompt; Minimize sets it, Expand leaves it.',
44 },
45 density: {
46 type: 'enum', values: ['detailed', 'dense'], default: 'detailed', group: 'Cockpit', target: 'dforge',
47 label: 'Density', title: 'Pane density',
48 description: 'detailed: cards, charts and lists. dense: one line per section. The pane\'s Dense and Detailed buttons set it.',
49 },
50 sections: {
51 type: 'string', values: ['account', 'context', 'usage', 'shells', 'agents', 'plan', 'repo', 'guard'], default: '', group: 'Cockpit', target: 'dforge',
52 label: 'Sections', title: 'Pane sections',
53 description: 'Which sections the full pane shows, in the order turned on. The last one left stays on.',
54 },
55 collapsed: {
56 type: 'string', values: ['account', 'context', 'usage', 'shells', 'agents', 'plan', 'repo', 'guard'], default: '', group: 'Cockpit', target: 'dforge',
57 label: 'Collapsed', title: 'Collapsed sections',
58 description: 'Sections drawn as their title row only.',
59 },
60 breakdownRows: {
61 type: 'number', default: 6, min: 0, max: 12, step: 1, zero: 'hidden', group: 'Cockpit', target: 'dforge',
62 label: 'Context rows', title: 'Context breakdown rows',
63 description: 'How many /context categories the Context section lists, largest first, up to 12. 0 hides the breakdown.',
64 },
65 guardAttribution: {
66 type: 'boolean', default: true, group: 'Cockpit', target: 'dforge',
67 label: 'AI attribution', title: 'Keep AI attribution out of git',
68 description: 'Drop the commit trailer and pull request footer Claude Code asks for, and refuse commits and pull requests that still carry AI attribution.',
69 },
70 bandLine1: {
71 type: 'string', values: ['dir', 'git', 'model', 'effort', 'style', 'turn', 'run', 'agents', 'shells', 'ctx', 'cache', 'cost', '5h', '7d', 'time', 'none'], default: '', group: 'Minimized', target: 'dforge',
72 label: 'Line 1', title: 'Minimized band: line 1',
73 description: 'What the minimized band\'s first line shows, in the order turned on. A segment turned on here leaves line 2; none on hides the line.',
74 },
75 bandLine2: {
76 type: 'string', values: ['dir', 'git', 'model', 'effort', 'style', 'turn', 'run', 'agents', 'shells', 'ctx', 'cache', 'cost', '5h', '7d', 'time', 'none'], default: '', group: 'Minimized', target: 'dforge',
77 label: 'Line 2', title: 'Minimized band: line 2',
78 description: 'What the minimized band\'s second line shows, in the order turned on. A segment turned on here leaves line 1; none on hides the line.',
79 },
80 handoffTokens: {
81 type: 'number', default: 0, min: 0, max: 2_000_000, step: 50_000, unit: 'k', zero: 'auto', env: 'DFORGE_BUDGET', group: 'Context', target: 'dforge',
82 label: 'Handoff budget', title: 'Handoff budget (tokens)',
83 description: "Context size at which the cockpit and the budget scripts say hand off. 0 follows dforge's rule: 93% of the smaller of autoCompactWindow and the model's window, minus 140,000, and never below a fifth of that window. DFORGE_BUDGET overrides it; relaxed handoff mode ignores it.",
84 },
85 handoffMode: {
86 type: 'enum', values: ['strict', 'relaxed'], default: 'strict', enforcedBy: 'script', group: 'Context', target: 'dforge',
87 label: 'Handoff mode', title: 'Handoff mode',
88 description: 'strict hands off at the handoff budget. relaxed measures every task against the final-phase limit, 85% of the window, and the handoff budget then has no effect.',
89 },
90 warnAt: {
91 type: 'number', default: 80, min: 1, max: 99, step: 5, unit: '%', group: 'Context', target: 'dforge',
92 label: 'Warn at', title: 'Warn at (% of the handoff budget)',
93 description: 'Where Hand off takes the accent color, as a share of the handoff budget, 1 to 99.',
94 },
95 nudgeModel: {
96 type: 'boolean', default: true, group: 'Context', target: 'dforge',
97 label: 'Handoff note', title: 'Tell the model at the budget',
98 description: 'In a session running dforge, add one note to the conversation when the main session crosses its handoff budget.',
99 },
100 autoCompactWindow: {
101 type: 'number', default: 0, min: 0, floor: 100_000, max: 1_000_000, step: 50_000, unit: 'k', zero: 'unset', group: 'Context', target: 'claude',
102 label: 'Auto-compact at', title: "Claude's auto-compaction window (tokens)",
103 description: "Claude's autoCompactWindow: 0 leaves it unset, else 100,000 to 1,000,000. Stored in Claude's settings file; applies from the next session.",
104 },
105 autoCompactEnabled: {
106 type: 'boolean', default: true, group: 'Context', target: 'claude',
107 label: 'Auto-compact', title: "Claude's auto-compaction",
108 description: "Claude's autoCompactEnabled. Stored in Claude's settings file; applies from the next session.",
109 },
110 codexLane: {
111 type: 'boolean', default: true, enforcedBy: 'script', group: 'Workflow', target: 'dforge',
112 label: 'Codex tasks', title: 'Give tasks to Codex',
113 description: 'Give plan tasks to Codex when its gate passes. Off keeps every task on Claude.',
114 },
115 codexReview: {
116 type: 'boolean', default: false, enforcedBy: 'script', group: 'Workflow', target: 'dforge',
117 label: 'Codex reviews', title: 'Let Codex review',
118 description: 'On lets Codex take review seats when its gate passes. Off, the default, keeps every review on Claude; /codex:review or /codex:adversarial-review runs a Codex review on demand.',
119 },
120 lensCap: {
121 type: 'number', default: 4, min: 0, max: 9, step: 1, zero: 'off', env: 'DFORGE_LENS_CAP', enforcedBy: 'script', group: 'Workflow', target: 'dforge',
122 label: 'Review lenses', title: 'Final-review lens cap',
123 description: 'How many specialist review lenses the final review runs at most, highest priority first. 0 turns them off. DFORGE_LENS_CAP overrides it.',
124 },
125 lensMinorFrom: {
126 type: 'number', default: 20, min: 0, max: 500, step: 5, zero: 'off', env: 'DFORGE_LENS_MINOR_FROM', enforcedBy: 'script', group: 'Workflow', target: 'dforge',
127 label: 'Lens minors', title: 'Lens Minor findings from',
128 description: 'The plan size, in tasks, from which final-review lenses also report Minor findings. A smaller plan gets Critical and Important lens findings only. 0 always asks for Minor findings. DFORGE_LENS_MINOR_FROM overrides it.',
129 },
130 executionStrategy: {
131 type: 'enum', values: ['ask', 'sequential', 'parallel'], default: 'parallel', enforcedBy: 'skill', group: 'Workflow', target: 'dforge',
132 label: 'Strategy', title: 'Execution strategy',
133 description: 'How a plan executes. parallel, the default, runs independent tasks in waves and falls back to sequential when no two tasks can share a wave, in inline mode, and on Codex; ask keeps the question.',
134 },
135 executionMode: {
136 type: 'enum', values: ['auto', 'inline', 'subagent'], default: 'auto', enforcedBy: 'skill', group: 'Workflow', target: 'dforge',
137 label: 'Execution mode', title: 'Execution mode',
138 description: "Who implements plan tasks. auto follows the planner's rule; inline and subagent fix the plan's Execution line, and inline also makes the plan sequential.",
139 },
140 worktree: {
141 type: 'enum', values: ['ask', 'always', 'never'], default: 'ask', enforcedBy: 'skill', group: 'Workflow', target: 'dforge',
142 label: 'Worktree', title: 'Worktree',
143 description: 'Whether work is isolated in a git worktree before it starts. ask keeps the consent question.',
144 },
145 autoRun: {
146 type: 'enum', values: ['on', 'ask', 'off'], default: 'on', enforcedBy: 'skill', group: 'Workflow', target: 'dforge',
147 label: 'Auto run', title: 'Unattended runs',
148 description: 'on runs the plan, execution, review and finish unattended once you approve the written spec (Claude Code only; off on Codex). ask offers that once the design is approved. off never runs one.',
149 },
150 processBias: {
151 type: 'enum', values: ['standard', 'lighter', 'heavier'], default: 'standard', enforcedBy: 'skill', group: 'Process', target: 'dforge',
152 label: 'Path bias', title: 'Path bias',
153 description: 'How readily a request gets a written spec and plan. lighter resolves doubt to a short in-chat design; heavier resolves it to a spec.',
154 },
155 designApproval: {
156 type: 'enum', values: ['sections', 'whole'], default: 'sections', enforcedBy: 'skill', group: 'Process', target: 'dforge',
157 label: 'Design approval', title: 'Design approval',
158 description: 'sections approves each design section in turn; whole presents the design once with one approval.',
159 },
160 specReview: {
161 type: 'boolean', default: true, enforcedBy: 'skill', group: 'Process', target: 'dforge',
162 label: 'Spec review stop', title: 'Stop for spec review',
163 description: 'Stop for you to review the written spec before the plan is written. Off goes straight to the plan after the self-review.',
164 },
165 questionStyle: {
166 type: 'enum', values: ['single', 'batched'], default: 'single', enforcedBy: 'skill', group: 'Process', target: 'dforge',
167 label: 'Questions', title: 'Clarifying questions',
168 description: 'single asks one clarifying question per message; batched asks them all in one message.',
169 },
170 finishAction: {
171 type: 'enum', values: ['ask', 'merge', 'pr', 'keep', 'wait'], default: 'ask', enforcedBy: 'script', group: 'Finish', target: 'dforge',
172 label: 'When done', title: 'When the work is done',
173 description: 'ask asks before the first task. merge, pr or keep skips the question and runs that action as soon as the final review is clean; wait skips it and shows the finish menu then.',
174 },
175 limitAlertAt: {
176 type: 'number', default: 90, min: 0, max: 100, step: 5, unit: '%', zero: 'off', group: 'Notifications', target: 'dforge',
177 label: 'Limit alert at', title: 'Discord usage limit alert (%)',
178 description: 'Send the Discord limit message once when the 5-hour or 7-day limit crosses this share, again after it resets. 0 turns it off.',
179 },
180 notifyOn: {
181 type: 'string', values: ['ask', 'question', 'done', 'error', 'blocked', 'budget', 'limit', 'task', 'session', 'agent', 'none'], default: '', group: 'Notifications', target: 'dforge',
182 label: 'Send on', title: 'Discord: events to send',
183 description: 'Which events go to Discord. None on sends nothing.',
184 },
185 notifyAfter: {
186 type: 'number', default: 60, min: 0, max: 3600, step: 15, unit: 's', zero: 'any', group: 'Notifications', target: 'dforge',
187 label: 'Done after', title: 'Discord: done after (seconds)',
188 description: 'A finished turn is sent only when it ran at least this long. 0 sends every turn.',
189 },
190 notifyAskAfter: {
191 type: 'number', default: 20, min: 0, max: 600, step: 10, unit: 's', group: 'Notifications', target: 'dforge',
192 label: 'Ask after', title: 'Discord: approval after (seconds)',
193 description: 'An approval or question is sent only when it is still unanswered after this long. 0 sends at once.',
194 },
195 notifyDetail: {
196 type: 'enum', values: ['full', 'brief'], default: 'full', group: 'Notifications', target: 'dforge',
197 label: 'Detail', title: 'Discord: detail',
198 description: 'full sends command, question and answer excerpts (secrets masked); brief sends titles and figures only.',
199 },
200 darkmemEnabled: {
201 type: 'boolean', default: false, keepDefault: true, group: 'darkmem', target: 'dforge',
202 label: 'Use darkmem', title: 'Use darkmem',
203 description: 'Keep a mapped repository\'s documents and progress in darkmem through the mirror. Off keeps both in the repository: a family already in the mirror moves back at the next session start with no plan in progress, from the mirror alone. Left unset after an upgrade, a family a 2.x release put in the mirror stays there unsynced until you choose. A repository is mapped under darkmem.repos in ~/.dforge/config.json.',
204 },
205 documentStore: {
206 type: 'enum', values: ['local', 'darkmem'], default: 'darkmem', group: 'darkmem', target: 'dforge',
207 label: 'Documents', title: 'Document store',
208 description: 'While Use darkmem is on, where a mapped repository keeps its specs, plans, registers and notes: local in the repository, or darkmem through the mirror. A change moves them at the next session start with no plan in progress.',
209 },
210 progressSource: {
211 type: 'enum', values: ['local', 'darkmem'], default: 'darkmem', group: 'darkmem', target: 'dforge',
212 label: 'Progress', title: 'Progress tracking source',
213 description: 'While Use darkmem is on, where a mapped repository keeps its plan ledgers and handoffs: local in the repository, or darkmem through the mirror. A change moves them at the next session start with no plan in progress.',
214 },
215};
216types/index.d.ts 312 lines1/** The main session's context against its handoff budget, as last read. */
2export type CockpitBudget = {
3 /** Input tokens the last main-loop response was answered over. */
4 tokens: number
5 /** The handoff budget in tokens. */
6 limit: number
7 /** The model's context window in tokens. */
8 window: number
9 /** The auto-compaction window from the settings, when one is set. */
10 compactWindow: number | null
11 /** What the session has cost so far, in US dollars, when the host says. */
12 usd: number | null
13}
14
15/** One rate-limit window as the last response reported it. */
16export type CockpitLimit = {
17 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
18 kind: string
19 percent: number
20 /** When the window resets, in milliseconds since the epoch. */
21 resetsAt: number | null
22}
23
24/** The account's limits and the session's spend, from the last measurement. */
25export type CockpitUsage = {
26 limits: CockpitLimit[]
27 /** When the session began, in milliseconds since the epoch. */
28 startedAt: number
29 /** When these figures were read, in milliseconds since the epoch. */
30 readAt: number
31}
32
33/** One row of the /context breakdown. */
34export type CockpitContextRow = {
35 name: string
36 tokens: number
37}
38
39/** The /context breakdown, estimated locally. */
40export type CockpitBreakdown = {
41 rows: CockpitContextRow[]
42 /** The token count at which auto-compaction runs, when it is on. */
43 compactAt: number | null
44 /** The estimate's total, all the pane has before a response measures the window. */
45 total?: number | null
46}
47
48/** One subagent type's runs and tokens this session. */
49export type CockpitSeat = {
50 seat: string
51 runs: number
52 input: number
53 cacheRead: number
54 cacheWrite: number
55 output: number
56}
57
58/** One subagent the session started. */
59export type CockpitSpawn = {
60 agentId: string
61 seat: string
62 model: string
63 description: string
64 isDone: boolean
65 /** How it ended: null while it runs, `failed` for an aborted, refused or failed end; absent on a record saved before it was kept. */
66 outcome?: 'done' | 'failed' | null
67 /** When it started, in milliseconds since the epoch. */
68 startedAt: number
69}
70
71/** One background shell the session started and has not heard the end of. */
72export type CockpitShell = {
73 /** The background task's id, as its notification names it. */
74 id: string
75 command: string
76 /** When it went to the background, in milliseconds since the epoch. */
77 startedAt: number
78 /** The subagent whose loop started it; null for the main conversation's, absent on a row saved before it was kept. */
79 agentId?: string | null
80}
81
82/** Remote Control as the cockpit sees it. */
83export type CockpitRemote = {
84 /** The remote clients attached now (a phone, the desktop app). */
85 clients: { id: string; surface: string }[]
86 /** The `/config` row "Enable Remote Control for all sessions": `true`, `false` or `default`. */
87 setting: string | null
88}
89
90/** One item of the session's todo or task list. */
91export type CockpitPlanItem = {
92 id: string
93 text: string
94 status: 'pending' | 'in_progress' | 'completed'
95}
96
97/** The working directory's git state. */
98export type CockpitRepo = {
99 branch: string
100 ahead: number
101 behind: number
102 changed: number
103 untracked: number
104}
105
106/** A git write the attribution guard refused. */
107export type CockpitRefusal = {
108 at: number
109 tool: string
110 line: string
111}
112
113/** The account the session runs as, from its .claude.json and dr-status' config. */
114export type CockpitAccount = {
115 email: string | null
116 name: string | null
117 organization: string | null
118 role: string | null
119 /** Claude's billing type, such as `stripe_subscription`. */
120 billing: string | null
121 /** The config directory as dr-status keys it: `~/.claude`, `~/.claude-work`. */
122 key: string
123 /** The frame color dr-status assigns this account, as configured. */
124 color: string | null
125}
126
127/** Where the session runs, for the status strip's path. */
128export type CockpitPlace = {
129 cwd: string
130 /** The repository's root, when the working directory is in one. */
131 root: string | null
132 /** The repository's `origin` remote URL, when it has one. */
133 remote?: string | null
134 home: string | null
135}
136
137/** The main loop's model, effort and cache hit rate, as its last request had them. */
138export type CockpitEngine = {
139 model: string | null
140 effort: string | null
141 /** The share of the last main turn's input served from the cache, 0 to 1. */
142 cache: number | null
143 /** The output style the main loop's requests go out with; null for the default style. */
144 style: string | null
145}
146
147/** What the session is doing now, for the Now row and the state file. */
148export type CockpitActivity = {
149 /** `waiting` on an approval, `asking` a question, `failed` once a turn ended in an error, `interrupted` once Esc stopped it, `background` once a turn ended with its shells or subagents still running. */
150 kind: 'idle' | 'running' | 'waiting' | 'asking' | 'failed' | 'interrupted' | 'background'
151 /** When this state began, in milliseconds since the epoch. */
152 since: number
153 /** When the running turn began. */
154 turnStartedAt: number | null
155 /** The main loop's request count this turn. */
156 step: number
157 /** The tool in flight, and the subagent seat running it. */
158 tool: string | null
159 agent: string | null
160 /** What waits on the person, or why the turn failed. */
161 detail: string | null
162 lastTurnMs: number | null
163 endedAt: number | null
164 /** Whether a Discord message went out for this state. */
165 isSent: boolean
166}
167
168/** One task of a dforge run, from its plan and its ledger. */
169export type CockpitRunTask = {
170 n: number
171 title: string
172 state: 'pending' | 'assigned' | 'complete' | 'blocked'
173 /** The fix or review round in flight, "2/5". */
174 round: string | null
175 seat: string | null
176 isClean: boolean
177 reason: string | null
178}
179
180/** A dforge run as its SDD ledger tells it. */
181export type CockpitRun = {
182 plan: string | null
183 tasks: CockpitRunTask[]
184 done: number
185 total: number
186 isFinished: boolean
187}
188
189/**
190 * What dforge finishing does once the final review is clean, as its
191 * `finish-choice` script keeps it (finish.json).
192 */
193export type CockpitFinish = {
194 action: 'merge' | 'pr' | 'keep' | 'wait'
195 base: string | null
196 /** Set once finishing carried the choice out or you answered the menu. */
197 answer: string | null
198}
199
200/** Where a repository's documents and progress live, as `darkmem-config.mjs --mode` prints them. */
201export type CockpitRoots = {
202 docs: { store: 'local' | 'darkmem'; root: string }
203 progress: { store: 'local' | 'darkmem'; ledgerRoot: string; latestFile: string }
204 /** The darkmem project the repository is mapped to; null when it is not. */
205 project: string | null
206}
207
208/** A handoff note that starts a next session, as the resume card and band offer it. */
209export type CockpitResumeOffer = {
210 /** When the note was written, in milliseconds since the epoch. */
211 at: number
212 /** The note's `**Next:**` line. */
213 next: string
214 /** The note's fenced first prompt; null when it has none, and Resume falls back to resume-execution. */
215 prompt: string | null
216}
217
218/** An auto run as its state file says (reference/auto-run.md), for the session it is bound to. */
219export type CockpitAutoRun = {
220 active: boolean
221 phase: string
222 regime: string
223 /** `compact`, `after-compact`, `clear`, `resume`, `switch`, `switch-resume`, `handoff`, or null. */
224 pending: string | null
225 /** The plan's repository-relative path, once attached. */
226 plan: string | null
227 compactions: number
228 resumes: number
229 /** Why the run stopped; null while it runs or once it finished. */
230 stopped: string | null
231 /** The first prompt of the session a clear starts. */
232 resumePrompt: string | null
233 /** When `auto-run stalled` last ran, in epoch seconds; null before. */
234 stalledAt: number | null
235 /** The model and effort that session runs on. */
236 launch: { model: string; effort: string } | null
237}
238
239/** The Discord webhook's state, as the settings view shows it. */
240export type CockpitNotifier = {
241 /** The webhook URL; never drawn, only masked. */
242 url: string | null
243 isFromEnv: boolean
244 /** The Discord user id that ping messages mention. */
245 mention: string | null
246 /** The field being edited in the settings view. */
247 editing: 'url' | 'mention' | null
248 /** Why the last edit was refused. */
249 error: string | null
250 /** The last send's outcome. */
251 last: { at: number; text: string; isError: boolean } | null
252}
253
254/** The settings view's own state: the open tab, and what is being typed or confirmed. */
255export type CockpitSettingsView = {
256 /** The open group; empty opens the first. */
257 tab: string
258 isHelpShown: boolean
259 /** The key whose value is being typed. */
260 editing: string | null
261 /** Why the typed value was refused. */
262 error: string | null
263 isConfirmingReset: boolean
264}
265
266declare module 'claude-code' {
267 interface PluginState {
268 'dforge': {
269 /** The settings in force, resolved from the settings file; null until read. */
270 settings: Record<string, boolean | number | string> | null
271 budget: CockpitBudget | null
272 usage: CockpitUsage | null
273 breakdown: CockpitBreakdown | null
274 seats: CockpitSeat[]
275 spawns: CockpitSpawn[]
276 shells: CockpitShell[]
277 remote: CockpitRemote
278 plan: CockpitPlanItem[]
279 repo: CockpitRepo | null
280 refusals: CockpitRefusal[]
281 account: CockpitAccount | null
282 place: CockpitPlace | null
283 engine: CockpitEngine
284 /** Rate-limit windows already alerted, as `<kind>@<resetsAt>`. */
285 alerted: string[]
286 /** The main session's context readings, oldest first, for the trend line. */
287 trend: number[]
288 /** Whether the pane shows the cockpit or its settings. */
289 view: 'cockpit' | 'settings'
290 settingsView: CockpitSettingsView
291 activity: CockpitActivity
292 /** The main session's context at the end of each turn, oldest first, for the forecast. */
293 turnTokens: number[]
294 run: CockpitRun | null
295 finish: CockpitFinish | null
296 /** The handoff this session could resume; null when the note starts no next session. */
297 resumeOffer: CockpitResumeOffer | null
298 /** The handoff time whose offer this session resumed or dismissed. */
299 resumeDismissed: number | null
300 /** Why the plan section reads checkout paths, not the roots `--mode` names: its first line of stderr; null otherwise. */
301 rootsNote: string | null
302 notifier: CockpitNotifier
303 isNudged: boolean
304 isPlanSession: boolean
305 /** This session runs a plan: an execution skill ran, an implementer was spawned, or the ledger moved. */
306 isRunSession: boolean
307 /** The auto run bound to this session, as last read; null without one. */
308 autoRun: CockpitAutoRun | null
309 }
310 }
311}
312