SLOPSHOPPER

dforge

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…

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

Draws

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

dforge

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.

Why

Four findings from real transcripts drove the fork; the numbers are in docs/superpowers/specs/2026-09-11-dr-superpowers-fork-design.md.

  • Cost. The main session was 92% of token spend, most of it re-reading a 400–700k context. The plugin hands off at a budget printed by its own scripts (the compaction point minus one task's growth, 465k at a 650000 window), keeps the execution controller on Sonnet, and reads the plan one task at a time instead of whole.
  • Small models executing literally. Upstream advises picking a model per task but has no field for the choice, and the Agent tool could not pass reasoning effort before Claude Code 2.1.292 (it now can, but a fleet dispatch still passes neither model nor effort, so the pinned pair is what runs). Here every task records an implementer from a model × effort grid, the plan's Execution: line decides inline or subagent mode, and plan-lint checks the header, the scores and the mode before a plan is saved.
  • Judgment in one seat. Executors and controllers rule only on mechanical conflicts and log every ruling. Plan defects, contested findings and cannot-verify items go to an Opus judge whose verdicts — CONFIRMED-GAP, PARK, AMEND, BLOCKED — are carried out verbatim; AMEND edits the plan through scripts/plan-amend so the plan on disk stays immutable.
  • Karpathy guidelines baked in. Think before coding, simplicity first, surgical changes, goal-driven verification: in the entry point, the plan template, the implementer template and TDD.

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.

What you get

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:

  • Only 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.
  • Valid reasoning efforts are 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.
  • Codex cannot commit. Its Windows restricted-token sandbox denies writes to .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.
  • Resume is a request field, not a subcommand. 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

Source 9 files
hooks/cockpit/register.tsx 4121 lines
1import { 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 lines
1import 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 lines
1import 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}
282
hooks/cockpit/native.tsx 73 lines
1import 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}
73
hooks/cockpit/settings-view.tsx 267 lines
1import 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}
267
hooks/cockpit/notify.ts 143 lines
1// Discord notifications: what each event sends, kept pure so tests reach it.
2// register.tsx holds the sender, since only it may reach the host.
3export const NOTIFY_KINDS = ['ask', 'question', 'done', 'error', 'blocked', 'budget', 'limit', 'task', 'session', 'agent'] as const
4export type NotifyKind = (typeof NOTIFY_KINDS)[number]
5
6/** The kinds that send unless the settings say otherwise. */
7export const DEFAULT_NOTIFY_ON: readonly NotifyKind[] = ['ask', 'question', 'done', 'error', 'blocked', 'budget', 'limit']
8
9// The kinds that need the person: they mention them, the rest post silently.
10const PINGS: readonly NotifyKind[] = ['ask', 'question', 'error', 'blocked']
11const COLORS: Record<NotifyKind, number> = {
12  ask: 0xffaf00,
13  question: 0xffaf00,
14  done: 0x57d787,
15  task: 0x57d787,
16  error: 0xff5f5f,
17  blocked: 0xff5f5f,
18  budget: 0x5f87ff,
19  limit: 0x5f87ff,
20  session: 0x5f87ff,
21  agent: 0x5f87ff,
22}
23// Discord's flag for a message that notifies nobody (`@silent`).
24const SUPPRESS_NOTIFICATIONS = 4096
25const WEBHOOK = /^https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api\/webhooks\/(\d+)\/([\w-]+)\/?$/
26
27/** The notifyOn setting, "ask, done" → ['ask', 'done']: known kinds, each once; empty is the default set, 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 }
143
hooks/cockpit/svg.ts 377 lines
1// 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}
377
config/schema.mjs 216 lines
1// 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};
216
types/index.d.ts 312 lines
1/** The main session's context against its handoff budget, as last read. */
2export type CockpitBudget = {
3  /** Input tokens the last main-loop response was answered over. */
4  tokens: number
5  /** The handoff budget in tokens. */
6  limit: number
7  /** The model's context window in tokens. */
8  window: number
9  /** The auto-compaction window from the settings, when one is set. */
10  compactWindow: number | null
11  /** What the session has cost so far, in US dollars, when the host says. */
12  usd: number | null
13}
14
15/** One rate-limit window as the last response reported it. */
16export type CockpitLimit = {
17  /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
18  kind: string
19  percent: number
20  /** When the window resets, in milliseconds since the epoch. */
21  resetsAt: number | null
22}
23
24/** The account's limits and the session's spend, from the last measurement. */
25export type CockpitUsage = {
26  limits: CockpitLimit[]
27  /** When the session began, in milliseconds since the epoch. */
28  startedAt: number
29  /** When these figures were read, in milliseconds since the epoch. */
30  readAt: number
31}
32
33/** One row of the /context breakdown. */
34export type CockpitContextRow = {
35  name: string
36  tokens: number
37}
38
39/** The /context breakdown, estimated locally. */
40export type CockpitBreakdown = {
41  rows: CockpitContextRow[]
42  /** The token count at which auto-compaction runs, when it is on. */
43  compactAt: number | null
44  /** 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