A Claude Code mod: it runs a hooks module inside Claude Code with your permissions. Shows usage limits, context, cost and the Specnaut chain above the prompt…

A Claude Code mod for developers who work with Specnaut in Claude Code. It shows how much of your usage limits is spent, keeps a local history of what your work costs, follows the /specnaut chain as the autopilot runs it, and stops the autopilot cleanly before a limit would cut a phase off halfway.
A band above the prompt, sized to your terminal:
5h 82% ↻2h10 · 7d 31% ↻3d4h · ctx 48% · $3.12 · ▸ plan ✓ tasks ✓ implement ● review ○ merge ○
A narrow terminal drops the reset times and the chain's detail first. The windows stay.
A toast announces each window once when it crosses the warning threshold, and once more at the hold threshold. It does not repeat on every turn.
/cockpit opens a pane with the limits, this session, the last seven days (cost and the 5-hour peak per day) and the branches that cost the most. /cockpit hide and /cockpit show toggle the band for the session.
Since v5, the chain runs on autopilot after the plan: implement, review, merge, push. A usage limit reached in the middle of that run can leave a merge without its push, or a review whose findings were never applied.
When a usage window is at or above the hold threshold (90% by default), the cockpit refuses the call that starts implement, review or merge. Refusing that call stops the chain at the boundary between two phases. The agent then tells you the chain is paused, with the figures, and that /specnaut <phase> resumes it after the reset. If you tell it to continue anyway, it continues, and the same window does not hold it again.
The hold is the only thing the cockpit ever refuses. It touches no other tool and no other skill, and if one of its hooks fails, the call goes through.
Set these in /config or with /plugin configure specnaut-cockpit@specnaut-marketplace:
| Option | Default | Effect |
|---|---|---|
hold_at | 90 | Window percentage that holds the autopilot. 100 turns the hold off. |
warn_at | 80 | Window percentage shown in yellow and announced. |
band | on | Off keeps only /cockpit and the hold. |
The band, the pane and the toasts appear in Claude Code in a terminal and in the Code tab of the desktop app. In the IDE extension's chat panel and in claude -p, nothing is drawn, but the hold still applies. Rate-limit windows appear on plans that have them. With an API key, the band shows context, cost and the chain.
The history (cost and peaks per day, cost per branch, kept for 90 days) is stored in the mod's own store on your machine. Nothing is sent anywhere. The mod reads the current branch with git rev-parse, and that is the only process it starts.
/plugin marketplace add specnaut/specnaut-marketplace
/plugin install specnaut-cockpit@specnaut-marketplace
Projects that specnaut init scaffolds for Claude Code declare the cockpit in .claude/settings.json, so Claude Code offers to install it when you trust the project. Requires Claude Code v2.1.287 or later.
The logic lives in hooks/core/, as pure TypeScript with no engine imports. It is tested by deno test tests/cockpit/ in the specnaut-cli repository. hooks/register.tsx connects it to Claude Code, and tests/*.test.tsx checks that connection:
claude plugin validate mods/specnaut-cockpit
claude plugin test mods/specnaut-cockpit
claude --plugin-dir mods/specnaut-cockpit # try it in a sessionhooks/register.tsx 245 lines1// Specnaut Cockpit — the hooks module.
2//
3// Wiring only: every decision is made in ./core/, which is pure and covered by
4// `deno test` in the specnaut-cli repository. This file reads what Claude Code
5// pushes, keeps it in session state, and draws it.
6//
7// What it does, and the one place it acts rather than observes:
8// - draws a band above the prompt: usage windows, context, cost, chain;
9// - announces a window once per threshold, as a toast;
10// - keeps a local history of cost and peaks per day and per git branch;
11// - serves `/cockpit` (a pane), `/cockpit hide` and `/cockpit show`;
12// - REFUSES one kind of tool call: the Skill call that starts `implement`,
13// `review` or `merge` of the `specnaut` skill while a usage window is at
14// or above the hold threshold. Nothing else is ever refused or rewritten.
15
16import { atom, read, update } from "claude-code";
17import type { EngineInterface, Register } from "claude-code";
18
19import { alertsFor } from "./core/alerts.ts";
20import { bandSegments, type Segment } from "./core/band.ts";
21import { IDLE, phaseOf, phaseOfPrompt, promptSubmitted, turnEnded } from "./core/chain.ts";
22import { asHistory, costDelta, localDay, prune, record } from "./core/history.ts";
23import { holdFor, lift } from "./core/hold.ts";
24import { paneRows } from "./core/pane.ts";
25import { type Reading, thresholdsFrom } from "./core/usage.ts";
26
27const PANE = "cockpit";
28const HISTORY_KEY = "history";
29
30const reading = atom({ plugin: "specnaut-cockpit", key: "reading" } as const, null);
31const chain = atom({ plugin: "specnaut-cockpit", key: "chain" } as const, IDLE);
32const isHidden = atom({ plugin: "specnaut-cockpit", key: "isHidden" } as const, false);
33const alerts = atom({ plugin: "specnaut-cockpit", key: "alerts" } as const, {});
34const held = atom({ plugin: "specnaut-cockpit", key: "held" } as const, []);
35const lifted = atom({ plugin: "specnaut-cockpit", key: "lifted" } as const, {});
36const costSeen = atom({ plugin: "specnaut-cockpit", key: "costSeen" } as const, 0);
37
38type Measured = {
39 context?: { percent?: number };
40 rateLimits?: readonly { kind: string; percentUsed: number; resetsAt?: string }[];
41 cost?: { usd: number };
42};
43
44function toReading(m: Measured): Reading {
45 return {
46 windows: (m.rateLimits ?? []).map((w) => ({
47 kind: w.kind,
48 percentUsed: w.percentUsed,
49 resetsAt: w.resetsAt,
50 })),
51 contextPercent: m.context?.percent,
52 costUsd: m.cost?.usd,
53 };
54}
55
56const COLOR: Record<Segment["tone"], { color?: string; dimColor?: boolean }> = {
57 normal: {},
58 dim: { dimColor: true },
59 warn: { color: "yellow" },
60 alert: { color: "red" },
61};
62
63// The branch a cost is recorded against, refreshed at most once a minute.
64let branch: { key: string; label: string } | undefined;
65let branchAt = Number.NEGATIVE_INFINITY;
66
67async function currentBranch($: EngineInterface) {
68 const now = await $.clock.now();
69 if (now - branchAt < 60_000) return branch;
70 branchAt = now;
71 try {
72 const top = await $.process.run(["git", "rev-parse", "--show-toplevel"], { timeoutMs: 5_000 });
73 const ref = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
74 timeoutMs: 5_000,
75 });
76 if (top.exitCode === 0 && ref.exitCode === 0) {
77 const root = top.stdout.trim();
78 const name = ref.stdout.trim();
79 branch = { key: `${root}#${name}`, label: `${root.split("/").at(-1)} · ${name}` };
80 } else {
81 branch = undefined;
82 }
83 } catch {
84 branch = undefined;
85 }
86 return branch;
87}
88
89export const register: Register = (on, options) => {
90 const opts = options as Record<string, unknown> | undefined;
91 const t = thresholdsFrom(opts);
92 const showBand = opts?.band !== false;
93 const offsetMinutes = -new Date().getTimezoneOffset();
94
95 on("session.start", async ($, e, next) => {
96 const started = await next(e);
97 await $.command.register({
98 name: "cockpit",
99 description: "Usage limits, cost history and the Specnaut chain (hide | show)",
100 argumentHint: "[hide|show]",
101 immediate: true,
102 });
103 const usage = await $.session.usage();
104 await update($, reading, () => toReading(usage));
105 // The baseline the history counts from: a resumed session's earlier cost
106 // was recorded when it was spent.
107 await update($, costSeen, () => usage.cost?.usd ?? 0);
108 // Reset times read as "in 2h10": redraw once a minute so they count down.
109 $.clock.every(60_000, () => $.ui.invalidate("ui.render"));
110 return started;
111 });
112
113 on("session.measure", async ($, e, next) => {
114 const r = toReading(e);
115 await update($, reading, () => r);
116
117 const now = await $.clock.now();
118 const steps = t.holdAt < 100 ? [t.warnAt, t.holdAt] : [t.warnAt];
119 const memo = await read($, alerts);
120 const out = alertsFor(r.windows, memo, steps, now);
121 await update($, alerts, () => out.memo);
122 for (const text of out.toasts) $.ui.toast(text);
123
124 const seen = await read($, costSeen);
125 const delta = costDelta(seen, r.costUsd);
126 if (r.costUsd !== undefined) await update($, costSeen, () => r.costUsd as number);
127 const day = localDay(now, offsetMinutes);
128 const history = record(asHistory(await $.store.get(HISTORY_KEY)), {
129 day,
130 costDelta: delta,
131 windows: r.windows,
132 branch: delta > 0 ? await currentBranch($) : undefined,
133 });
134 await $.store.set(HISTORY_KEY, prune(history, day));
135
136 return next(e);
137 });
138
139 // The quota hold, and the chain's progress as the autopilot crosses phases.
140 on("tool.call", { tool: "Skill" }, async ($, e, next) => {
141 const phase = phaseOf(e.skill, e.args);
142 if (!phase) return next(e);
143 const r = await read($, reading);
144 const hold = holdFor(
145 phase,
146 r?.windows ?? [],
147 t.holdAt,
148 await read($, lifted),
149 await $.clock.now(),
150 );
151 if (hold) {
152 await update($, held, () => [hold.window]);
153 $.ui.toast(
154 `Autopilot held before ${phase}: ${hold.window.kind} at ${
155 Math.round(hold.window.percentUsed)
156 }%`,
157 );
158 return { deny: hold.reason };
159 }
160 await update($, chain, (s) => promptSubmitted(s, phase));
161 return next(e);
162 }).catch((_$, e, next) => next(e)); // a cockpit fault never blocks a tool call
163
164 on("prompt.submit", async ($, e, next) => {
165 // The person's next message answers a hold: if they ask to go on, the
166 // same window does not hold the chain again.
167 const pending = await read($, held);
168 if (pending.length > 0) {
169 await update($, lifted, (l) => lift(l, pending));
170 await update($, held, () => []);
171 }
172 await update($, chain, (s) => promptSubmitted(s, phaseOfPrompt(e.text)));
173 return next(e);
174 }).catch((_$, e, next) => next(e)); // nor a prompt
175
176 on("turn.complete", async ($, e, next) => {
177 await update($, chain, turnEnded);
178 return next(e);
179 });
180
181 on("session.end", async ($, e, next) => {
182 if (e.reason === "clear") await update($, chain, () => IDLE);
183 return next(e);
184 });
185
186 on("command.run", { command: "cockpit" }, async ($, e) => {
187 const arg = e.args.trim();
188 if (arg === "hide") {
189 await update($, isHidden, () => true);
190 return { text: "Cockpit band hidden for this session. /cockpit show brings it back." };
191 }
192 if (arg === "show") {
193 await update($, isHidden, () => false);
194 return { text: "Cockpit band shown." };
195 }
196 await $.ui.open({ id: PANE, title: "Specnaut Cockpit" });
197 return { text: "Cockpit pane opened." };
198 });
199
200 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
201 if (!showBand || e.props.hasSurvey || (await read($, isHidden))) return next(e);
202 const segments = bandSegments(
203 await read($, reading),
204 await read($, chain),
205 t,
206 e.props.bodyColumns,
207 await $.clock.now(),
208 );
209 if (segments.length === 0) return next(e);
210 const { Box, Text } = $.ui.resolve(e);
211 return (
212 <Box key="cockpit-band">
213 <Text wrap="truncate-end">
214 {segments.map((s, i) => <Text key={`s${i}`} {...COLOR[s.tone]}>{s.text}</Text>)}
215 </Text>
216 </Box>
217 );
218 });
219
220 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
221 const { Box, Text } = $.ui.resolve(e);
222 const now = await $.clock.now();
223 const today = localDay(now, offsetMinutes);
224 const rows = paneRows({
225 reading: await read($, reading),
226 chain: await read($, chain),
227 history: asHistory(await $.store.get(HISTORY_KEY)),
228 today,
229 thresholds: t,
230 now,
231 });
232 return (
233 <Box flexDirection="column">
234 {rows.map((row, r) => (
235 <Text key={`r${r}`} wrap="truncate-end">
236 {row.length === 0
237 ? " "
238 : row.map((s, i) => <Text key={`r${r}s${i}`} {...COLOR[s.tone]}>{s.text}</Text>)}
239 </Text>
240 ))}
241 </Box>
242 );
243 });
244};
245hooks/core/alerts.ts 38 lines1// One toast per threshold per window — not one per turn.
2//
3// `session.measure` fires after every turn and whenever a window moves a whole
4// point, so a window sitting at 84% would toast on every one of them. The memo
5// records, per window kind, the highest threshold already announced and the
6// reset it was announced for; a new reset (a new window) starts it over.
7
8import { longLabel, untilReset, type Window } from "./usage.ts";
9
10export type AlertMemo = Record<string, { resetsAt?: string; announced: number }>;
11
12export function alertsFor(
13 windows: readonly Window[],
14 memo: AlertMemo,
15 thresholds: readonly number[],
16 now: number,
17): { toasts: string[]; memo: AlertMemo } {
18 const steps = [...new Set(thresholds)].filter((t) => t > 0 && t <= 100).sort((a, b) => a - b);
19 const next: AlertMemo = { ...memo };
20 const toasts: string[] = [];
21 for (const w of windows) {
22 const prior = next[w.kind];
23 const announced = prior && prior.resetsAt === w.resetsAt ? prior.announced : 0;
24 const crossed = steps.filter((t) => w.percentUsed >= t).at(-1);
25 if (crossed !== undefined && crossed > announced) {
26 const eta = untilReset(w.resetsAt, now);
27 toasts.push(
28 `${longLabel(w.kind)} usage window at ${Math.round(w.percentUsed)}%` +
29 (eta ? ` — resets in ${eta}` : ""),
30 );
31 next[w.kind] = { resetsAt: w.resetsAt, announced: crossed };
32 } else {
33 next[w.kind] = { resetsAt: w.resetsAt, announced };
34 }
35 }
36 return { toasts, memo: next };
37}
38hooks/core/band.ts 82 lines1// The band above the prompt, as segments of text and tone, sized to its width.
2//
3// It is built in decreasing detail and the first form that fits wins, so a
4// narrow terminal loses the reset times and the chain's detail before it loses
5// a limit: the windows are the reason the band exists.
6
7import { chainLine, chainShort, type ChainState } from "./chain.ts";
8import {
9 formatCost,
10 formatPercent,
11 ordered,
12 type Reading,
13 shortLabel,
14 type Thresholds,
15 type Tone,
16 toneOf,
17 untilReset,
18} from "./usage.ts";
19
20export type Segment = { text: string; tone: Tone | "dim" };
21
22type Detail = {
23 resets: boolean;
24 context: boolean;
25 cost: boolean;
26 chain: "full" | "short" | "none";
27};
28
29const FORMS: Detail[] = [
30 { resets: true, context: true, cost: true, chain: "full" },
31 { resets: true, context: true, cost: true, chain: "short" },
32 { resets: false, context: true, cost: true, chain: "short" },
33 { resets: false, context: false, cost: false, chain: "short" },
34 { resets: false, context: false, cost: false, chain: "none" },
35];
36
37const SEP: Segment = { text: " · ", tone: "dim" };
38
39function build(r: Reading | null, chain: ChainState, t: Thresholds, now: number, d: Detail) {
40 const groups: Segment[][] = [];
41 for (const w of ordered(r?.windows ?? [])) {
42 const eta = d.resets ? untilReset(w.resetsAt, now) : undefined;
43 groups.push([
44 { text: `${shortLabel(w.kind)} `, tone: "dim" },
45 { text: formatPercent(w.percentUsed), tone: toneOf(w.percentUsed, t) },
46 ...(eta ? [{ text: ` ↻${eta}`, tone: "dim" as const }] : []),
47 ]);
48 }
49 if (d.context && r?.contextPercent !== undefined) {
50 groups.push([
51 { text: "ctx ", tone: "dim" },
52 { text: formatPercent(r.contextPercent), tone: r.contextPercent >= 85 ? "warn" : "normal" },
53 ]);
54 }
55 if (d.cost && r?.costUsd !== undefined) {
56 groups.push([{ text: formatCost(r.costUsd), tone: "normal" }]);
57 }
58 const line = d.chain === "full" ? chainLine(chain) : d.chain === "short" ? chainShort(chain) : "";
59 if (line) groups.push([{ text: `▸ ${line}`, tone: "normal" }]);
60 return groups.flatMap((g, i) => (i === 0 ? g : [SEP, ...g]));
61}
62
63export function width(segments: readonly Segment[]): number {
64 return segments.reduce((n, s) => n + [...s.text].length, 0);
65}
66
67/** The most detailed form that fits in `columns`; [] when there is nothing to show. */
68export function bandSegments(
69 r: Reading | null,
70 chain: ChainState,
71 t: Thresholds,
72 columns: number,
73 now: number,
74): Segment[] {
75 let last: Segment[] = [];
76 for (const d of FORMS) {
77 last = build(r, chain, t, now, d);
78 if (width(last) <= columns) return last;
79 }
80 return last;
81}
82hooks/core/chain.ts 92 lines1// Where the Specnaut chain stands, read from how its phases are invoked.
2//
3// The autopilot crosses each boundary by calling the Skill tool on the
4// `specnaut` skill with the next phase as its argument; a person starts it by
5// typing `/specnaut <phase>`. Both name the phase as the first argument that is
6// not a flag. The Skill call returns as soon as the phase's instructions are
7// loaded, so what can be known is the phase in progress, not its completion:
8// a phase is done when the next one starts, and the chain is done when the turn
9// that ran `merge` ends.
10
11export const PHASES = ["plan", "tasks", "implement", "review", "merge"] as const;
12export type Phase = typeof PHASES[number];
13
14export type ChainState = {
15 /** The phase in progress; null when no chain is running. */
16 current: Phase | null;
17 /** True once the turn that ran `merge` has ended. */
18 finished: boolean;
19};
20
21export const IDLE: ChainState = { current: null, finished: false };
22
23function isPhase(s: string): s is Phase {
24 return (PHASES as readonly string[]).includes(s);
25}
26
27/**
28 * True for Specnaut's router: `specnaut` as a project scaffolds it, and
29 * `specnaut-plugin:specnaut` as the plugin serves it. Not any plugin's skill
30 * that happens to be called `specnaut` — the hold refuses calls, so it names
31 * exactly whose.
32 */
33export function isSpecnautSkill(skill: string): boolean {
34 const name = skill.replace(/^\//, "");
35 return name === "specnaut" || name === "specnaut-plugin:specnaut";
36}
37
38/** The chain phase a `specnaut` invocation starts, or null (audits, unknown, none). */
39export function phaseOf(skill: string, args: string | undefined): Phase | null {
40 if (!isSpecnautSkill(skill)) return null;
41 const first = (args ?? "").trim().split(/\s+/).find((t) => t.length > 0 && !t.startsWith("--"));
42 return first !== undefined && isPhase(first) ? first : null;
43}
44
45/** The phase a typed prompt starts: `/specnaut plan …`, `/specnaut-plugin:specnaut merge`. */
46export function phaseOfPrompt(text: string): Phase | null {
47 const m = text.trimStart().match(/^\/((?:specnaut-plugin:)?specnaut)(?:\s+([\s\S]*))?$/);
48 return m ? phaseOf(m[1] ?? "", m[2]) : null;
49}
50
51export function advance(state: ChainState, phase: Phase): ChainState {
52 // `plan` starts a new chain; any other phase resumes or continues one.
53 void state;
54 return { current: phase, finished: false };
55}
56
57/** The turn ended: a chain that reached `merge` is complete. */
58export function turnEnded(state: ChainState): ChainState {
59 return state.current === "merge" ? { current: "merge", finished: true } : state;
60}
61
62/** A prompt that is not a chain phase clears a finished chain from view. */
63export function promptSubmitted(state: ChainState, phase: Phase | null): ChainState {
64 if (phase) return advance(state, phase);
65 return state.finished ? IDLE : state;
66}
67
68export type StepMark = "done" | "current" | "todo";
69
70export function steps(state: ChainState): { phase: Phase; mark: StepMark }[] {
71 if (!state.current) return [];
72 const at = PHASES.indexOf(state.current);
73 return PHASES.map((phase, i) => ({
74 phase,
75 mark: state.finished || i < at ? "done" : i === at ? "current" : "todo",
76 }));
77}
78
79const GLYPH: Record<StepMark, string> = { done: "✓", current: "●", todo: "○" };
80
81/** `plan ✓ tasks ✓ implement ● review ○ merge ○`, or `` when idle. */
82export function chainLine(state: ChainState): string {
83 return steps(state).map((s) => `${s.phase} ${GLYPH[s.mark]}`).join(" ");
84}
85
86/** `implement 3/5`, `merged ✓`, or `` when idle — for a narrow band. */
87export function chainShort(state: ChainState): string {
88 if (!state.current) return "";
89 if (state.finished) return "merged ✓";
90 return `${state.current} ${PHASES.indexOf(state.current) + 1}/${PHASES.length}`;
91}
92hooks/core/history.ts 119 lines1// Usage over time: per local day and per git branch.
2//
3// The rate-limit windows are rolling and carry no daily figure, and the session
4// cost starts over with every session. So the cockpit keeps its own ledger:
5// each increase of a session's cost is added to the day it happened on and to
6// the branch that was checked out, and each day keeps the highest reading of
7// every window. It lives in the mod's persistent store, on this machine only.
8
9import type { Window } from "./usage.ts";
10
11export type DayStat = {
12 costUsd: number;
13 /** Highest `percentUsed` seen that day, per window kind. */
14 peak: Record<string, number>;
15};
16
17export type BranchStat = {
18 /** `<repo> · <branch>`, for display. */
19 label: string;
20 costUsd: number;
21 /** The last day a cost was recorded against it. */
22 lastDay: string;
23};
24
25export type History = {
26 v: 1;
27 days: Record<string, DayStat>;
28 branches: Record<string, BranchStat>;
29};
30
31export const RETENTION_DAYS = 90;
32
33export function emptyHistory(): History {
34 return { v: 1, days: {}, branches: {} };
35}
36
37/** A stored value read back: anything not shaped like a History is a fresh one. */
38export function asHistory(raw: unknown): History {
39 const h = raw as Partial<History> | null | undefined;
40 if (!h || h.v !== 1 || typeof h.days !== "object" || typeof h.branches !== "object") {
41 return emptyHistory();
42 }
43 return { v: 1, days: { ...h.days }, branches: { ...h.branches } };
44}
45
46/** `YYYY-MM-DD` of `now` at a UTC offset of `offsetMinutes` (east positive). */
47export function localDay(now: number, offsetMinutes: number): string {
48 return new Date(now + offsetMinutes * 60_000).toISOString().slice(0, 10);
49}
50
51function addDays(day: string, n: number): string {
52 return new Date(Date.parse(`${day}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
53}
54
55/**
56 * How much a session's running cost grew since `seen`. A total below `seen`
57 * means the session's ledger started over (`/clear`), and everything in it is
58 * new; a non-finite total adds nothing.
59 */
60export function costDelta(seen: number, total: number | undefined): number {
61 if (total === undefined || !Number.isFinite(total)) return 0;
62 return total >= seen ? total - seen : total;
63}
64
65export type Sample = {
66 day: string;
67 /** How much the session's cost grew since the last sample. */
68 costDelta: number;
69 windows: readonly Window[];
70 branch?: { key: string; label: string };
71};
72
73export function record(h: History, s: Sample): History {
74 const days = { ...h.days };
75 const prior = days[s.day] ?? { costUsd: 0, peak: {} };
76 const peak = { ...prior.peak };
77 for (const w of s.windows) peak[w.kind] = Math.max(peak[w.kind] ?? 0, w.percentUsed);
78 const delta = Number.isFinite(s.costDelta) && s.costDelta > 0 ? s.costDelta : 0;
79 days[s.day] = { costUsd: prior.costUsd + delta, peak };
80
81 const branches = { ...h.branches };
82 if (s.branch && delta > 0) {
83 const b = branches[s.branch.key];
84 branches[s.branch.key] = {
85 label: s.branch.label,
86 costUsd: (b?.costUsd ?? 0) + delta,
87 lastDay: s.day,
88 };
89 }
90 return { v: 1, days, branches };
91}
92
93/** Drop days and branches older than `keep` days before `today`. */
94export function prune(h: History, today: string, keep = RETENTION_DAYS): History {
95 const oldest = addDays(today, -(keep - 1));
96 const days = Object.fromEntries(Object.entries(h.days).filter(([d]) => d >= oldest));
97 const branches = Object.fromEntries(
98 Object.entries(h.branches).filter(([, b]) => b.lastDay >= oldest),
99 );
100 return { v: 1, days, branches };
101}
102
103/** The last `n` days ending today, oldest first, empty days included. */
104export function lastDays(h: History, today: string, n = 7): ({ day: string } & DayStat)[] {
105 return Array.from({ length: n }, (_, i) => {
106 const day = addDays(today, i - (n - 1));
107 return { day, ...(h.days[day] ?? { costUsd: 0, peak: {} }) };
108 });
109}
110
111/** The costliest branches with a cost in the last `n` days. */
112export function topBranches(h: History, today: string, n = 5, sinceDays = 7): BranchStat[] {
113 const oldest = addDays(today, -(sinceDays - 1));
114 return Object.values(h.branches)
115 .filter((b) => b.lastDay >= oldest)
116 .sort((a, b) => b.costUsd - a.costUsd)
117 .slice(0, n);
118}
119hooks/core/hold.ts 68 lines1// The quota hold: stop the autopilot at a phase boundary rather than let a
2// usage limit cut a phase off halfway.
3//
4// Since the chain stopped asking before it merges and pushes, a limit reached
5// mid-run can leave a merge without its push, or a review whose findings were
6// never applied. The boundary between two phases is the last clean place to
7// stop, so the hold refuses the Skill call that would start the next expensive
8// phase, and its reason tells the agent to stop and say so.
9//
10// Only phases that are expensive or unsafe to cut are guarded: `implement`
11// (the bulk of the work), `review` (it dispatches every expert seat) and
12// `merge` (a cut there is the one that leaves a half-landed change).
13
14import { longLabel, ordered, untilReset, type Window } from "./usage.ts";
15import type { Phase } from "./chain.ts";
16
17export const GUARDED: readonly Phase[] = ["implement", "review", "merge"];
18
19/**
20 * Windows the person chose to continue past, by kind, with the reset they
21 * were looking at. A hold says once per window; after the person says to go on,
22 * it does not say again until that window resets.
23 */
24export type Lifted = Record<string, string | undefined>;
25
26export type Hold = { window: Window; reason: string };
27
28export function holdFor(
29 phase: Phase,
30 windows: readonly Window[],
31 holdAt: number,
32 lifted: Lifted,
33 now: number,
34): Hold | null {
35 if (holdAt >= 100 || !GUARDED.includes(phase)) return null;
36 const over = ordered(windows).find((w) =>
37 w.percentUsed >= holdAt &&
38 !(w.kind in lifted && lifted[w.kind] === w.resetsAt) &&
39 // A reading taken before its window reset says nothing about the new one.
40 !(w.resetsAt !== undefined && Date.parse(w.resetsAt) <= now)
41 );
42 if (!over) return null;
43 const eta = untilReset(over.resetsAt, now);
44 const pct = Math.round(over.percentUsed);
45 return {
46 window: over,
47 reason: [
48 `Specnaut cockpit — quota hold before \`${phase}\`.`,
49 `The ${longLabel(over.kind)} usage window is at ${pct}%` +
50 (eta ? ` and resets in ${eta}.` : "."),
51 `Starting \`${phase}\` now risks the limit cutting it off half-done.`,
52 "Stop the chain here. Tell the user it is paused at a clean phase boundary," +
53 ` give them the figures above, and say that \`/specnaut ${phase}\` resumes it` +
54 (eta ? " after the reset." : " once the window has room."),
55 "Do not run the phase's steps by any other means.",
56 "If the user tells you to continue anyway, invoke the phase again:" +
57 " the hold does not repeat for this window.",
58 ].join(" "),
59 };
60}
61
62/** The person answered a hold: lift it for the windows it was about. */
63export function lift(lifted: Lifted, held: readonly Window[]): Lifted {
64 const next = { ...lifted };
65 for (const w of held) next[w.kind] = w.resetsAt;
66 return next;
67}
68hooks/core/pane.ts 116 lines1// The `/cockpit` pane: limits, this session, the last seven days, and the
2// branches that cost the most. Rows of segments, so the hooks module only maps
3// them to elements.
4
5import type { Segment } from "./band.ts";
6import { chainLine, type ChainState } from "./chain.ts";
7import { type History, lastDays, topBranches } from "./history.ts";
8import {
9 formatCost,
10 formatPercent,
11 longLabel,
12 ordered,
13 type Reading,
14 type Thresholds,
15 toneOf,
16 untilReset,
17} from "./usage.ts";
18
19export type Row = Segment[];
20
21const BAR = 10;
22const WEEKDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
23
24function bar(percent: number): string {
25 const filled = Math.max(0, Math.min(BAR, Math.round((percent / 100) * BAR)));
26 return "█".repeat(filled) + "░".repeat(BAR - filled);
27}
28
29function heading(text: string): Row {
30 return [{ text, tone: "normal" }];
31}
32
33function dayLabel(day: string): string {
34 const d = new Date(`${day}T00:00:00Z`);
35 return `${WEEKDAY[d.getUTCDay()]} ${day.slice(8)}`;
36}
37
38export function paneRows(input: {
39 reading: Reading | null;
40 chain: ChainState;
41 history: History;
42 today: string;
43 thresholds: Thresholds;
44 now: number;
45}): Row[] {
46 const { reading, chain, history, today, thresholds: t, now } = input;
47 const rows: Row[] = [heading("Limits")];
48 const windows = ordered(reading?.windows ?? []);
49 if (windows.length === 0) {
50 rows.push([{
51 text:
52 " No rate-limit reading yet — it arrives with the first response on a plan with limits.",
53 tone: "dim",
54 }]);
55 }
56 for (const w of windows) {
57 const eta = untilReset(w.resetsAt, now);
58 rows.push([
59 { text: ` ${longLabel(w.kind).padEnd(12)}`, tone: "dim" },
60 {
61 text: `${bar(w.percentUsed)} ${formatPercent(w.percentUsed).padStart(6)}`,
62 tone: toneOf(w.percentUsed, t),
63 },
64 { text: eta ? ` resets in ${eta}` : "", tone: "dim" },
65 ]);
66 }
67 rows.push(
68 [{
69 text: t.holdAt >= 100
70 ? " The quota hold is off."
71 : ` The autopilot holds before implement, review and merge at ${t.holdAt}%.`,
72 tone: "dim",
73 }],
74 [],
75 heading("This session"),
76 );
77 const parts = [
78 reading?.contextPercent !== undefined ? `context ${formatPercent(reading.contextPercent)}` : "",
79 reading?.costUsd !== undefined ? `cost ${formatCost(reading.costUsd)}` : "",
80 ].filter(Boolean);
81 rows.push([{ text: ` ${parts.join(" · ") || "nothing measured yet"}`, tone: "normal" }]);
82 const line = chainLine(chain);
83 if (line) rows.push([{ text: ` chain ${line}`, tone: "normal" }]);
84
85 rows.push([], heading("Last 7 days"));
86 const days = lastDays(history, today);
87 for (const d of days) {
88 const peak = d.peak.five_hour;
89 rows.push([
90 { text: ` ${dayLabel(d.day)}${d.day === today ? " (today)" : ""}`.padEnd(18), tone: "dim" },
91 { text: formatCost(d.costUsd).padStart(8), tone: "normal" },
92 { text: peak !== undefined ? ` peak 5h ${formatPercent(peak)}` : "", tone: "dim" },
93 ]);
94 }
95 rows.push([
96 { text: " total".padEnd(18), tone: "dim" },
97 { text: formatCost(days.reduce((n, d) => n + d.costUsd, 0)).padStart(8), tone: "normal" },
98 ]);
99
100 const branches = topBranches(history, today);
101 if (branches.length > 0) {
102 rows.push([], heading("Branches, last 7 days"));
103 for (const b of branches) {
104 rows.push([
105 { text: formatCost(b.costUsd).padStart(10), tone: "normal" },
106 { text: ` ${b.label}`, tone: "dim" },
107 ]);
108 }
109 }
110 rows.push([], [{
111 text: "Kept on this machine only. /cockpit hide · /cockpit show",
112 tone: "dim",
113 }]);
114 return rows;
115}
116hooks/core/usage.ts 114 lines1// The cockpit's reading of the session: rate-limit windows, context fill and
2// cost, and how each is put into words. Pure — no engine, no clock, no I/O —
3// so the hooks module stays wiring and this stays testable under `deno test`.
4
5/** One rate-limit window as the engine reports it. */
6export type Window = {
7 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
8 kind: string;
9 /** 0 to 100, past 100 on an exceeded spend limit. */
10 percentUsed: number;
11 /** ISO 8601. */
12 resetsAt?: string;
13};
14
15/** What the band and the pane draw from: the last measurement. */
16export type Reading = {
17 windows: Window[];
18 /** Context fill, 0 to 100, once a response reported one. */
19 contextPercent?: number;
20 /** The session's cost so far, in US dollars. */
21 costUsd?: number;
22};
23
24export type Tone = "normal" | "warn" | "alert";
25
26/** The cockpit's two thresholds, both percentages of a window. */
27export type Thresholds = {
28 /** A window at or above this is shown in the warning colour and toasted. */
29 warnAt: number;
30 /** A window at or above this holds the autopilot. 100 turns the hold off. */
31 holdAt: number;
32};
33
34export const DEFAULT_THRESHOLDS: Thresholds = { warnAt: 80, holdAt: 90 };
35
36/**
37 * The thresholds from the plugin's options, held to the bounds plugin.json
38 * declares (hold 50–100, warn 10–100): a stored value outside them is
39 * clamped, a missing or non-numeric one takes the default.
40 */
41export function thresholdsFrom(options: Record<string, unknown> | undefined): Thresholds {
42 const n = (v: unknown, d: number, lo: number, hi: number) =>
43 typeof v === "number" && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : d;
44 return {
45 warnAt: n(options?.warn_at, DEFAULT_THRESHOLDS.warnAt, 10, 100),
46 holdAt: n(options?.hold_at, DEFAULT_THRESHOLDS.holdAt, 50, 100),
47 };
48}
49
50const LABELS: Record<string, { short: string; long: string }> = {
51 five_hour: { short: "5h", long: "5-hour" },
52 seven_day: { short: "7d", long: "weekly" },
53 spend_limit: { short: "spend", long: "spend-limit" },
54};
55
56/** `5h`, `7d`, `spend`, or the kind itself for one this build does not know. */
57export function shortLabel(kind: string): string {
58 return LABELS[kind]?.short ?? kind;
59}
60
61/** `5-hour`, `weekly`, `spend-limit` — for sentences. */
62export function longLabel(kind: string): string {
63 return LABELS[kind]?.long ?? kind.replaceAll("_", " ");
64}
65
66/** The windows in a stable order: 5h, 7d, spend, then the rest by name. */
67export function ordered(windows: readonly Window[]): Window[] {
68 const rank = (k: string) => {
69 const i = Object.keys(LABELS).indexOf(k);
70 return i === -1 ? Object.keys(LABELS).length : i;
71 };
72 return [...windows].sort((a, b) => rank(a.kind) - rank(b.kind) || a.kind.localeCompare(b.kind));
73}
74
75export function toneOf(percent: number, t: Thresholds): Tone {
76 if (t.holdAt < 100 && percent >= t.holdAt) return "alert";
77 if (percent >= t.warnAt) return "warn";
78 return "normal";
79}
80
81/**
82 * Time until `resetsAt`, coarse enough to read at a glance: `45m`, `2h10`,
83 * `3d4h`, `<1m`. Relative on purpose — it needs no time zone, and "how long do
84 * I wait" is the question a reset time answers. Undefined when unknown or
85 * unparseable; `<1m` once it has passed (the next reading will move it).
86 */
87export function untilReset(resetsAt: string | undefined, now: number): string | undefined {
88 if (!resetsAt) return undefined;
89 const at = Date.parse(resetsAt);
90 if (Number.isNaN(at)) return undefined;
91 const minutes = Math.floor((at - now) / 60_000);
92 if (minutes < 1) return "<1m";
93 if (minutes < 60) return `${minutes}m`;
94 const hours = Math.floor(minutes / 60);
95 if (hours < 24) {
96 const rest = minutes % 60;
97 return rest === 0 ? `${hours}h` : `${hours}h${String(rest).padStart(2, "0")}`;
98 }
99 const days = Math.floor(hours / 24);
100 const restHours = hours % 24;
101 return restHours === 0 ? `${days}d` : `${days}d${restHours}h`;
102}
103
104/** `$0.04`, `$3.12`, `$124` — cents until the figure is large enough to drop them. */
105export function formatCost(usd: number): string {
106 if (!Number.isFinite(usd) || usd < 0) return "$0";
107 return usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`;
108}
109
110/** A whole or one-decimal percentage as the engine gives it: `62%`, `23.5%`. */
111export function formatPercent(p: number): string {
112 return `${Math.round(p * 10) / 10}%`;
113}
114types/index.d.ts 39 lines1// The cockpit's session state, as Claude Code requires it: self-contained.
2// The shapes mirror ./hooks/core/ (Window, Reading, ChainState, AlertMemo,
3// Lifted); `tsc -p` over the mod holds the two together, because the hooks
4// module writes core values into these slots.
5
6export type CockpitWindow = { kind: string; percentUsed: number; resetsAt?: string };
7
8export type CockpitReading = {
9 windows: CockpitWindow[];
10 contextPercent?: number;
11 costUsd?: number;
12};
13
14export type CockpitChain = {
15 current: "plan" | "tasks" | "implement" | "review" | "merge" | null;
16 finished: boolean;
17};
18
19declare module "claude-code" {
20 interface PluginState {
21 "specnaut-cockpit": {
22 /** The last measurement the engine pushed. */
23 reading: CockpitReading | null;
24 /** Where the Specnaut chain stands. */
25 chain: CockpitChain;
26 /** The person hid the band for this session. */
27 isHidden: boolean;
28 /** Thresholds already announced, per window kind. */
29 alerts: Record<string, { resetsAt?: string; announced: number }>;
30 /** The windows of the last hold, until the person answers it. */
31 held: CockpitWindow[];
32 /** Windows the person chose to continue past: kind → the reset they saw. */
33 lifted: Record<string, string | undefined>;
34 /** The session's cost when it was last added to the history. */
35 costSeen: number;
36 };
37 }
38}
39