A cockpit above your Claude Code prompt. The duck reads the gauges: task progress and how long the work will take, pace, context, plan limits, checks and cost…

A cockpit above your Claude Code prompt. The duck reads the gauges.
quackpit is one Claude Code mod that replaces a dozen single-gauge mods. It draws a live instrument panel in the band above the prompt: task progress, how long the work in hand will take (a duckling swimming to a flag), pace, context left before auto-compact, plan limits with forecasts, checks and cost. Its pane is six views under a header that stays in view; the overview is how long the work will take, then eight instruments of one size, like a cockpit's six-pack: your pace right now, a fuel gauge for your whole account's five-hour window, what fills the context (one bar, a colour a source), the cost of each turn, your checks by kind, the files edited, a judgement of how the work is going, and the session. Press any of them for what it is made of. A small rubber duck sits in the Master Caution slot. It stays asleep until something needs you, then wakes up and says Quack:.
It speaks English, Korean, Japanese and Chinese (simplified and traditional).
No quackery: every number comes from a real session event, and every measured estimate is marked ~. The one estimate that is not measured, the opt-in AI ETA, always says it is the AI's.

<sub>A real Claude Code 2.1.296 session at 140 columns: Claude (Haiku) fixing a failing test, then running the full suite in the background. No task list: on Claude 5 models Claude Code does not offer its task tools unless you turn them on (see How long it will take).</sub>
~ and are only as precise as their data. The account dial says how old its readings are, and the AI ETA's accuracy is measured and shown, never promised.In a Claude Code session:
/plugin marketplace add imsungbin/quackpit-mod
/plugin install quackpit@quackpit
Or in one line: /plugin install quackpit --marketplace imsungbin/quackpit-mod. Pick a scope when asked (user is the usual one). The band appears at once. If the installer says some options are not set, the defaults are fine: /config changes them later.
To learn what each gauge means, run:
/quackpit help
It opens the pane with a guide beside the real gauges.
claude plugin marketplace update quackpit
claude plugin update quackpit@quackpit # restart Claude Code to apply
claude plugin disable quackpit@quackpit
claude plugin uninstall quackpit@quackpit
git clone https://github.com/imsungbin/quackpit-mod ~/projects/quackpit-mod
claude --plugin-dir ~/projects/quackpit-mod

Six tiles are on after install, in this order:
| Tile | What it shows | Where the number comes from |
|---|---|---|
| progress | With a task list: done/total in big digits, the current task, and a pond where a duckling swims toward a checkered flag, then ~2m left. Without one: what the turn is doing (Running pnpm test, Editing band.tsx) and turn 3 · 7 tools, then, once a turn runs long, longer than 90% of your turns, or the AI ETA's ~8m left (AI) if you turned it on. While Claude waits on you, where the band is in view (in a terminal Claude Code hides it behind the dialog, and the pane says it): ⏸ waiting on you · 1m05, and nothing counts down. Between turns: the last turn at minute resolution, or the background work Claude will come back to (make test-all · background) with its time left. | Claude's own TodoWrite, TaskCreate, TaskUpdate and TaskList calls, its subagents and background commands, Claude Code's agent list, the estimate below, and the lengths quackpit has recorded. |
| pace | New tokens an hour right now against your usual pace (amber from 1.5×, red from 1.8×), and whether the 5h window lasts to its reset (5h holds) or when it empties at this pace (empties 16:43). "Right now" weighs each response by how recent it is, half as much 5 minutes on, so the figure falls while you are idle. | Each model response's usage as it arrives: input, output and cache writes count, while cache reads do not. "Usual" is the median of your last 14 days, or this session's own average until 3 past days are kept. |
| context | How full the context is, and how far it is to the auto-compact point (84k to compact), with compactions so far (⟲1). | Claude Code's own measurement after each turn, and its local estimate of where auto-compact starts. No request is sent for either. |
| limits | The 5h and 7d windows: percent used, a dim projection to the reset (→ ~60%, or → full ~16:43 when it runs out first) and a reset countdown (↻2h12). | The plan windows Claude Code reports. The 5h forecast is a least-squares line through the last 90 minutes of readings; the 7d forecast is the average so far. |
| checks | The last test, typecheck, lint or build Claude ran, and how it came out: ✓ 48 passed 3.1 s, then tests · 2 edits since once files change after the last pass. While a check is failing, the failing run and its age, as the duck says it. A chain or a loop counts every check in it (types+tests). A check running in the background: ◌ background tests · 2m (usually ~4m). | The Bash call's exit status, the runner's summary line and the call's measured time (none when the call waited on a permission dialog); for a background run, the exit code in its notice. quackpit never runs anything itself. |
| cost | The session's cost, this turn's cost (— until the ledger has moved) and a tokens-per-turn sparkline. | Claude Code's own cost ledger (what /cost shows). |
Optional tiles: agents (subagents running, with their elapsed time), files (files edited, with edit counts), session (model, effort, permission mode, time up) and health (a judgement of how the work is going, from measured parts; see Health).
Only Claude's own work counts. Tool calls and turns inside a subagent, and tool calls that other plugins make, never move a gauge. quackpit does watch subagents start and end, so the estimate knows what Claude is waiting for, but their tokens and tools are never counted.
When the terminal is narrow, the rightmost tiles switch to compact faces one by one. Below 80 columns, or with rows 1, the band becomes one dense row. Anything that still does not fit becomes a +n that opens the pane. Click any tile to open the pane on that tile's detail.
The turn receipt. Under Claude Code's own line at the end of each turn (✻ Baked for 1m 4s), quackpit adds a dim line with what the turn cost: 48k tokens · $0.41 · tests ✓.
quackpit estimates how long the work in hand will take and draws the answer as a duckling swimming across a pond to a checkered flag. The water behind it is pale; the water still to cross is blue. Its place is the work done over the whole, and the words beside it say what is left (~2m left) and, in the pane, when it should be done (done ~16:40).
Claude's task list needs Claude Code's task tools, and Claude Code 2.1.296 offers them only to Claude 4 models and older. On the Claude 5 family (Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1), Claude has no task list unless you start Claude Code with
CLAUDE_CODE_ENABLE_TODO_TOOLS=1in its environment (or in theenvof yoursettings.json). Without a list, quackpit still estimates the subagents and background jobs Claude runs, and the opt-in AI ETA covers the rest of a long turn; the README's main animation shows a session without one. quackpit reads whichever task tools Claude has:TaskCreate,TaskUpdateandTaskList, orTodoWrite.
The work in hand is everything Claude will finish or come back to, and each part has a measured length:
pnpm dev · background 2h05). A monitor or a workflow Claude starts is followed from its call.The whole ends at the latest of them: parts that run at once are never added up, and none replaces another. When a part runs over, it gets half its usual length more, a minute at most, rather than an end in the past.
Waiting on you is not work. When Claude asks you a question, shows you its plan or waits for your OK in a permission dialog, the pane says ⏸ waiting on you · 1m05 and what it waits for, the duckling holds still, and nothing counts down. (In a terminal Claude Code draws no band while a dialog is open, so the pane is where a wait shows.) When you answer, every end that waited moves later by the wait. Waits are left out of every length quackpit records (tasks, subagents, turns and the working time), so a slow answer never makes the next estimate longer. An ask that auto mode or a hook settles by itself is no wait.

After the turn ends, the background work Claude will come back to keeps the estimate going. The band names it in place of Standing by (node --test · background, by its command where that reads as one) with the time it has left, and when the job's notice starts Claude's next turn, the duckling carries on across the same pond. A background check counts as a check: its exit code passes or fails it, and a failure wakes the duck.

The estimate uses only measured lengths, never Claude's own words about time, and it is always marked ~. Before anything has been measured, the duckling moves with the tasks done instead, with no time beside it. When tasks are added the far shore moves away and the duckling slips back, which is what the estimate says. A finished list puts the duckling at the flag, unless a subagent or a background job of Claude's is still running.

Every subagent, listed. The pane's timeline lists every subagent in the session with who started it: Claude, a workflow, another subagent, a plugin, or a teammate. Only the ones Claude started (and a workflow's) count in the estimate; the rest say not counted. A teammate idle between its turns, or an agent waiting on its own work, is alive but not running: the progress and AGENTS, which read the same answer, count it in one quiet line (3 teammates idle), never as running work. quackpit reads Claude Code's agent list as turns start and end, and every 5 seconds while one runs or waits, so a subagent it did not see start, such as a skill's fork, is listed too.
With no list, a fact instead of a countdown. Most turns have no task list, and their lengths vary widely, so quackpit does not guess a time for them. Once a turn has run longer than half of the turns it has recorded (20 at least, from this session and your recent ones), the hero says so: longer than 90% of your turns, rounded down, or longer than any of your last 30 turns.
Off by default. Turn it on with:
/quackpit ai on
or with the AI ETA row in /config. /quackpit ai off turns it off, and /quackpit ai says how it stands and what it spent today.
While it is on, once a turn has worked a minute with nothing measured covering its own work (a task list's pace, or a subagent of a known length it waits on), quackpit asks Claude Haiku, in the background, how many more minutes the turn will run. The answer shows as ~8m left (AI) in the band and the pane. It is one part of the whole: a subagent or a background job expected to end later still sets the end, and the duckling's pond follows whichever ends last.

It asks again when the turn outruns its answer (by 30 seconds or half the answer, whichever is more), after you answer a question or a permission dialog, and when the work changes shape: a subagent or a background job starts or ends, a task list appears, or the measured estimate it deferred to goes away. A re-ask tells Haiku its earlier answers, what happened since and Claude's latest words, and asks it to keep its estimate unless those change it. A revision shows as ~4m left (AI, revised), and the pane says what it rests on: Haiku's 2nd guess at 10:49 (1st ~2m) · not measured. Past an answer with a re-ask due, the band says past the AI's ~2m · asking again.
$.model.complete call: model haiku, effort low, a few tokens of reply, a 10-second limit, cancelled if the turn ends first. A call is a few hundred tokens (a live one reported 294 in and 179 out, its thinking included). The pane counts every call's tokens, as its own usage reports them, today and over the last 7 days. It shows tokens, not dollars: there is no price reading for these calls.Its reply must be a number alone; anything else draws nothing.
The duck is quackpit's Master Caution: an original pixel rubber duck at the left of the band. It sleeps and never animates while idle. When something needs you, it wakes, quacks once (a short beak flap) and the cell says Quack: with the cause, in every language:
| Cause | When |
|---|---|
| A failed check | The latest run of a test, typecheck, lint or build failed. |
| The 5h window | It reaches 80% and will not last to its reset. A window that resets within 30 minutes and will last until then raises nothing. |
| The context | It reaches 90% of the way to the auto-compact point (Quack: 11k to compact). |
| A runaway turn | A turn costs over 3× the session's median turn, after 5 turns. |

A command that you refused, that a rule blocked or that you stopped with Esc never failed, so it raises nothing.
Each new caution shows one toast. A failed check and the 5h window at 90% send an OS notification instead, at most 3 a day across all your sessions. Click Quack: to acknowledge a caution; the gauge stays amber or red until the cause clears. An optional quack sound is off by default. /quackpit duck off removes the duck and its toasts, and the gauges still change colour.
When the context gets long (past 200k tokens, or 400k in a window larger than 200k, or 80% of the way to auto-compact, whichever comes first), the hero says shift change soon · /quackpit handover once.
/quackpit opens the pane, and so does a click on any tile in the band, which opens it on that tile's detail. It never opens by itself.
The pane is six views: overview, timeline, turns, band, handover and guide. A bar of them stays on top, keyed 1 to 6, with a heavy rule under the one in front; a line at the bottom names the keys of the view in front. Outside the overview, the progress stays in view as one line under the bar: the list's count, the duckling's pond, the time left and what is being done. In a terminal the body between them scrolls on its own, so the bar and the progress never scroll away.

Progress across the top: how long the work in hand will take. The task running, the list's count in big digits, the duck crossing to its flag, the time left (~2m left) and when it should be done (done ~16:40), and what that rests on, the part that ends last first (2 open, ~1m each · at this list's pace, make test-all took ~4m last time, Haiku's 2nd guess at 10:49 (1st ~2m) · not measured). A part with no length known gets a line of its own (general · running 3m, no usual length yet). While Claude waits on you, how long it has waited and what for. With no list, what the turn is doing and how it compares with your turns. After the turn, while background work runs, its name and time left; with nothing running, a line: ✓ All 5 done · last turn 4m · 5 tools · 1 min ago.
Eight instruments under it, all one size: a title that is its press, a graphic about four rows tall, and its words beside it. Every graphic draws a real reading, and colour appears only past a threshold, except in the context's bar, where a colour is a key to a source.
| Instrument | Its graphic | Its words |
|---|---|---|
| PACE, this session | A dial from 0 to twice your usual pace. The needle is new tokens an hour right now (cache reads left out), each response counting half as much 5 minutes on, so it falls while nothing runs. The mark outside the rim at its top is your usual. Amber from 1.5×, red from 1.8×. | The pace, how it compares (1.3× usual, or 1.3× this session's average until 3 past days are kept), your usual or idle 7m, the cache's share. |
| 5H WINDOW, whole account | A fuel gauge, 0 to
hooks/register.tsx 2144 lines1// Claude Code adapter: the only file that touches `$`. It turns Claude Code's
2// events into the plain inputs of the agent-agnostic core (src/), keeps the
3// cockpit in the session's state, and draws with hooks/draw.tsx.
4//
5// Rules every hook here keeps:
6// - fail-open: any error draws nothing and blocks nothing; every observing
7// hook hands its event on unchanged;
8// - nothing reaches the model's context: no prompt.compose, no prompt
9// text or context, and the commands answer with an empty result;
10// - zero model tokens by default: the one model call is the opt-in AI ETA's
11// `$.model.complete` (askAi), a completion of its own, made only while its
12// setting is on, its usage tallied; never `$.model.fork`;
13// - no network, no processes; `$.fs` only reads claude-hud's config
14// (readHudFile) and writes the handover (writeHandoverFile);
15// - main agent only: tool and turn events that carry an `agentId` are passed by.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, Events, MatchedHook, ModelCompleteResult, PaneOpenArgs, PromptOrigin, Register, RenderElement, RenderSurface, SessionUsage, TurnStepResult } from 'claude-code'
19
20import { aiRequest, parseMinutes, planAsk, startedBy, tallyTokens, type AiOutcome, type AiPrevious, type AiRecord } from '../src/ai'
21
22import { forcedTtl, ttlOf, type Ttl } from '../src/cache'
23import { parseCounts } from '../src/checks'
24import { parseCommand } from '../src/commands'
25import type { DayTotals } from '../src/derive'
26import { cautionsOf, freshCautions, quackLine, cautionText, type Caution } from '../src/duck'
27import { baseName, clockText, dayKey, formatCountdown, formatTokens } from '../src/format'
28import { duckCells, duckImage, probeImage } from '../src/gauges'
29import { measureMessages, splitOf, type BreakdownInput } from '../src/context'
30import { buildHandover, HANDOVER_PATH } from '../src/handover'
31import { hudTiles, layerHud, parseHudConfig, HUD_FILES } from '../src/hud'
32import { systemLocale, t } from '../src/i18n'
33import { DENSE_BELOW, planBand, sharedRows } from '../src/layout'
34import { isJobRunning, isRunLength, jobLength, parseNotices, type CommandLengths, type ListedTask } from '../src/jobs'
35import { calibration, historyLeft, turnLengths } from '../src/lengths'
36import { isNum, MINUTE, newTokens } from '../src/metrics'
37import {
38 acknowledge,
39 adoptEarly,
40 asCockpit,
41 backfill,
42 isAgentOpen,
43 isCounted,
44 markHandover,
45 markSeen,
46 newCockpit,
47 isCompactPointDue,
48 joinStretch,
49 onAgentList,
50 onAgentSpawn,
51 onAgentTurnEnd,
52 onBackgroundTasks,
53 onCompact,
54 onCompactPoint,
55 onContextSplit,
56 onDialogsClosed,
57 onForeignTask,
58 onMeta,
59 onNotices,
60 onPrompt,
61 onReading,
62 onStep,
63 onStepStart,
64 onToolEnd,
65 onToolStart,
66 onTurnEnd,
67 onTurnStart as startTurn,
68 onWaitEnd,
69 onWaitStart,
70 settleWith,
71 turnLength,
72 turnWorkSoFar,
73 waitKindOf,
74 type AiAsk,
75 type AiWhy,
76 type AgentRun,
77 type Cockpit,
78 type ListedAgent,
79 type PastMessage,
80 type Reading,
81 type Stretch,
82 type ToolEnd,
83} from '../src/model'
84import { activeMs, openWaits, waitingNow } from '../src/waits'
85import { detailOf, type DetailId } from '../src/details'
86import { bandBody, bodyKeys, drillBody, guideBody, handoverBody, headRows, overviewBody, paneModel, sumRows, timelineBody, turnsBody, type BodyPart, type OverviewPlan } from '../src/panes'
87import { clampTop, followed, overflows, scrolled, windowRows } from '../src/pin'
88import { asView, DEFAULT_VIEW, VIEW_IDS, type PaneView, type ViewId } from '../src/views'
89import {
90 addTile,
91 effectiveLayout,
92 hudLayout,
93 isCurrent,
94 isSameLayout,
95 moveTile,
96 readSettings,
97 removeTile,
98 setDuck,
99 setRows,
100 type Change,
101 type Layout,
102 type LayoutRecord,
103 type Settings,
104} from '../src/prefs'
105import { isListLive, progressOf } from '../src/progress'
106import { quackWav } from '../src/sound'
107import { QUACK_FRAME_MS, QUACK_FRAMES, type Frame } from '../src/sprites'
108import { toRecord } from '../src/record'
109import { guideRows } from '../src/guide'
110import { aiLines, timeBlocks, type AiView } from '../src/timeline'
111import { topSection } from '../src/top'
112import {
113 addAiCall,
114 addAiRecord,
115 addCommandRun,
116 addToDay,
117 giveBackNotify,
118 loadAiSummary,
119 loadDays,
120 loadHud,
121 loadLayout,
122 loadLengths,
123 loadSession,
124 loadView,
125 pruneAi,
126 rollUp,
127 saveHud,
128 saveLayout,
129 saveSession,
130 saveView,
131 surveySessions,
132 takeNotify,
133 type AiSummary,
134 type KV,
135} from '../src/store'
136import { emptyDurations, isTurnCovered, type Durations } from '../src/eta'
137import { availableTiles, isTileId, readingsOf, receiptText, unknownTiles, type TileId } from '../src/tiles'
138import type { QuackpitJson } from '../types'
139import { drawBand, drawPane, drawsNothing, planDraws, type Kit } from './draw'
140
141type Dollar = EngineInterface
142
143export const PANE_ID = 'quackpit'
144
145// ------------------------------------------------------------------ state
146
147const cockpitAtom = atom({ plugin: 'quackpit', key: 'cockpit' } as const, null)
148const uiAtom = atom({ plugin: 'quackpit', key: 'ui' } as const, null)
149
150type UiState = {
151 graphics: 'unknown' | 'pixels' | 'text'
152 layout: Layout
153 days: DayTotals[]
154 /** The pane's view in front and the detail drilled into, kept in the store too, so /clear keeps them. */
155 pane?: PaneView
156 /** The last handover written: when, and whether the file was written. */
157 handover?: { at: number; written: boolean }
158 /** The AI ETA's last 7 days of calls and its score, from the store: what the pane shows of it. */
159 ai?: AiSummary
160}
161
162/** The module's own settings, from userConfig; a change reloads the module. */
163let settings: Settings = readSettings({})
164
165function defaultUi(): UiState {
166 return { graphics: 'unknown', layout: { tiles: [...settings.tiles], rows: settings.rows, duck: settings.duck }, days: [] }
167}
168
169function asUi(x: unknown): UiState {
170 if (typeof x !== 'object' || x === null) return defaultUi()
171 const u = x as Partial<UiState>
172 const base = defaultUi()
173 const layout = u.layout && Array.isArray(u.layout.tiles) ? { ...base.layout, ...u.layout, tiles: u.layout.tiles.filter(isTileId) } : base.layout
174 return {
175 graphics: u.graphics === 'pixels' || u.graphics === 'text' ? u.graphics : 'unknown',
176 layout,
177 days: Array.isArray(u.days) ? u.days : [],
178 ...(u.handover && isNum(u.handover.at) ? { handover: { at: u.handover.at, written: u.handover.written === true } } : {}),
179 ...(asView(u.pane) ? { pane: asView(u.pane) as PaneView } : {}),
180 ...(isAiSummary(u.ai) ? { ai: u.ai } : {}),
181 }
182}
183
184/** A stored AI summary, checked before it is drawn from. */
185function isAiSummary(x: unknown): x is AiSummary {
186 if (typeof x !== 'object' || x === null) return false
187 const a = x as Partial<AiSummary>
188 const isScore = (x: unknown) => typeof x === 'object' && x !== null && isNum((x as { n?: unknown }).n) && isNum((x as { ai?: unknown }).ai) && isNum((x as { history?: unknown }).history)
189 return Array.isArray(a.days) && a.days.every(d => typeof d?.day === 'string' && typeof d.tally === 'object' && d.tally !== null) && isScore(a.scores?.first) && isScore(a.scores?.revised)
190}
191
192const json = (x: unknown): QuackpitJson => x as QuackpitJson
193
194async function now($: Dollar): Promise<number> {
195 try {
196 return await $.clock.now()
197 } catch {
198 return Date.now()
199 }
200}
201
202async function attempt<T>(work: () => Promise<T>, fallback: T): Promise<T> {
203 try {
204 return await work()
205 } catch {
206 return fallback
207 }
208}
209
210/** A line of work where each waits for the one before: a store read-then-write never interleaves with another. */
211type Queue = { tail: Promise<unknown> }
212
213function serially<T>(queue: Queue, work: () => Promise<T>): Promise<T> {
214 const run = queue.tail.then(work, work)
215 queue.tail = run.catch(() => undefined)
216 return run
217}
218
219/** The day's notification count; the layout override. */
220const notifyQueue: Queue = { tail: Promise.resolve() }
221const layoutQueue: Queue = { tail: Promise.resolve() }
222
223/** A press or a timer's work, unawaited by anyone: its failure goes to the debug log, never unhandled. */
224function safely($: Dollar, where: string, work: () => Promise<unknown>): void {
225 void work().catch(err => debugLog($, where, err))
226}
227
228/**
229 * What the timers and the drawing need to know without reading the state:
230 * whether agents are at work (their list is looked up while they are), which
231 * calls a wait on the person holds (a progress row of one ends its wait), and
232 * which loops a wait holds. Refreshed on every write.
233 */
234const hint = { isAgentOpen: false, waitIds: new Set<string>(), waitLoops: new Set<string>(), isWaiting: false, hasDialog: false }
235
236function noteCockpit(c: Cockpit) {
237 // Polled while an agent is at work or waiting: either changes by itself, and no event of the main loop says when.
238 hint.isAgentOpen = c.agents.some(a => isAgentOpen(a) || a.status === 'waiting')
239 const open = openWaits(c.waits)
240 hint.waitIds = new Set(open.flatMap(w => (w.toolUseId ? [w.toolUseId] : [])))
241 hint.waitLoops = new Set(open.map(w => w.agentId ?? MAIN_LOOP))
242 hint.isWaiting = open.length > 0
243 hint.hasDialog = open.some(w => w.kind === 'permission')
244}
245
246/** The main loop's name among the loops a wait may hold. */
247const MAIN_LOOP = '(main)'
248
249/** Applies a reducer to the cockpit, starting a fresh one when there is none. */
250async function apply($: Dollar, at: number, change: (c: Cockpit) => Cockpit): Promise<Cockpit> {
251 let result: Cockpit = newCockpit(at)
252 await update($, cockpitAtom, raw => {
253 result = change(asCockpit(raw) ?? newCockpit(at))
254 return json(result)
255 })
256 noteCockpit(result)
257 ensurePoller($)
258 ensureWaitTicker($)
259 return result
260}
261
262async function readCockpit($: Dollar): Promise<Cockpit | undefined> {
263 return asCockpit(await read($, cockpitAtom))
264}
265
266async function readUi($: Dollar): Promise<UiState> {
267 const raw = await read($, uiAtom)
268 return raw === null ? restoredUi($) : asUi(raw)
269}
270
271/** The ui state read back from the store, kept until this session's own is written. */
272let restored: UiState | undefined
273
274/**
275 * The ui state where this session's is new and nothing wrote it yet: after
276 * /clear Claude Code starts the session's state afresh with no session.start,
277 * so the layout override and the days for the usual pace are read back from
278 * the store, as start-up reads them. Only read here (a drawing may not write);
279 * the next change writes it, starting from this.
280 */
281async function restoredUi($: Dollar): Promise<UiState> {
282 if (restored) return restored
283 const at = await now($)
284 const kv = kvOf($)
285 const record = await attempt(() => loadLayout(kv), undefined)
286 const days = await attempt(() => loadDays(kv, dayKey(at)), [] as DayTotals[])
287 // The AI ETA's calls and score are the store's too: /clear does not spend them.
288 const ai = await attempt(() => loadAiSummary(kv, dayKey(at)), undefined)
289 // And the pane opens on the tab and detail it was left on.
290 const pane = await attempt(() => loadView(kv), undefined)
291 restored = { ...defaultUi(), layout: effectiveLayout(settings, record), days, ...(ai ? { ai } : {}), ...(pane ? { pane } : {}) }
292 return restored
293}
294
295async function changeUi($: Dollar, change: (u: UiState) => UiState): Promise<UiState> {
296 // A new state starts from what the store holds, not from the settings alone.
297 const start = (await read($, uiAtom)) === null ? await restoredUi($) : undefined
298 let result = defaultUi()
299 await update($, uiAtom, raw => {
300 result = change(raw === null && start ? start : asUi(raw))
301 return json(result)
302 })
303 restored = undefined
304 return result
305}
306
307/** The pane's view, as the session's state has it (or the store kept it). */
308function viewOf(ui: UiState): PaneView {
309 return ui.pane ?? DEFAULT_VIEW
310}
311
312/**
313 * Changes the pane's view: in the store first, which a /clear (whose state
314 * starts afresh) reads it back from, then in the session's state, which
315 * redraws the pane. A state that cannot be written leaves the store's.
316 */
317async function setView($: Dollar, change: (view: PaneView) => PaneView): Promise<PaneView> {
318 const view = change(viewOf(await attempt(() => readUi($), defaultUi())))
319 await attempt(() => saveView(kvOf($), view), undefined)
320 restored = undefined
321 await attempt(() => changeUi($, u => ({ ...u, pane: view })), defaultUi())
322 redraw($)
323 return view
324}
325
326function kvOf($: Dollar): KV {
327 return {
328 get: key => $.store.get(key),
329 set: (key, value) => $.store.set(key, value),
330 delete: key => $.store.delete(key),
331 keys: () => $.store.keys(),
332 }
333}
334
335function toast($: Dollar, text: string, timeoutMs = 6000) {
336 try {
337 $.ui.toast(text, { timeoutMs })
338 } catch {
339 // a toast that cannot show is not worth an error
340 }
341}
342
343/** A failure, to the debug log alone (`claude --debug`): fail-open, but not silent. */
344function debugLog($: Dollar, where: string, err: unknown) {
345 try {
346 $.ui.log(`quackpit ${where}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`, { to: 'debug' })
347 } catch {
348 // nowhere to say it
349 }
350}
351
352function redraw($: Dollar) {
353 try {
354 $.ui.invalidate('ui.render')
355 } catch {
356 // nothing drawn to redraw
357 }
358}
359
360// ------------------------------------------------------------------ readings
361
362/** The figures `$.session.usage()` answers and `session.measure` pushes, flattened. */
363function readingOf(usage: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'> & { startedAt?: number }, at: number): Reading {
364 const reading: Reading = { at, ...(isNum(usage.startedAt) ? { sessionStartedAt: usage.startedAt } : {}) }
365 const ctx = usage.context
366 if (ctx) {
367 if (isNum(ctx.percent)) reading.contextPct = ctx.percent
368 if (isNum(ctx.tokens)) reading.contextTokens = ctx.tokens
369 if (isNum(ctx.window)) reading.contextWindow = ctx.window
370 }
371 for (const limit of usage.rateLimits ?? []) {
372 if (!isNum(limit.percentUsed)) continue
373 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
374 const window = { pct: limit.percentUsed, ...(Number.isFinite(resetsAt) ? { resetsAt } : {}) }
375 if (limit.kind === 'five_hour') reading.fiveHour = window
376 else if (limit.kind === 'seven_day') reading.sevenDay = window
377 }
378 if (usage.cost && isNum(usage.cost.usd)) reading.costUsd = usage.cost.usd
379 return reading
380}
381
382/**
383 * `$.session.usage()` read directly: the fallback at start-up and at a turn's
384 * start (`session.measure` pushes every other reading). Undefined when it
385 * throws (fail-open).
386 */
387async function readUsage($: Dollar, at: number): Promise<Reading | undefined> {
388 try {
389 return readingOf(await $.session.usage(), at)
390 } catch {
391 return undefined
392 }
393}
394
395// ------------------------------------------------------------------ the duck's voice
396
397let lastQuackAt = 0
398
399/** An OS notification within the day's 3, else a toast. Never awaited by a turn. */
400async function notifyCapped($: Dollar, title: string, body: string, day: string) {
401 try {
402 const kv = kvOf($)
403 // Two cautions at once (a failed check and the 5h window) take their slots in turn.
404 if (!(await serially(notifyQueue, () => attempt(() => takeNotify(kv, day), false)))) return toast($, title, 8000)
405 let isSent = false
406 try {
407 const result = await $.ui.notify(body, { title })
408 isSent = result.isSent === true
409 } catch {
410 // before 2.1.295 there is no $.ui.notify; a hook may refuse it
411 isSent = false
412 }
413 if (!isSent) {
414 await serially(notifyQueue, () => attempt(() => giveBackNotify(kv, day), undefined))
415 toast($, title, 8000)
416 }
417 } catch {
418 toast($, title, 8000)
419 }
420}
421
422function playQuack($: Dollar, at: number) {
423 if (!settings.sound || at - lastQuackAt < 20_000) return
424 lastQuackAt = at
425 try {
426 void $.audio.play({ base64: quackWav(), mime: 'audio/wav' }).catch(() => undefined)
427 } catch {
428 // no player here: the duck stays silent
429 }
430}
431
432function cautionBody(k: Caution, c: Cockpit): string {
433 if (k.kind === 'check') {
434 const check = [...c.checks].reverse().find(ch => ch.kind === k.checkKind && ch.status === 'fail')
435 return check ? `${check.command}` : cautionText(k, settings.locale)
436 }
437 if (k.kind === 'five') {
438 const w = c.reading?.fiveHour
439 return w?.resetsAt !== undefined ? `${cautionText(k, settings.locale)} · ↻${formatCountdown(w.resetsAt - (c.reading?.at ?? 0))} (${clockText(w.resetsAt)})` : cautionText(k, settings.locale)
440 }
441 return cautionText(k, settings.locale)
442}
443
444/**
445 * A caution was just announced: the band plays its quack at its next draw that
446 * shows the duck. Only an announcement sets it, so neither a redraw nor a
447 * reload (which starts the module's variables over) replays an old one.
448 */
449let quackDue = false
450
451/**
452 * Each new caution: one "Quack:" toast, or for the notify kinds an OS
453 * notification. The checks one run failed (a chain whose output named none
454 * of them) are one announcement: `Quack: types+tests failed`.
455 */
456function announce($: Dollar, fresh: readonly Caution[], c: Cockpit, layout: Layout, at: number) {
457 if (!layout.duck || fresh.length === 0) return
458 const L = settings.locale
459 const groups: Caution[][] = []
460 for (const k of fresh) {
461 const run = k.kind === 'check' ? groups.find(g => g[0]?.kind === 'check' && g[0].at === k.at) : undefined
462 if (run) run.push(k)
463 else groups.push([k])
464 }
465 for (const [k, ...more] of groups) {
466 if (!k) continue
467 const line = more.length === 0 ? quackLine(k, L) : `${t(L, 'duck.quack')} ${t(L, 'caution.check', { kind: [k, ...more].map(g => t(L, `kind.${g.checkKind ?? 'test'}`)).join('+') })}`
468 if (k.isNotify) void notifyCapped($, line, cautionBody(k, c), dayKey(at))
469 else toast($, line)
470 }
471 quackDue = true
472 playQuack($, at)
473}
474
475/** Folds an event in and announces what it newly raised. */
476async function fold($: Dollar, at: number, change: (c: Cockpit) => Cockpit): Promise<Cockpit> {
477 let fresh: Caution[] = []
478 const c = await apply($, at, before => {
479 const next = change(before)
480 fresh = freshCautions(next)
481 return fresh.length > 0 ? markSeen(next, fresh.map(k => k.key)) : next
482 })
483 if (fresh.length > 0) {
484 const ui = await attempt(() => readUi($), defaultUi())
485 announce($, fresh, c, ui.layout, at)
486 }
487 return c
488}
489
490// ------------------------------------------------------------------ the ticker
491
492let ticker: { cancel: () => void } | undefined
493let poller: { cancel: () => void } | undefined
494
495function ensureTicker($: Dollar) {
496 if (ticker) return
497 try {
498 ticker = $.clock.every(30_000, () => void tick($))
499 } catch {
500 ticker = undefined
501 }
502}
503
504/**
505 * Every 30 seconds, for the countdowns, ages and the swimming duck's place:
506 * nothing in the band ticks seconds, and every event that changes the cockpit
507 * redraws it by itself. It redraws and nothing more. The duck never animates
508 * while idle.
509 */
510function tick($: Dollar) {
511 redraw($)
512}
513
514/** While an agent is at work (or waiting), the agents' list is looked up every 5 seconds: no event of the main loop says when it ends. */
515function ensurePoller($: Dollar) {
516 if (hint.isAgentOpen === (poller !== undefined)) return
517 if (poller) {
518 try {
519 poller.cancel()
520 } catch {
521 // gone already
522 }
523 poller = undefined
524 return
525 }
526 try {
527 poller = $.clock.every(5000, () => safely($, 'agents', () => lookUpAgents($)))
528 } catch {
529 poller = undefined
530 }
531}
532
533/** Every agent loop of the session as the host lists it: subagents, teammates, workflows' and plugins' agents. */
534async function lookUpAgents($: Dollar) {
535 const listed = await attempt(
536 async () =>
537 (await $.agent.list()).map(
538 (a): ListedAgent => ({
539 id: a.id,
540 status: a.status,
541 type: a.type,
542 description: a.description,
543 ...(a.parentId ? { parentId: a.parentId } : {}),
544 ...(a.spawnedBy ? { spawnedBy: a.spawnedBy } : {}),
545 ...(a.teammateId ? { teammateId: a.teammateId } : {}),
546 ...(a.name ? { name: a.name } : {}),
547 }),
548 ),
549 undefined,
550 )
551 if (!listed) return
552 const at = await now($)
553 await apply($, at, before => onAgentList(before, at, listed))
554 planAi($)
555}
556
557let waitTicker: { cancel: () => void } | undefined
558
559/**
560 * While a wait on the person is open, a redraw every second: its clock is the
561 * one thing that ticks (`⏸ waiting on you · 1m05`). Nothing counts down, and
562 * the duck holds still.
563 */
564function ensureWaitTicker($: Dollar) {
565 if (hint.isWaiting === (waitTicker !== undefined)) return
566 if (waitTicker) {
567 try {
568 waitTicker.cancel()
569 } catch {
570 // gone already
571 }
572 waitTicker = undefined
573 return
574 }
575 try {
576 waitTicker = $.clock.every(1000, () => redraw($))
577 } catch {
578 waitTicker = undefined
579 }
580}
581
582// ------------------------------------------------------------------ the AI ETA
583
584/**
585 * The AI ETA's next call for the turn running: its timer until it is due,
586 * its controller while it runs, so the turn's end cancels either. Only while
587 * the setting is on; a reload drops both, and the turn's asks in the state
588 * keep the limits.
589 */
590let aiCall: { turnId: string; timer?: { cancel: () => void }; controller?: AbortController } | undefined
591
592/** The AI ETA's store writes (its tally and its scored answers) go one after another. */
593const aiQueue: Queue = { tail: Promise.resolve() }
594
595/** Main turns that have ended, the last few: a call about to go out for one of them is not made. */
596const endedTurns = new Set<string>()
597
598/**
599 * The work's shape, which an AI answer was made for: Claude's agents at work
600 * and its background jobs running, whether a task list is open, and whether
601 * something measured covers the turn. A change asks the AI again.
602 */
603function shapeOf(c: Cockpit, covered: boolean): string {
604 const agents = c.agents.filter(a => isCounted(a) && isAgentOpen(a)).map(a => a.id)
605 const jobs = c.jobs.filter(isJobRunning).map(j => j.id)
606 const list = c.tasks?.items.some(t => t.status !== 'completed') ? 'list' : ''
607 return [agents.sort().join(','), jobs.sort().join(','), list, covered ? 'covered' : ''].join('|')
608}
609
610/** A plain estimate context, for the AI's own decisions (its answer never decides whether it is asked). */
611function etaContext() {
612 return { priors, ...(cmdLengths ? { lengths: cmdLengths } : {}) }
613}
614
615/** Cancels the AI ETA's timer, or its call while it runs: the turn ended first. */
616function disarmAi(turnId?: string) {
617 const armed = aiCall
618 if (!armed || (turnId !== undefined && armed.turnId !== turnId)) return
619 aiCall = undefined
620 try {
621 armed.timer?.cancel()
622 } catch {
623 // fired already
624 }
625 armed.controller?.abort()
626}
627
628/** Only the timer: a call in flight goes on. */
629function clearAiTimer() {
630 try {
631 aiCall?.timer?.cancel()
632 } catch {
633 // fired already
634 }
635 if (aiCall) aiCall = aiCall.controller ? { turnId: aiCall.turnId, controller: aiCall.controller } : undefined
636}
637
638/**
639 * Looks at the AI ETA for the turn running, after anything that changes it:
640 * notes a reason to ask again (the work changed shape, the measured estimate
641 * it deferred to went away), then asks now, sets a timer for when it may, or
642 * leaves it. A timer's work, never a hook's: it is never awaited.
643 */
644function planAi($: Dollar) {
645 if (!settings.aiEta) return
646 safely($, 'ai plan', async () => {
647 const at = await now($)
648 const c = await readCockpit($)
649 const turn = c?.turn
650 if (!c || !turn || endedTurns.has(turn.id)) return
651 const covered = isTurnCovered(c, at, etaContext())
652 const shape = shapeOf(c, covered)
653 const asks = turn.asks ?? []
654 let due = turn.aiDue?.why
655 // A change of shape after the AI answered is a reason to ask it again.
656 if (turn.shape !== shape) {
657 const wasCovered = turn.shape?.endsWith('|covered') === true
658 const why: AiWhy | undefined = asks.length > 0 || (wasCovered && !covered) ? (wasCovered && !covered ? 'deferred' : 'shape') : undefined
659 await apply($, at, x => (x.turn?.id === turn.id ? { ...x, turn: { ...x.turn, shape, ...(why && !x.turn.aiDue ? { aiDue: { why, at } } : {}) } } : x))
660 if (why && !due) due = why
661 }
662 const answered = asks.findLast(a => a.state === 'answered')
663 const plan = planAsk({
664 now: at,
665 workedMs: turnWorkSoFar(c, at),
666 isWaiting: waitingNow(c.waits) !== undefined,
667 isAsking: asks.at(-1)?.state === 'asking' || aiCall?.controller !== undefined,
668 asks,
669 isCovered: covered,
670 ...(due ? { due } : {}),
671 ...(answered ? { workedSinceLast: activeMs(c.waits, answered.askedAt, at) } : {}),
672 })
673 clearAiTimer()
674 if (plan.kind === 'ask') return askAi($, turn.id, plan.why)
675 if (plan.kind === 'later') {
676 try {
677 const timer = $.clock.after(Math.max(1000, plan.inMs), () => planAi($))
678 aiCall = { turnId: turn.id, timer, ...(aiCall?.controller ? { controller: aiCall.controller } : {}) }
679 } catch {
680 // no timers: the next event looks again
681 }
682 }
683 })
684}
685
686/**
687 * One AI ETA call for the turn running: made in the background, never by a
688 * hook. The turn is marked before the call, so a reload never asks twice;
689 * its earlier answers and what happened since go with a re-ask. The call's
690 * own usage is tallied whatever its outcome; an answer that is a number
691 * becomes the turn's latest, anything else draws nothing, and an answer made
692 * for a state that changed while it was asked (a wait began, the work
693 * changed shape) is dropped as stale.
694 */
695async function askAi($: Dollar, turnId: string, why: AiWhy) {
696 if (!settings.aiEta) return
697 const at = await now($)
698 const c = await readCockpit($)
699 const turn = c?.turn
700 if (!c || !turn || turn.id !== turnId || endedTurns.has(turnId)) return
701 const asks = turn.asks ?? []
702 if (asks.at(-1)?.state === 'asking') return
703 const lengths = turnLengths(c, priors)
704 const elapsedMs = turnWorkSoFar(c, at)
705 // What history alone says at the same moment, kept to score the two side by side.
706 const historyMs = historyLeft(lengths, elapsedMs)
707 const n = asks.length + 1
708 const shape = shapeOf(c, isTurnCovered(c, at, etaContext()))
709 const marked = { isMarked: false }
710 await apply($, at, x => {
711 if (x.turn?.id !== turnId || (x.turn.asks ?? []).length !== asks.length) return x
712 marked.isMarked = true
713 const ask: AiAsk = { n, askedAt: at, elapsedMs, why, state: 'asking', ...(historyMs !== undefined ? { historyMs: Math.round(historyMs) } : {}) }
714 const { aiDue: _due, ...rest } = x.turn
715 return { ...x, turn: { ...rest, asks: [...(x.turn.asks ?? []), ask], shape } }
716 })
717 if (!marked.isMarked) return
718 const tasks = c.tasks && isListLive(c.tasks, turn.n) ? c.tasks.items.map(task => ({ title: task.title, status: task.status })) : undefined
719 const cal = calibration(lengths, elapsedMs)
720 // A re-ask: the answers before, and what happened since the latest.
721 const previous: AiPrevious[] = asks.map(a => ({ at: a.askedAt, elapsedMs: a.elapsedMs, ...(a.state === 'answered' && a.leftMs !== undefined ? { leftMs: a.leftMs } : {}) }))
722 const last = asks.at(-1)
723 const since = last
724 ? {
725 workedMs: activeMs(c.waits, last.askedAt, at),
726 heldMs: Math.max(0, at - last.askedAt - activeMs(c.waits, last.askedAt, at)),
727 notes: (turn.log ?? []).filter(note => note.at > last.askedAt).map(note => note.text),
728 ...(turn.lastWords ? { lastWords: turn.lastWords } : {}),
729 }
730 : undefined
731 const request = aiRequest({
732 elapsedMs,
733 tools: turn.tools,
734 ...(turn.text ? { text: turn.text } : {}),
735 ...(turn.source ? { startedBy: startedBy(turn.source.kind) } : {}),
736 ...(turn.words ? { words: turn.words } : {}),
737 ...(tasks ? { tasks } : {}),
738 ...(cal ? { calibration: cal } : {}),
739 ...(previous.length > 0 ? { previous } : {}),
740 ...(since ? { since } : {}),
741 })
742 // The turn may have ended while it was being marked: then there is nothing to ask about.
743 if (endedTurns.has(turnId)) return
744 const controller = new AbortController()
745 clearAiTimer()
746 aiCall = { turnId, controller }
747 let result: ModelCompleteResult | undefined
748 try {
749 result = await $.model.complete(request, { signal: controller.signal })
750 } catch (err) {
751 // A request the engine would not send (a blocked model): no reply, no usage.
752 debugLog($, 'ai', err)
753 }
754 if (aiCall?.controller === controller) aiCall = undefined
755 const minutes = result?.isAnswered ? parseMinutes(result.text) : undefined
756 const after = await now($)
757 const latest = await readCockpit($)
758 // Made for a state that changed while it was asked: a wait began, or the work changed shape.
759 const isStale = !controller.signal.aborted && latest?.turn?.id === turnId && (latest.waits.some(w => w.main && w.from >= at) || shapeOf(latest, isTurnCovered(latest, after, etaContext())) !== shape)
760 const outcome: AiOutcome = controller.signal.aborted || isStale ? 'cancelled' : !result?.isAnswered ? 'failed' : minutes === undefined ? 'unread' : 'answered'
761 await serially(aiQueue, () => attempt(() => addAiCall(kvOf($), dayKey(at), c.sid, outcome, result?.usage), undefined))
762 await apply($, after, x => {
763 const asked = x.turn?.id === turnId ? (x.turn.asks ?? []).find(a => a.n === n) : undefined
764 if (!x.turn || asked?.state !== 'asking') return x
765 const ask: AiAsk = isStale
766 ? { ...asked, state: 'stale' }
767 : minutes !== undefined && outcome === 'answered'
768 ? { ...asked, state: 'answered', leftMs: Math.round(minutes * MINUTE) }
769 : { ...asked, state: outcome === 'cancelled' ? 'cancelled' : 'failed' }
770 // A stale answer leaves its reason to ask standing: the state it was made for is gone.
771 const due = isStale && !x.turn.aiDue ? { aiDue: { why: 'shape' as const, at: after } } : {}
772 return { ...x, turn: { ...x.turn, asks: (x.turn.asks ?? []).map(a => (a.n === n ? ask : a)), ...due } }
773 })
774 await refreshAi($, after)
775 planAi($)
776}
777
778/** The pane's AI ETA figures, read again from the store after a call or a scored turn. */
779async function refreshAi($: Dollar, at: number) {
780 const ai = await attempt(() => loadAiSummary(kvOf($), dayKey(at)), undefined)
781 if (ai) await attempt(() => changeUi($, u => ({ ...u, ai })), defaultUi())
782}
783
784/**
785 * Scores each of the AI's answers for a turn that ended on its own (an
786 * interrupted one is no measure): what it said was left, what history alone
787 * said at the same moment, and what was really left in the turn's working
788 * time (its waits on the person since left out), each answer on its own.
789 * Numbers only.
790 */
791async function scoreAi($: Dollar, sid: string, asks: readonly AiAsk[], waits: Cockpit['waits'], at: number) {
792 const records: AiRecord[] = asks
793 .filter(a => a.state === 'answered' && a.leftMs !== undefined)
794 .map(a => [a.askedAt, a.elapsedMs, a.leftMs ?? 0, a.historyMs ?? -1, activeMs(waits, a.askedAt, at), a.n])
795 if (records.length === 0) return
796 for (const record of records) await serially(aiQueue, () => attempt(() => addAiRecord(kvOf($), sid, record), undefined))
797 await refreshAi($, at)
798}
799
800/** The AI ETA as the pane shows it. */
801function aiView(ui: UiState, at: number): AiView {
802 const none = { n: 0, ai: 0, history: 0 }
803 return { on: settings.aiEta, today: dayKey(at), days: ui.ai?.days ?? [], scores: ui.ai?.scores ?? { first: none, revised: none } }
804}
805
806// ------------------------------------------------------------------ how long commands take
807
808/** The last runs of each command, by project: read at start-up, kept as written. */
809let cmdLengths: CommandLengths | undefined
810
811/** The record's writes go one after another: each reads what the last wrote. */
812const runsQueue: Queue = { tail: Promise.resolve() }
813
814/** Keeps a run of a command in the project's record, when it is long enough to estimate anything by. */
815function noteRun($: Dollar, c: Cockpit, command: string, ms: number, at: number) {
816 if (!isRunLength(ms)) return
817 const project = c.meta.root ?? ''
818 safely($, 'runs', () =>
819 serially(runsQueue, async () => {
820 cmdLengths = await addCommandRun(kvOf($), project, command, ms, at)
821 }),
822 )
823}
824
825/** The jobs that ended between two cockpits with both ends seen: their runs are kept for their commands. */
826function noteEndedJobs($: Dollar, before: Cockpit, after: Cockpit, at: number) {
827 for (const j of after.jobs) {
828 const was = before.jobs.find(x => x.id === j.id)
829 if (!was || !isJobRunning(was) || isJobRunning(j) || j.status !== 'done' || j.exitCode !== 0) continue
830 const ms = jobLength(j)
831 if (ms !== undefined && j.command) noteRun($, after, j.command, ms, at)
832 }
833}
834
835// ------------------------------------------------------------------ files: the two uses of $.fs
836
837/** Reads one of claude-hud's config files; undefined when it is not there. Read only. */
838async function readHudFile($: Dollar, path: string): Promise<string | undefined> {
839 try {
840 return await $.fs.read(path)
841 } catch {
842 return undefined
843 }
844}
845
846/** Writes the handover to the project. */
847async function writeHandoverFile($: Dollar, path: string, text: string): Promise<boolean> {
848 try {
849 await $.fs.write(path, text)
850 return true
851 } catch {
852 return false
853 }
854}
855
856async function claudeConfigDir($: Dollar): Promise<string | undefined> {
857 const dir = await attempt(() => $.env.get('CLAUDE_CONFIG_DIR'), undefined)
858 if (dir) return dir.replace(/\/+$/, '')
859 const home = await attempt(() => $.env.get('HOME'), undefined)
860 return home ? `${home.replace(/\/+$/, '')}/.claude` : undefined
861}
862
863/** The first time quackpit runs where a person is at the prompt: one toast on where the pane is. */
864async function welcome($: Dollar, kv: KV, at: number) {
865 if ((await kv.get('welcomed')) !== undefined) return
866 await kv.set('welcomed', at)
867 toast($, t(settings.locale, 'toast.welcome'), 10_000)
868}
869
870/** First run only: claude-hud's enabled lines become the layout, said once in a toast. */
871async function importHud($: Dollar, kv: KV, at: number): Promise<LayoutRecord | undefined> {
872 if (await attempt(() => loadHud(kv), undefined)) return undefined
873 const dir = await claudeConfigDir($)
874 let mapped: TileId[] | undefined
875 if (dir) {
876 const shared = parseHudConfig(await readHudFile($, `${dir}/${HUD_FILES[0]}`))
877 const local = parseHudConfig(await readHudFile($, `${dir}/${HUD_FILES[1]}`))
878 const config = layerHud(shared, local)
879 if (config) mapped = hudTiles(config)
880 }
881 await attempt(() => saveHud(kv, { checkedAt: at, ...(mapped ? { tiles: mapped } : {}) }), undefined)
882 if (!mapped) return undefined
883 const record = hudLayout(settings, mapped)
884 await attempt(() => saveLayout(kv, record), undefined)
885 toast($, t(settings.locale, 'toast.hud', { tiles: mapped.join(', ') }), 10_000)
886 return record
887}
888
889// ------------------------------------------------------------------ session
890
891let sessionId = ''
892
893/** The id a cockpit carries between /clear and the next turn, which gives it the new session's. */
894const CLEARED = '(cleared)'
895/**
896 * A /clear just ended the last session. Claude Code starts the next one's
897 * state afresh, so the cleared cockpit written there may not carry over; this
898 * module, which /clear keeps, remembers it instead, and the next session
899 * starts at turn 1 rather than counting the commands in its transcript.
900 */
901let clearPending = false
902/** The person's stretch of prompts when /clear came: they did not pause for it, so the next session carries it on. */
903let clearedStretch: Stretch | undefined
904
905async function currentSid($: Dollar): Promise<string> {
906 return attempt(() => $.session.id(), sessionId)
907}
908
909/**
910 * A fresh cockpit for this session: its record when quackpit kept one (a
911 * process that restarted gets its turns back as they were at its last turn),
912 * else rebuilt from the transcript when it has one.
913 */
914async function freshCockpit($: Dollar, at: number, sid: string): Promise<Cockpit> {
915 const restored = sid !== CLEARED ? await attempt(() => loadSession(kvOf($), sid), undefined) : undefined
916 const c = restored ?? (await fromTranscript($, at, sid))
917 return withMeta($, c)
918}
919
920/** The project root and the model, as the session has them now. */
921async function withMeta($: Dollar, c: Cockpit): Promise<Cockpit> {
922 const root = await attempt(() => $.session.root(), undefined)
923 const model = await attempt(() => $.session.model(), undefined)
924 return onMeta(c, { ...(root ? { root, repo: baseName(root) } : {}), ...(model ? { model } : {}) })
925}
926
927/**
928 * The project root and the model, read again into the cockpit kept: at
929 * start-up, so a reload into a session whose cockpit has none (an older
930 * version's, cleared) gets them, and at each turn's start, since /model
931 * changes the model mid-session.
932 */
933async function refreshMeta($: Dollar, at: number, what: { root?: boolean } = { root: true }) {
934 const root = what.root ? await attempt(() => $.session.root(), undefined) : undefined
935 const model = await attempt(() => $.session.model(), undefined)
936 if (!root && !model) return
937 await apply($, at, c => onMeta(c, { ...(root ? { root, repo: baseName(root) } : {}), ...(model ? { model } : {}) }))
938}
939
940/** What the transcript still holds, for a session quackpit joined under way with no record of it. */
941async function fromTranscript($: Dollar, at: number, sid: string): Promise<Cockpit> {
942 let c = newCockpit(at, sid)
943 const messages = await attempt(async () => {
944 const rows = await $.session.messages()
945 if (!Array.isArray(rows)) return [] as PastMessage[]
946 return rows.map((m): PastMessage => ({ ...m, toolUses: m.toolUses.map(u => ({ ...u, outcome: outcomeOf(u.tool, u) })) }))
947 }, [] as PastMessage[])
948 if (messages.length > 0) {
949 const turns = await attempt(() => $.session.turns(), 0)
950 c = backfill(c, messages, at, turns)
951 // What the transcript already held is history: the duck does not quack about it.
952 const old = cautionsOf(c).map(k => k.key)
953 c = markSeen(acknowledge(c, old), old)
954 }
955 return c
956}
957
958/** Follows a /clear or an in-process resume: another session id means another cockpit. */
959async function syncSession($: Dollar, at: number): Promise<void> {
960 const sid = await currentSid($)
961 const c = await readCockpit($)
962 if (c && c.sid === sid) return
963 sessionId = sid
964 // After /clear the cockpit is fresh already and only takes the new id: the new
965 // transcript has nothing to rebuild, and counting its rows as turns would count
966 // /clear itself and the prompt now starting.
967 const cleared = c?.sid === CLEARED || clearPending
968 clearPending = false
969 // What came after the clear (an early event's cockpit, or the cleared one) carries on; nothing before it does.
970 // Its model and project are read again: the cleared cockpit starts with none.
971 const base = c && (c.sid === CLEARED || c.sid === '') ? c : newCockpit(at)
972 // The person's stretch goes on through a /clear: theirs, not the session's.
973 const stretch = cleared ? joinStretch(clearedStretch, base.stretch) : undefined
974 clearedStretch = undefined
975 const fresh = cleared ? await withMeta($, { ...base, sid, ...(stretch ? { stretch } : {}) }) : await freshCockpit($, at, sid)
976 // Written only if no other event got there first: a cockpit already on this session stays.
977 let kept: Cockpit = fresh
978 await update($, cockpitAtom, raw => {
979 const current = asCockpit(raw)
980 kept = current && current.sid === sid ? current : current?.sid === CLEARED ? onMeta({ ...current, sid }, fresh.meta) : adoptEarly(fresh, current)
981 return json(kept)
982 })
983 noteCockpit(kept)
984}
985
986/** The prompt cache's lifetime as the environment or the settings force it (read at start-up). */
987let cacheForced: Ttl | undefined
988/** What the other sessions' finished tasks and subagents took: read once at start-up. */
989let priors: Durations = emptyDurations()
990
991/** The environment and settings Claude Code decides the prompt cache's lifetime by. Read only. */
992async function readCacheTtl($: Dollar): Promise<Ttl | undefined> {
993 const merged: Readonly<Record<string, unknown>> = await attempt(() => $.settings.read(), {})
994 const force5m = await attempt(() => $.env.get('FORCE_PROMPT_CACHING_5M'), undefined)
995 const ttlVar = await attempt(() => $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'), undefined)
996 const enable1h = await attempt(() => $.env.get('ENABLE_PROMPT_CACHING_1H'), undefined)
997 return forcedTtl({ setting: merged.promptCacheTtl, ...(force5m ? { force5m } : {}), ...(ttlVar ? { ttlVar } : {}), ...(enable1h ? { enable1h } : {}) })
998}
999
1000async function boot($: Dollar, isInteractive = true) {
1001 const at = await now($)
1002 cacheForced = await readCacheTtl($)
1003 const today = dayKey(at)
1004 const kv = kvOf($)
1005 let record = await attempt(() => loadLayout(kv), undefined)
1006 if (record && !isCurrent(record, settings)) {
1007 // userConfig changed since the override: the setting wins.
1008 await attempt(() => saveLayout(kv, undefined), undefined)
1009 record = undefined
1010 }
1011 if (!record) record = await attempt(() => importHud($, kv, at), undefined)
1012 const layout = effectiveLayout(settings, record)
1013 await attempt(() => rollUp(kv, today), undefined)
1014 const days = await attempt(() => loadDays(kv, today), [] as DayTotals[])
1015 await attempt(() => pruneAi(kv, today), undefined)
1016 const ai = await attempt(() => loadAiSummary(kv, today), undefined)
1017 await changeUi($, u => ({ ...u, layout, days, ...(ai ? { ai } : {}) }))
1018 sessionId = await currentSid($)
1019 await syncSession($, at)
1020 // A reload finds this session's cockpit kept: its model and project are read again all the same.
1021 await attempt(() => refreshMeta($, at), undefined)
1022 // After this session's own record was read back: the others' lengths stand in for the estimate.
1023 priors = await attempt(() => surveySessions(kv, sessionId, at), emptyDurations())
1024 // How long each command took the last few times: a background job's expected length.
1025 cmdLengths = await attempt(() => loadLengths(kv), undefined)
1026 const c = await attempt(() => readCockpit($), undefined)
1027 if (c) noteCockpit(c)
1028 // A reload mid-turn (the AI ETA just turned on, an edit) looks at the turn again, within its limits.
1029 if (c?.turn) planAi($)
1030 const reading = await readUsage($, at)
1031 if (reading) await fold($, at, c => onReading(c, reading))
1032 safely($, 'context', () => readContext($))
1033 if (isInteractive) await attempt(() => welcome($, kv, at), undefined)
1034 ensureTicker($)
1035}
1036
1037// ------------------------------------------------------------------ hooks
1038
1039/** `/config`'s tiles row refuses a name that is no tile, and says which tiles there are. */
1040export const onTilesSet: MatchedHook<'config.set', { readonly key: 'quackpit.tiles' }> = async ($, e, next) => {
1041 const unknown = unknownTiles(e.value)
1042 if (unknown.length > 0) return { deny: t(settings.locale, 'config.unknownTiles', { tiles: unknown.map(name => `"${name}"`).join(', '), list: availableTiles().join(', ') }) }
1043 return next(e)
1044}
1045
1046/** The tiles row lists every tile there is. */
1047export const onTilesDescribe: MatchedHook<'config.describe', { readonly key: 'quackpit.tiles' }> = ($, e, next) =>
1048 next({ ...e, description: t(settings.locale, 'config.tilesHelp', { list: availableTiles().join(', ') }) })
1049
1050export const onSessionStart: Events['session.start'] = async ($, e, next) => {
1051 try {
1052 const argumentHint = t(settings.locale, 'command.hint')
1053 await $.command.register({ name: 'quackpit', description: t(settings.locale, 'command.description'), argumentHint, immediate: true })
1054 await $.command.register({ name: 'quack', description: t(settings.locale, 'command.alias'), argumentHint, immediate: true })
1055 } catch {
1056 // the commands are a convenience; the band still draws
1057 }
1058 // The engine holds the first prompt for this hook, so the cockpit is ready when it
1059 // comes; should reading it (a long resumed transcript) take longer than that, the
1060 // session starts anyway and the cockpit fills in after.
1061 const booting = boot($, e.isInteractive).catch(err => debugLog($, 'boot', err))
1062 let timer: { cancel: () => void } | undefined
1063 await Promise.race([booting, new Promise<void>(resolve => (timer = startTimer($, BOOT_WAIT_MS, resolve)))])
1064 timer?.cancel()
1065 return next(e)
1066}
1067
1068/** How long session.start waits for the cockpit before letting the session go on. */
1069const BOOT_WAIT_MS = 1500
1070
1071/** A one-off timer; where there is no clock, `fn` runs at once. */
1072function startTimer($: Dollar, ms: number, fn: () => void): { cancel: () => void } | undefined {
1073 try {
1074 return $.clock.after(ms, fn)
1075 } catch {
1076 fn()
1077 return undefined
1078 }
1079}
1080
1081/** The person's own prompts: typed, from their phone, or the SDK. */
1082function isPersonal(origin: PromptOrigin | undefined): boolean {
1083 return origin === undefined || ['composer', 'bridge', 'sdk', 'unclassified'].includes(origin.kind)
1084}
1085
1086export const onPromptSubmit: Events['prompt.submit'] = async ($, e, next) => {
1087 const result = await next(e)
1088 try {
1089 if (result.drop === undefined) {
1090 const at = await now($)
1091 // A task's notice, as a turn of its own or delivered into one: the job or agent it names has ended.
1092 const notices = e.origin.kind === 'task-notification' ? parseNotices(result.text) : []
1093 let before: Cockpit | undefined
1094 // Folded: a background check that failed is a caution like any other.
1095 const after = await fold($, at, c => {
1096 before = c
1097 const told = notices.length > 0 ? onNotices(c, at, notices) : c
1098 return onPrompt(told, { at, text: result.text, isPersonal: isPersonal(e.origin) })
1099 })
1100 if (before && notices.length > 0) {
1101 noteEndedJobs($, before, after, at)
1102 planAi($)
1103 }
1104 }
1105 } catch {
1106 // fail open: the prompt went through untouched
1107 }
1108 return result
1109}
1110
1111export const onTurnStart: Events['turn.start'] = async ($, e, next) => {
1112 try {
1113 const at = await now($)
1114 await syncSession($, at)
1115 const reading = await readUsage($, at)
1116 await fold($, at, c => {
1117 // Taken between turns, the reading settles the last turn's cost before this one starts.
1118 const started = startTurn(reading ? settleWith(c, reading) : c, { at, turnId: e.turnId, text: e.text, ...(reading?.costUsd !== undefined ? { costUsd: reading.costUsd } : {}) })
1119 return reading ? onReading(started, reading) : started
1120 })
1121 // /model may have changed it since the last turn.
1122 await attempt(() => refreshMeta($, at, {}), undefined)
1123 // The agents at work as the turn begins: ones left from earlier turns, a skill's, a teammate.
1124 safely($, 'agents', () => lookUpAgents($))
1125 // The AI ETA, when it is on, is due once the turn has worked a minute: a timer, never this hook's work.
1126 planAi($)
1127 } catch {
1128 // fail open
1129 }
1130 return next(e)
1131}
1132
1133/**
1134 * Each main-loop model response, as it ends: pace counts its new tokens at
1135 * once. It observes only: every chunk passes on as it came, the request goes
1136 * out at once (the time is read alongside, not before), and the response is
1137 * handed back before quackpit folds it in. A subagent's steps are its own.
1138 */
1139export const onTurnStep: Events['turn.step'] = async function* ($, e, next) {
1140 // A loop's next request: whatever dialog held it is over, since it moved on.
1141 if (hint.waitLoops.has(e.agentId ?? MAIN_LOOP)) safely($, 'step start', () => endLoopWaits($, e.agentId))
1142 if (e.agentId !== undefined) return yield* next(e)
1143 const startedAt = now($)
1144 const response = yield* next(e)
1145 safely($, 'step', () => foldStep($, e.turnId, e.model, response, startedAt))
1146 return response
1147}
1148
1149/** The waits of one loop end: it went on to its next request. */
1150async function endLoopWaits($: Dollar, agentId: string | undefined) {
1151 const at = await now($)
1152 await apply($, at, c => onStepStart(c, { at, ...(agentId !== undefined ? { agentId } : {}) }))
1153 planAi($)
1154}
1155
1156async function foldStep($: Dollar, turnId: string, model: string, response: TurnStepResult, startedAt: Promise<number>) {
1157 if (!response.usage) return
1158 const at = await now($)
1159 const began = await startedAt
1160 const usage = response.usage
1161 await fold($, at, c => onStep(c, { at, startedAt: began, turnId, model, usage, answer: response.answer }))
1162}
1163
1164/** The readings Claude Code pushes after each main turn and when a plan window moves a point. */
1165export const onMeasure: Events['session.measure'] = async ($, e, next) => {
1166 const result = await next(e)
1167 try {
1168 const at = await now($)
1169 const reading: Reading = { ...readingOf(e, at), pushed: true }
1170 const c = await fold($, at, before => onReading(before, reading))
1171 if (isCompactPointDue(c)) safely($, 'context', () => readContext($))
1172 } catch {
1173 // fail open
1174 }
1175 return result
1176}
1177
1178let isReadingPoint = false
1179
1180/** The breakdown as the split reads it: /context's rows and the lists beneath them, plain. */
1181function breakdownOf(b: NonNullable<SessionUsage['context']['breakdown']>): BreakdownInput {
1182 return {
1183 categories: b.categories.map(row => ({ name: row.name, tokens: isNum(row.tokens) ? row.tokens : 0, kind: row.kind })),
1184 mcpTools: (b.mcpTools ?? []).map(tool => ({ serverName: tool.serverName, tokens: isNum(tool.tokens) ? tool.tokens : 0, isLoaded: tool.isLoaded === true })),
1185 memoryFiles: (b.memoryFiles ?? []).map(file => ({ path: file.path, tokens: isNum(file.tokens) ? file.tokens : 0 })),
1186 agents: (b.agents ?? []).map(agent => ({ agentType: agent.agentType, tokens: isNum(agent.tokens) ? agent.tokens : 0 })),
1187 ...(b.skills ? { skills: { totalSkills: b.skills.totalSkills, includedSkills: b.skills.includedSkills, tokens: b.skills.tokens } } : {}),
1188 }
1189}
1190
1191/**
1192 * What fills the context, and where Claude Code compacts it by itself: the
1193 * breakdown /context draws, which the engine estimates locally (`summary`
1194 * sends no request), and the conversation the next request is built from
1195 * (`$.session.messages({ as: 'api' })`, the live window after a compaction),
1196 * measured by source: sizes and tool names, never its words. Once at
1197 * start-up, at most once a turn after it (when the turn's reading arrives),
1198 * and once after a compaction, which no reading follows. A timer's work,
1199 * never a drawing's.
1200 */src/ai.ts 332 lines1// The AI ETA, off by default and on only while its setting is. Once a main
2// turn has worked a minute with nothing measured to say how long it has left,
3// quackpit asks Claude Haiku, in the background, how many minutes the turn has
4// left: from what the turn began with (secrets masked, cut), what Claude has
5// said so far, the task list if any, and your recorded turn lengths to
6// calibrate by. It asks again (4 calls a turn at most, the next no sooner than
7// 2, then 4, then 8 minutes after the last) when the turn runs past its
8// answer, when a wait on the person ended, or when the work changed shape,
9// and tells it its earlier answers and what happened since. It reads one
10// number from each reply, strictly; anything else draws nothing. Every call's
11// own usage is tallied by day (tokens, never dollars: there is no price
12// reading for these calls), answered and failed calls counted, and each
13// answer is scored at the turn's real end against the history-only estimate
14// made at the same moment. Only numbers are kept, never a prompt or a reply.
15// The call is a completion of its own: nothing reaches the conversation's
16// context. Pure: the adapter makes the call and keeps the store.
17
18import { clockText, formatSpan } from './format'
19import type { Calibration } from './lengths'
20import type { AiAsk, AiWhy, TurnSourceKind } from './model'
21
22export const AI_MODEL = 'haiku'
23export const AI_EFFORT = 'low'
24/** A turn that has worked this long with no measured estimate is asked about. */
25export const AI_AFTER_MS = 60_000
26/** Calls a turn at most: its first answer and three revisions. */
27export const AI_MAX_CALLS = 4
28/** The least time after a call before the next: 2 minutes, then 4, then 8. */
29export const AI_GAPS_MS = [2 * 60_000, 4 * 60_000, 8 * 60_000] as const
30/** Past its answer by this much at least (or half the answer, when that is more), the turn is asked about again. */
31export const AI_OVERRUN_MS = 30_000
32export const AI_TIMEOUT_MS = 10_000
33/** One number fits in a few tokens; a reply that runs on is cut and read as none. */
34export const AI_MAX_TOKENS = 16
35/** Past 12 hours an estimate is no help (the measured one's limit too). */
36export const AI_MAX_MINUTES = 720
37
38/** One task as the request lists it. */
39export type AiTask = { title: string; status: 'pending' | 'in_progress' | 'completed' }
40
41export type AiInput = {
42 /** How long the turn has worked (its waits on the person left out), and the tool calls it made. */
43 elapsedMs: number
44 tools: number
45 /** The text the turn began with, masked and cut. */
46 text?: string
47 /** What started the turn when no person typed it, in English words (the request is in English). */
48 startedBy?: string
49 /** Claude's own words so far this turn, masked and cut. */
50 words?: string
51 tasks?: readonly AiTask[]
52 calibration?: Calibration
53 /** A re-ask: the answers given before, oldest first, and what happened since the last. */
54 previous?: readonly AiPrevious[]
55 since?: AiSince
56}
57
58/** An earlier answer as a re-ask recalls it: when it was given, how long the turn had worked then, and what it said. */
59export type AiPrevious = { at: number; elapsedMs: number; leftMs?: number }
60
61/** What happened since the last answer: the working time since, what happened, and Claude's latest words. */
62export type AiSince = { workedMs: number; heldMs: number; notes: readonly string[]; lastWords?: string }
63
64export type AiRequest = { model: string; system: string; prompt: string; maxTokens: number; effort: 'low'; timeoutMs: number }
65
66const SYSTEM =
67 'You estimate how much longer a turn of Claude Code, an AI coding assistant, will run. A turn starts when a request arrives and ends when Claude has finished working on it and replied. Answer with a single number: the minutes left. Write only the number.'
68
69/** What started a turn no person typed, in the request's words (it is written in English). */
70const STARTED_BY: Record<TurnSourceKind, string> = {
71 report: "a subagent's report",
72 task: "a background task's notice",
73 teammate: "a teammate's message",
74 peer: "another session's message",
75 event: 'a background event',
76 command: 'a slash command',
77 compaction: 'the summary of a compacted conversation',
78 plugin: "a plugin's message",
79 system: 'a system message',
80}
81
82export function startedBy(kind: TurnSourceKind): string {
83 return STARTED_BY[kind]
84}
85
86/** Quoted text, set off so the model reads it as material, not as instructions to it. */
87const quoted = (text: string) => `<<<\n${text}\n>>>`
88
89/** The tasks shown at most: the open ones are what is left. */
90const MAX_TASKS = 12
91const MARK: Record<AiTask['status'], string> = { completed: '[x]', in_progress: '[>]', pending: '[ ]' }
92/** What happened since the last answer, the latest few. */
93const MAX_NOTES = 12
94
95const minutes = (ms: number) => {
96 const m = Math.round(ms / 6000) / 10
97 return `${Number.isInteger(m) ? m : m.toFixed(1)} minute${m === 1 ? '' : 's'}`
98}
99
100/** One completion the AI ETA makes for a turn: its first question, or a re-ask with what came before. */
101export function aiRequest(input: AiInput): AiRequest {
102 const parts: string[] = [`This turn has worked ${formatSpan(input.elapsedMs)} so far (time spent waiting on the person left out) and made ${input.tools} tool call${input.tools === 1 ? '' : 's'}.`]
103 if (input.text) {
104 const what = input.startedBy ? `The message that started the turn (${input.startedBy}, not typed by the person)` : "The person's request"
105 parts.push(`${what}, secrets masked, long text cut:\n${quoted(input.text)}`)
106 }
107 parts.push(input.words ? `What Claude has said so far this turn, cut:\n${quoted(input.words)}` : 'Claude has said nothing yet this turn.')
108 const tasks = input.tasks ?? []
109 if (tasks.length > 0) {
110 const done = tasks.filter(task => task.status === 'completed').length
111 // The open tasks first when the list is long: they are what is left.
112 const shown = tasks.length > MAX_TASKS ? [...tasks.filter(task => task.status !== 'completed'), ...tasks.filter(task => task.status === 'completed')].slice(0, MAX_TASKS) : tasks
113 const lines = shown.map(task => `${MARK[task.status]} ${task.title}`)
114 if (shown.length < tasks.length) lines.push(`(${tasks.length - shown.length} more)`)
115 parts.push(`Claude's task list, ${done} of ${tasks.length} done:\n${lines.join('\n')}`)
116 }
117 const cal = input.calibration
118 if (cal) {
119 const all = `This person's recorded turns (${cal.n}): half ended within ${formatSpan(cal.p50)}, 9 in 10 within ${formatSpan(cal.p90)}, 19 in 20 within ${formatSpan(cal.p95)}.`
120 const longer = cal.longer ? ` Of the ${cal.longer.n} that ran past ${formatSpan(input.elapsedMs)}, half ended within ${formatSpan(cal.longer.p50)} in all and 9 in 10 within ${formatSpan(cal.longer.p90)}.` : ''
121 parts.push(all + longer)
122 }
123 const previous = input.previous ?? []
124 if (previous.length > 0) {
125 const said = previous.map(p => `at ${clockText(p.at)}, ${formatSpan(p.elapsedMs)} into the turn, ${p.leftMs !== undefined ? `you said ${minutes(p.leftMs)} were left` : 'you gave no number'}`)
126 parts.push(`You were asked before: ${said.join('; ')}.`)
127 const since = input.since
128 if (since) {
129 const held = since.heldMs >= 1000 ? `, besides ${formatSpan(since.heldMs)} it waited on the person` : ''
130 const notes = since.notes.slice(-MAX_NOTES)
131 const happened = notes.length > 0 ? `\n${notes.map(n => `- ${n}`).join('\n')}` : ' nothing new.'
132 parts.push(`Since your last answer the turn has worked ${formatSpan(since.workedMs)}${held}. What happened since:${happened}`)
133 if (since.lastWords) parts.push(`Claude's latest words, cut:\n${quoted(since.lastWords)}`)
134 }
135 parts.push('Keep your previous estimate unless what happened since changes it.')
136 }
137 parts.push('How many more minutes will this turn run? Answer with the number only.')
138 return { model: AI_MODEL, system: SYSTEM, prompt: parts.join('\n\n'), maxTokens: AI_MAX_TOKENS, effort: AI_EFFORT, timeoutMs: AI_TIMEOUT_MS }
139}
140
141// ------------------------------------------------------------------ when to ask
142
143/** What the planner decides: ask now (and why), look again in `inMs`, or nothing to do. */
144export type AiPlan = { kind: 'none' } | { kind: 'ask'; why: AiWhy } | { kind: 'later'; inMs: number }
145
146export type AiPlanInput = {
147 now: number
148 /** How long the turn has worked so far: its waits on the person left out. */
149 workedMs: number
150 /** A wait on the person holds the turn now: no call goes out during one. */
151 isWaiting: boolean
152 /** A call is in flight: never two at once. */
153 isAsking: boolean
154 /** This turn's calls so far. */
155 asks: readonly AiAsk[]
156 /** Something measured says how long the turn's own work has left: the first call defers to it. */
157 isCovered: boolean
158 /** A reason to ask again that came since the last call (a wait ended, the work changed shape). */
159 due?: AiWhy
160 /** Working time since the latest answer was asked for. */
161 workedSinceLast?: number
162}
163
164/** The least time after the `n`th call (1-based) before the next one goes out. */
165export function gapAfter(n: number): number {
166 return AI_GAPS_MS[Math.min(AI_GAPS_MS.length, Math.max(1, n)) - 1] ?? 8 * 60_000
167}
168
169/** How far past its answer the turn may run before it is asked again: 30 seconds, or half the answer. */
170export function overrunAfter(leftMs: number): number {
171 return leftMs + Math.max(AI_OVERRUN_MS, leftMs / 2)
172}
173
174/**
175 * When the AI ETA is asked: first, once the turn has worked a minute with
176 * nothing measured covering it; then again when the turn runs past the last
177 * answer by 30 seconds or half that answer (whichever is more), or for a
178 * reason that came since (a wait on the person ended, the work changed
179 * shape, the measured estimate it deferred to went away). Four calls a turn
180 * at most, the next no sooner than 2, 4 and then 8 minutes after the one
181 * before, none while one is in flight or while the turn waits on the person.
182 */
183export function planAsk(p: AiPlanInput): AiPlan {
184 if (p.isWaiting || p.isAsking || p.asks.length >= AI_MAX_CALLS) return { kind: 'none' }
185 if (p.asks.length === 0) {
186 if (p.isCovered) return { kind: 'none' }
187 if (p.workedMs < AI_AFTER_MS) return { kind: 'later', inMs: AI_AFTER_MS - p.workedMs }
188 return { kind: 'ask', why: p.due === 'deferred' ? 'deferred' : 'first' }
189 }
190 const last = p.asks.at(-1) as AiAsk
191 const waitGap = Math.max(0, last.askedAt + gapAfter(p.asks.length) - p.now)
192 const answered = p.asks.findLast(a => a.state === 'answered' && a.leftMs !== undefined)
193 const since = p.workedSinceLast ?? 0
194 const overrunIn = answered?.leftMs !== undefined ? overrunAfter(answered.leftMs) - since : undefined
195 const why: AiWhy | undefined = overrunIn !== undefined && overrunIn < 0 ? 'overrun' : p.due
196 if (why) return waitGap > 0 ? { kind: 'later', inMs: waitGap } : { kind: 'ask', why }
197 // Nothing to ask about yet: look again when the turn would run past the answer.
198 return overrunIn !== undefined ? { kind: 'later', inMs: Math.max(waitGap, overrunIn + 1000) } : { kind: 'none' }
199}
200
201/**
202 * The minutes a reply says, read strictly: a number alone (`8`, `12.5`), 0 to
203 * 720. Anything else (words, a range, a unit, a reply cut short) is none, and
204 * draws nothing.
205 */
206export function parseMinutes(reply: string): number | undefined {
207 const m = /^\s*(\d{1,4}(?:\.\d{1,2})?)\s*$/.exec(reply)
208 if (!m) return undefined
209 const minutes = Number(m[1])
210 return Number.isFinite(minutes) && minutes >= 0 && minutes <= AI_MAX_MINUTES ? minutes : undefined
211}
212
213// ------------------------------------------------------------------ the tally
214
215/**
216 * How a call ended: `answered` with a number quackpit read; `unread`, a reply
217 * that was no number; `failed`, no reply (an API error, an empty reply, the
218 * 10 seconds out, a request refused); `cancelled`, cut by quackpit because
219 * the turn ended first.
220 */
221export type AiOutcome = 'answered' | 'unread' | 'failed' | 'cancelled'
222
223/** A day's calls (of one session, or summed) and the tokens their own usage reported. */
224export type AiTally = {
225 calls: number
226 answered: number
227 unread: number
228 failed: number
229 cancelled: number
230 input: number
231 output: number
232 cacheRead: number
233 cacheWrite: number
234}
235
236/** The four counts a call's usage reports, as the API spells them. */
237export type AiUsage = { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
238
239export function emptyTally(): AiTally {
240 return { calls: 0, answered: 0, unread: 0, failed: 0, cancelled: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
241}
242
243const count = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) && x > 0 ? Math.round(x) : 0)
244
245/** A tally with one more call, its tokens as its own usage reported them (every outcome has one). */
246export function addCall(tally: AiTally | undefined, outcome: AiOutcome, usage: AiUsage | undefined): AiTally {
247 const t = tally ?? emptyTally()
248 return {
249 ...t,
250 calls: t.calls + 1,
251 [outcome]: t[outcome] + 1,
252 input: t.input + count(usage?.input_tokens),
253 output: t.output + count(usage?.output_tokens),
254 cacheRead: t.cacheRead + count(usage?.cache_read_input_tokens),
255 cacheWrite: t.cacheWrite + count(usage?.cache_creation_input_tokens),
256 }
257}
258
259/** A stored tally read back defensively: missing counts are zero, anything else is none. */
260export function asTally(x: unknown): AiTally | undefined {
261 if (typeof x !== 'object' || x === null) return undefined
262 const r = x as Record<string, unknown>
263 const t = emptyTally()
264 for (const key of Object.keys(t) as (keyof AiTally)[]) t[key] = count(r[key])
265 return t.calls > 0 ? t : undefined
266}
267
268export function sumTallies(tallies: readonly AiTally[]): AiTally {
269 const sum = emptyTally()
270 for (const t of tallies) for (const key of Object.keys(sum) as (keyof AiTally)[]) sum[key] += t[key]
271 return sum
272}
273
274/** Every token a tally's calls reported: input, output, cache reads and cache writes. */
275export function tallyTokens(t: AiTally): number {
276 return t.input + t.output + t.cacheRead + t.cacheWrite
277}
278
279// ------------------------------------------------------------------ the score
280
281/**
282 * One answer, scored when its turn ended: when it was asked, how long the
283 * turn had worked, what the AI said was left, what history alone said at the
284 * same moment (-1 when it had nothing to say), what was really left (the
285 * turn's working time from then to its end), and which answer of the turn it
286 * was (1 the first; 2 to 4 its revisions; an older record has no sixth
287 * number and was a first answer). Numbers only.
288 */
289export type AiRecord = [askedAt: number, elapsedMs: number, aiMs: number, historyMs: number, actualMs: number, n?: number]
290
291/** Answers scored before "within 2×" is shown: under 20 a share means little. */
292export const MIN_SCORED = 20
293/** Records kept across sessions, the newest. */
294export const KEEP_RECORDS = 200
295
296/** Within 2× of the truth: neither more than twice the other. */
297export function isWithin2x(predictedMs: number, actualMs: number): boolean {
298 return predictedMs <= 2 * actualMs && actualMs <= 2 * predictedMs
299}
300
301export function asRecords(x: unknown): AiRecord[] {
302 if (!Array.isArray(x)) return []
303 return x.filter((r): r is AiRecord => Array.isArray(r) && (r.length === 5 || r.length === 6) && r.every(n => typeof n === 'number' && Number.isFinite(n)) && r[2] >= 0 && r[4] >= 0)
304}
305
306/** Whether a record is a revision (a later answer in its turn), not a first answer. */
307export const isRevision = (r: AiRecord) => (r[5] ?? 1) > 1
308
309/**
310 * How often each landed within 2× of what was really left, over the same
311 * answers: those the AI gave. An answer history could not match counts
312 * against history, as an estimate it did not give.
313 */
314export type AiScore = { n: number; ai: number; history: number }
315
316export function scoreOf(records: readonly AiRecord[]): AiScore {
317 let ai = 0
318 let history = 0
319 for (const [, , aiMs, historyMs, actualMs] of records) {
320 if (isWithin2x(aiMs, actualMs)) ai++
321 if (historyMs >= 0 && isWithin2x(historyMs, actualMs)) history++
322 }
323 return { n: records.length, ai, history }
324}
325
326/** First answers and revisions, scored apart: a revision knows more of the turn than a first answer did. */
327export type AiScores = { first: AiScore; revised: AiScore }
328
329export function scoresOf(records: readonly AiRecord[]): AiScores {
330 return { first: scoreOf(records.filter(r => !isRevision(r))), revised: scoreOf(records.filter(isRevision)) }
331}
332src/cache.ts 51 lines1// The prompt cache's clock: how long the main conversation's cached prompt
2// stays warm after its last request, and what the next prompt rewrites once
3// it has gone cold. The lifetime is decided the way Claude Code decides it
4// (code.claude.com/docs/en/prompt-caching; claude-cache-timer, MIT, reads it
5// the same way), first match wins: FORCE_PROMPT_CACHING_5M, then
6// CLAUDE_CODE_PROMPT_CACHE_TTL, then the `promptCacheTtl` setting, then
7// ENABLE_PROMPT_CACHING_1H, then the account: an hour on a subscription within
8// its plan, 5 minutes otherwise. The lifetime counts from the start of the
9// request that last read or wrote it. Pure.
10
11import { MINUTE } from './metrics'
12import type { Cockpit, Reading } from './model'
13
14export type Ttl = '5m' | '1h'
15
16/** What the environment and the settings say, before the account has a say. */
17export type TtlInputs = { force5m?: string; ttlVar?: string; setting?: unknown; enable1h?: string }
18
19const isOn = (v: string | undefined) => v === '1' || v?.toLowerCase() === 'true'
20const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
21
22/** The lifetime the environment or the settings force, if any does. */
23export function forcedTtl(inputs: TtlInputs): Ttl | undefined {
24 return (isOn(inputs.force5m) ? '5m' : undefined) ?? asTtl(inputs.ttlVar) ?? asTtl(inputs.setting) ?? (isOn(inputs.enable1h) ? '1h' : undefined)
25}
26
27/**
28 * The lifetime in force: the forced one, else the account's. A five-hour or
29 * seven-day window in the readings means a subscription; one at 100% means
30 * requests now draw on usage credits, which cache for 5 minutes.
31 */
32export function ttlOf(forced: Ttl | undefined, reading: Reading | undefined): Ttl {
33 if (forced) return forced
34 const windows = [reading?.fiveHour, reading?.sevenDay].filter(w => w !== undefined)
35 return windows.length > 0 && windows.every(w => w.pct < 100) ? '1h' : '5m'
36}
37
38export const ttlMs = (ttl: Ttl) => (ttl === '1h' ? 60 * MINUTE : 5 * MINUTE)
39
40export type CacheClock = { state: 'turn'; ttl: Ttl } | { state: 'warm'; ttl: Ttl; left: number } | { state: 'cold'; ttl: Ttl; rewrite: number }
41
42/** Where the cache stands now: warm while a turn runs, then the minutes left, then cold. Undefined before the first response. */
43export function cacheClock(c: Cockpit, now: number, ttl: Ttl): CacheClock | undefined {
44 const last = c.lastStep
45 if (!last) return undefined
46 if (c.turn) return { state: 'turn', ttl }
47 const left = last.startedAt + ttlMs(ttl) - now
48 if (left > 0) return { state: 'warm', ttl, left }
49 return { state: 'cold', ttl, rewrite: last.read + last.write + last.input }
50}
51src/checks.ts 366 lines1// Which Bash commands are checks (test, typecheck, lint, build), and what
2// their result said. quackpit never runs anything: it reads the command the
3// main agent ran and the result the tool answered. Pure.
4//
5// Detection reuses token-detox's `isVerifyCommand` (src/metrics.ts) as the
6// first pass, widened by a few runners it does not know, then confirms the
7// kind on the command's own segments, so `cat jest.config.js` is no check.
8// A chain or a loop that runs several checks is one run of each.
9
10import { maskSecrets } from './mask'
11import { isVerifyCommand } from './metrics'
12
13export type CheckKind = 'test' | 'typecheck' | 'lint' | 'build'
14
15/** `hidden`: it ran, but what it said was cut away and its exit hidden (a test run's summary behind `| tail -4`): no verdict. */
16export type CheckStatus = 'pass' | 'fail' | 'running' | 'interrupted' | 'background' | 'hidden'
17
18export type CheckResult = {
19 /** The tool call's id, so a result finds its own start. */
20 id: string
21 /** The kind it is named by: a failure's own kind, else the chain's last. */
22 kind: CheckKind
23 /** Every kind the call ran, in order, when it ran more than one (a chain, a loop). */
24 kinds?: CheckKind[]
25 /** The kinds a failed chain's output names as failing; absent, the failure is the whole chain's. */
26 judged?: CheckKind[]
27 /** The command as run, masked and cut to 200 characters. */
28 command: string
29 status: CheckStatus
30 passed?: number
31 failed?: number
32 errors?: number
33 warnings?: number
34 startedAt: number
35 endedAt?: number
36 /** Measured around the tool call: what it took, not what it printed. */
37 durationMs?: number
38 turn: number
39 /** Its call's place among the session's main calls, so what came after it is known even without times. */
40 seq?: number
41 /** A failed run's last lines, masked and short: what its detail shows. */
42 tail?: string
43 /** Rebuilt from a resumed transcript: when it ran is not known. */
44 replayed?: true
45}
46
47/** Every kind a check ran. */
48export function kindsOf(check: Pick<CheckResult, 'kind' | 'kinds'>): CheckKind[] {
49 return check.kinds ?? [check.kind]
50}
51
52/**
53 * The kinds a finished run counts for: all of them when it passed, and when
54 * it failed, those its output names as failing (or, naming none, all of them:
55 * the failure is the whole chain's).
56 */
57export function judgedKinds(check: Pick<CheckResult, 'kind' | 'kinds' | 'judged'>): CheckKind[] {
58 return check.judged ?? kindsOf(check)
59}
60
61/** Runners `VERIFY_RE` does not list. */
62const EXTRA_RE =
63 /\bclaude\s+plugin\s+(test|validate)\b|\bnode\s+--test\b|\bdeno\s+(test|check|lint)\b|\bbun\s+test\b|\bcargo\s+(clippy|build|nextest)\b|\bgo\s+build\b|\b(vite|next|nuxt|astro)\s+build\b|\b(tsup|esbuild|webpack|rollup)\b|\bdocker\s+build\b|\b(pnpm|npm|yarn|bun)\s+(run\s+)?(type-?check|check-types|lint:\S+|test:\S+)\b|\b(biome|oxlint|pyright|flake8|pylint|golangci-lint|rubocop|stylelint|shellcheck|phpunit|rspec|vue-tsc|playwright\s+test|xcodebuild|swift\s+(build|test)|dotnet\s+(build|test)|gradlew?\s+(build|test|check)|mvn\s+(test|verify|package))\b/
64
65/** Leading words that run the command after them: `npx vitest`, `pnpm exec tsc`, `pnpm vitest`. */
66const WRAPPER_RE =
67 /^(?:(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)|(?:time|nice|command|exec|env|caffeinate|timeout\s+\S+)\s+|(?:npx|bunx|pnpx)(?:\s+-[a-zA-Z]+(?:\s+\S+@\S+|\s+-p\s+\S+)?)*\s+|(?:pnpm|yarn|npm)\s+(?:exec|dlx|x)\s+|(?:pnpm|yarn|bun)\s+(?=(?:vitest|jest|mocha|ava|tap|tsc|vue-tsc|eslint|oxlint|biome|prettier|playwright|tsup|esbuild|webpack|rollup|vite|next)\b)|(?:uv|poetry|pipenv|hatch)\s+run\s+|python3?\s+-m\s+)+/
68
69const SEGMENT_KIND: readonly [CheckKind, RegExp][] = [
70 [
71 'test',
72 /^(?:(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?(?:test|test:\S+|check)\b|(?:vitest|jest|pytest|mocha|ava|tap|rspec|phpunit|ctest|tox|nox)\b|playwright\s+test\b|cypress\s+run\b|go\s+test\b|cargo\s+(?:test|nextest)\b|make\s+(?:test|check)\b|node\s+--test\b|deno\s+test\b|bun\s+test\b|claude\s+plugin\s+test\b|swift\s+test\b|dotnet\s+test\b|gradlew?\s+(?:test|check)\b|mvn\s+(?:test|verify)\b|unittest\b)/,
73 ],
74 [
75 'typecheck',
76 /^(?:(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?(?:typecheck|type-check|check-types|tsc|types)\b|(?:tsc|vue-tsc|mypy|pyright)\b|cargo\s+check\b|go\s+vet\b|deno\s+check\b|claude\s+plugin\s+validate\b)/,
77 ],
78 [
79 'lint',
80 /^(?:(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?(?:lint|lint:\S+)\b|(?:eslint|oxlint|ruff|flake8|pylint|golangci-lint|rubocop|stylelint|shellcheck)\b|biome\s+(?:check|lint|ci)\b|cargo\s+clippy\b|deno\s+lint\b|prettier\s+(?:--check|-c)\b)/,
81 ],
82 [
83 'build',
84 /^(?:(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?(?:build|build:\S+)\b|cargo\s+build\b|go\s+build\b|make\b(?!\s+(?:test|check|clean|install))|(?:vite|next|nuxt|astro)\s+build\b|(?:tsup|esbuild|webpack|rollup)\b|xcodebuild\b|swift\s+build\b|dotnet\s+build\b|gradlew?\s+build\b|mvn\s+package\b|docker\s+build\b)/,
85 ],
86]
87
88/**
89 * The words that open or close a compound command rather than run one: a
90 * loop's `do`, an `if`'s condition and branches, a group's braces. A segment
91 * that starts with one runs what follows it (`do node --test $f`).
92 */
93const SHELL_WORDS_RE = /^(?:(?:do|then|else|elif|if|while|until|time|!)\s+|[({]\s*)+/
94/** A loop's head (`for f in a b`) and the words that close a compound command run nothing. */
95const NO_COMMAND_RE = /^(?:for|select|case)\s|^(?:done|fi|esac|[)}])$/
96
97/**
98 * Splits a shell line on `&&`, `||`, `;` and `|` (not inside quotes) into the
99 * commands it runs: `cd` left out, a loop's or an `if`'s own words taken off
100 * (`for f in a b; do node --test $f; done` runs `node --test $f`).
101 */
102export function segments(command: string): string[] {
103 const out: string[] = []
104 let cur = ''
105 let quote: string | undefined
106 for (let i = 0; i < command.length; i++) {
107 const ch = command[i] ?? ''
108 if (quote) {
109 if (ch === quote) quote = undefined
110 cur += ch
111 continue
112 }
113 if (ch === '"' || ch === "'") {
114 quote = ch
115 cur += ch
116 continue
117 }
118 if (ch === ';' || ch === '\n' || ch === '|' || (ch === '&' && command[i + 1] === '&')) {
119 out.push(cur)
120 cur = ''
121 if ((ch === '|' && command[i + 1] === '|') || ch === '&') i++
122 continue
123 }
124 cur += ch
125 }
126 out.push(cur)
127 return out
128 .map(s => s.trim())
129 .filter(s => !NO_COMMAND_RE.test(s))
130 .map(s => s.replace(SHELL_WORDS_RE, '').replace(/\s*[)}]+$/, '').trim())
131 .filter(s => s !== '' && !NO_COMMAND_RE.test(s) && !/^cd\b/.test(s))
132}
133
134/**
135 * Whether the line's exit status may not be the check's own: a pipe (`| tail`),
136 * an `||`, or a `;` or newline after it. `&&` passes a failure on. Quotes are skipped.
137 */
138export function masksExit(command: string): boolean {
139 let quote: string | undefined
140 for (const ch of command.trim()) {
141 if (quote) {
142 if (ch === quote) quote = undefined
143 continue
144 }
145 if (ch === '"' || ch === "'") quote = ch
146 else if (ch === '|' || ch === ';' || ch === '\n') return true
147 }
148 return false
149}
150
151/**
152 * Whether the line's exit status is a pipe's last command's (`pnpm test 2>&1
153 * | grep -E "pass|fail"`), its other joins all `&&`: the filter decides the
154 * exit, so where the runner's summary got through, the summary decides.
155 */
156export function exitIsPipe(command: string): boolean {
157 let quote: string | undefined
158 let last = ''
159 const line = command.trim()
160 for (let i = 0; i < line.length; i++) {
161 const ch = line[i] ?? ''
162 if (quote) {
163 if (ch === quote) quote = undefined
164 continue
165 }
166 if (ch === '"' || ch === "'") quote = ch
167 else if (ch === ';' || ch === '\n' || (ch === '|' && line[i + 1] === '|')) return false
168 else if (ch === '|') last = '|'
169 else if (ch === '&' && line[i + 1] === '&') {
170 last = '&&'
171 i++
172 }
173 }
174 return last === '|'
175}
176
177/**
178 * Every kind of check a command runs, in the order it runs them, each once:
179 * `pnpm typecheck && pnpm test` runs a typecheck and the tests. Empty when it
180 * runs none.
181 */
182export function checkKinds(command: string): CheckKind[] {
183 if (!isVerifyCommand(command) && !EXTRA_RE.test(command)) return []
184 const kinds: CheckKind[] = []
185 for (const segment of segments(command)) {
186 const bare = segment.replace(WRAPPER_RE, '')
187 const kind = SEGMENT_KIND.find(([, pattern]) => pattern.test(bare))?.[0]
188 if (kind && !kinds.includes(kind)) kinds.push(kind)
189 }
190 return kinds
191}
192
193/** The first kind of check a command runs, or undefined when it runs none. */
194export function checkKind(command: string): CheckKind | undefined {
195 return checkKinds(command)[0]
196}
197
198/** Counts a result printed: tests passed and failed, errors and warnings. */
199export type CheckCounts = { passed?: number; failed?: number; errors?: number; warnings?: number }
200
201function num(match: RegExpMatchArray | null, group = 1): number | undefined {
202 const v = match?.[group]
203 if (v === undefined) return undefined
204 const n = Number(v)
205 return Number.isFinite(n) ? n : undefined
206}
207
208/**
209 * Reads the summary lines of common runners; undefined fields were not
210 * printed. A runner that printed its summary more than once (a loop ran it
211 * several times) has its counts summed; the first runner whose summary
212 * appears decides each count.
213 */
214export function parseCounts(text: string): CheckCounts {
215 const out: CheckCounts = {}
216 const set = (key: keyof CheckCounts, value: number | undefined) => {
217 if (value !== undefined && out[key] === undefined) out[key] = value
218 }
219 /** Every match of a summary, its groups summed; undefined when it never appears. */
220 const sums = (re: RegExp, groups: readonly number[]): (number | undefined)[] | undefined => {
221 const totals: (number | undefined)[] = groups.map(() => undefined)
222 let seen = false
223 for (const m of text.matchAll(re)) {
224 seen = true
225 groups.forEach((g, i) => {
226 const n = num(m, g)
227 if (n !== undefined) totals[i] = (totals[i] ?? 0) + n
228 })
229 }
230 return seen ? totals : undefined
231 }
232 // vitest: "Tests 2 failed | 10 passed (12)"
233 const vitest = sums(/^\s*Tests\s+(?:(\d+)\s+failed\s*\|\s*)?(?:\d+\s+skipped\s*\|\s*)?(\d+)\s+passed/gm, [1, 2])
234 if (vitest) {
235 set('failed', vitest[0] ?? 0)
236 set('passed', vitest[1])
237 }
238 // jest: "Tests: 1 failed, 11 passed, 12 total"
239 const jest = sums(/Tests:\s+(?:(\d+)\s+failed,\s+)?(?:\d+\s+skipped,\s+)?(?:\d+\s+todo,\s+)?(\d+)\s+passed,\s+\d+\s+total/g, [1, 2])
240 if (jest) {
241 set('failed', jest[0] ?? 0)
242 set('passed', jest[1])
243 }
244 // cargo: "test result: FAILED. 10 passed; 2 failed;"
245 const cargo = sums(/test result: \w+\.\s+(\d+)\s+passed;\s+(\d+)\s+failed/g, [1, 2])
246 if (cargo) {
247 set('passed', cargo[0])
248 set('failed', cargo[1])
249 }
250 // node --test / TAP: "# pass 12" "# fail 1"
251 set('passed', sums(/^#\s+pass\s+(\d+)/gm, [1])?.[0])
252 set('failed', sums(/^#\s+fail\s+(\d+)/gm, [1])?.[0])
253 // mocha: "12 passing" "1 failing"
254 set('passed', sums(/\b(\d+)\s+passing\b/g, [1])?.[0])
255 set('failed', sums(/\b(\d+)\s+failing\b/g, [1])?.[0])
256 // tsc: "Found 3 errors in 2 files." / "Found 1 error."
257 set('errors', sums(/\bFound\s+(\d+)\s+errors?\b/g, [1])?.[0])
258 // eslint: "✖ 5 problems (3 errors, 2 warnings)"
259 const eslint = sums(/(\d+)\s+problems?\s+\((\d+)\s+errors?,\s+(\d+)\s+warnings?\)/g, [2, 3])
260 if (eslint) {
261 set('errors', eslint[0])
262 set('warnings', eslint[1])
263 }
264 // pytest and the generic summary lines: "3 failed, 9 passed in 1.2s", "12 passed".
265 // A count must end its clause, so "2 failed attempts" or "3 errors retried" is no summary.
266 const ENDS = String.raw`(?=\s*(?:[,;|()=]|\bin\b|$))`
267 set('failed', sums(new RegExp(String.raw`\b(\d+)\s+(?:tests?\s+)?failed\b${ENDS}`, 'gim'), [1])?.[0])
268 set('passed', sums(/\b(\d+)\s+(?:tests?\s+)?passed\b/gi, [1])?.[0])
269 set('errors', sums(new RegExp(String.raw`\b(\d+)\s+errors?\b${ENDS}(?!\s*in\s+\d)`, 'gim'), [1])?.[0])
270 if (out.errors === undefined) {
271 const tsErrors = text.match(/error TS\d+:/g)
272 if (tsErrors) out.errors = tsErrors.length
273 }
274 return out
275}
276
277/** What in a failed run's output says which kind of check failed. */
278const FAILED_SAYS: Record<CheckKind, RegExp> = {
279 test: /^\s*(?:FAIL\b|not ok \d)|^#\s+fail\s+[1-9]|\b[1-9]\d*\s+(?:tests?\s+)?(?:failed|failing)\b/m,
280 typecheck: /\berror TS\d+:|^Found [1-9]\d* errors? in \d+ files?|\berror: .*\[[a-z-]+\]$|^\S+\.py:\d+: error:/m,
281 lint: /\b[1-9]\d* problems? \(|^\s*\d+:\d+\s+error\s|\bFound [1-9]\d* errors?\.\s*$|^.+:\d+:\d+: [A-Z]+\d+ /m,
282 build: /\bBuild failed\b|\berror during build\b|\bFailed to compile\b|^ERROR in\b|\bCompilation failed\b|^error(?:\[E\d+\])?: |\bbuild failed\b/m,
283}
284
285/**
286 * The kinds a failed run's output says failed, among those it ran: a runner's
287 * failure count, tsc's or a type checker's errors, a linter's problems, a
288 * build's error. Empty when it names none of them.
289 */
290export function failedKinds(text: string, kinds: readonly CheckKind[]): CheckKind[] {
291 return kinds.filter(kind => FAILED_SAYS[kind].test(text))
292}
293
294/** How a finished check came out, from the tool's own verdict and its output. */
295export function judgeCheck(input: {
296 isError: boolean
297 interrupted?: boolean
298 background?: boolean
299 text: string
300 /** The exit status may not be the check's own (`masksExit`); unknown counts as maybe. */
301 exitMasked?: boolean
302 /** The exit status is a pipe's last command's, the line's only other joins being `&&` (`exitIsPipe`). */
303 exitIsPipe?: boolean
304 /** The kinds the call ran, for a chain's failure to be put on the one its output names. */
305 kinds?: readonly CheckKind[]
306}): { status: CheckStatus; counts: CheckCounts; judged?: CheckKind[] } {
307 const counts = parseCounts(input.text)
308 if (input.background) return { status: 'background', counts }
309 if (input.interrupted) return { status: 'interrupted', counts }
310 // A failing exit wins. Where a pipe may hide it (`| tail`), so does a summary naming failures;
311 // where nothing hides it, a clean exit is a pass whatever the output mentions.
312 const failedByText =
313 input.exitMasked !== false && ((counts.failed ?? 0) > 0 || ((counts.errors ?? 0) > 0 && counts.passed === undefined))
314 // Where the line ends in a pipe (`… | grep x`), the exit is the last filter's: a runner's own
315 // summary that names passes and no failure decides then.
316 const cleanSummary = input.exitIsPipe === true && counts.failed === 0 && (counts.passed ?? 0) > 0 && !failedByText
317 const kinds = input.kinds ?? []
318 // A test run behind a pipe whose output kept no runner's summary at all: its result was cut away
319 // (`node --test 2>&1 | tail -4` keeps TAP's last counts, not its passes and failures). No verdict.
320 const isHidden = input.exitMasked === true && !input.isError && kinds.includes('test') && counts.passed === undefined && counts.failed === undefined
321 if (isHidden) return { status: 'hidden', counts }
322 const status: CheckStatus = cleanSummary ? 'pass' : input.isError || failedByText ? 'fail' : 'pass'
323 if (status !== 'fail' || kinds.length < 2) return { status, counts }
324 // A chain that failed: the failure is put on the checks its output names, else on the chain as a whole.
325 const named = failedKinds(input.text, kinds)
326 return named.length > 0 && named.length < kinds.length ? { status, counts, judged: named } : { status, counts }
327}
328
329/** The command as shown and kept: one line, masked, at most `max` characters. */
330export function shownCommand(command: string, max = 200): string {
331 const one = maskSecrets(command).replace(/\s+/g, ' ').trim()
332 return one.length > max ? `${one.slice(0, max - 1)}…` : one
333}
334
335/** A line that says what failed: a runner's FAIL, an error, an assertion, a count of failures; never a count of none. */
336const FAILING_LINE_RE = /\bFAIL\b|✗|✖|×|\bnot ok\b|\bError\b|\berror\b|Assertion|\bexpected\b|\bfailed\b|\bfailing\b|^#\s+fail\s+[1-9]/
337const NOTHING_FAILED_RE = /^#\s+fail\s+0\b|\b0 (?:failed|failing|errors?)\b/
338/** A runner's bookkeeping, never what failed: TAP's zero counts and its timing. */
339const NOISE_RE = /^#\s+(?:\w+\s+0|duration_ms\b)|^ℹ\s+(?:\w+\s+0|duration_ms\b)/
340
341/**
342 * A failed run's failing lines, for its detail: the first few that say what
343 * failed (a FAIL, an error, an assertion), else its last lines; masked, each
344 * one line, the whole cut to `maxChars`.
345 */
346export function failingLines(text: string, maxLines = 3, maxChars = 240): string {
347 const lines = maskSecrets(text)
348 .replace(/\r/g, '')
349 .split('\n')
350 .map(line => line.replace(/\s+$/, ''))
351 .filter(line => line.trim() !== '' && !/^Exit code \d+$/.test(line.trim()))
352 const failing = lines.filter(line => FAILING_LINE_RE.test(line.trim()) && !NOTHING_FAILED_RE.test(line.trim()))
353 const telling = lines.filter(line => !NOISE_RE.test(line.trim()))
354 const shown = (failing.length > 0 ? failing.slice(0, maxLines) : telling.slice(-maxLines)).map(line => line.trim())
355 const joined = shown.join('\n')
356 return joined.length > maxChars ? `${joined.slice(0, maxChars - 1)}…` : joined
357}
358
359/** The tail of a failing output, masked: what the next shift needs to see first. */
360export function failureTail(text: string, maxLines = 30, maxChars = 2000): string {
361 const lines = maskSecrets(text).replace(/\r/g, '').split('\n')
362 let tail = lines.slice(-maxLines).join('\n').trim()
363 if (tail.length > maxChars) tail = `…${tail.slice(-maxChars)}`
364 return tail
365}
366src/commands.ts 92 lines1// `/quackpit <args>` (and its alias `/quack`), parsed. Pure.
2
3import { isViewId, VIEW_IDS, type ViewId } from './views'
4import { isTileId, type TileId } from './tiles'
5
6export type Command =
7 | { kind: 'pane' }
8 /** A view of the pane, by name or number; none named, the list of them. */
9 | { kind: 'view'; view?: ViewId }
10 /** A tile's detail, opened in the pane (the hero's is the timeline). */
11 | { kind: 'detail'; tile: TileId }
12 | { kind: 'add'; tile: string; at?: number }
13 | { kind: 'remove'; tile: string }
14 | { kind: 'move'; tile: string; to?: number }
15 | { kind: 'reset' }
16 | { kind: 'rows'; n?: number }
17 | { kind: 'handover' }
18 | { kind: 'duck'; on?: boolean }
19 | { kind: 'help' }
20 | { kind: 'guide' }
21 /** The AI ETA: turned on or off, or with neither, how it stands. */
22 | { kind: 'ai'; on?: boolean }
23
24const ON = new Set(['on', 'yes', 'true', '1', 'show'])
25const OFF = new Set(['off', 'no', 'false', '0', 'hide'])
26
27function toNumber(word: string | undefined): number | undefined {
28 if (word === undefined) return undefined
29 const n = Number(word)
30 return Number.isFinite(n) ? n : undefined
31}
32
33export function parseCommand(args: string): Command {
34 const words = args.trim().split(/\s+/).filter(Boolean)
35 const [head, a, b] = words.map(w => w.toLowerCase())
36 switch (head) {
37 case undefined:
38 case 'pane':
39 case 'open':
40 return { kind: 'pane' }
41 case 'add':
42 if (!a) return { kind: 'help' }
43 return toNumber(b) !== undefined ? { kind: 'add', tile: a, at: toNumber(b) as number } : { kind: 'add', tile: a }
44 case 'remove':
45 case 'rm':
46 case 'hide':
47 return a ? { kind: 'remove', tile: a } : { kind: 'help' }
48 case 'move':
49 case 'mv': {
50 if (!a) return { kind: 'help' }
51 const to = toNumber(b)
52 return to !== undefined ? { kind: 'move', tile: a, to } : { kind: 'move', tile: a }
53 }
54 case 'reset':
55 return { kind: 'reset' }
56 case 'rows': {
57 const n = toNumber(a)
58 return n !== undefined ? { kind: 'rows', n } : { kind: 'rows' }
59 }
60 case 'handover':
61 case 'handoff':
62 return { kind: 'handover' }
63 case 'duck':
64 if (a && ON.has(a)) return { kind: 'duck', on: true }
65 if (a && OFF.has(a)) return { kind: 'duck', on: false }
66 return { kind: 'duck' }
67 case 'help':
68 case 'guide':
69 case '?':
70 return { kind: 'guide' }
71 case 'view':
72 case 'tab': {
73 const view = a === undefined ? undefined : isViewId(a) ? a : VIEW_IDS[(toNumber(a) ?? 0) - 1]
74 return view ? { kind: 'view', view } : { kind: 'view' }
75 }
76 // A view by its own name; `handover` writes one and `guide` is above.
77 case 'overview':
78 case 'timeline':
79 case 'turns':
80 case 'band':
81 return { kind: 'view', view: head }
82 case 'ai':
83 case 'eta':
84 if (a && ON.has(a)) return { kind: 'ai', on: true }
85 if (a && OFF.has(a)) return { kind: 'ai', on: false }
86 return { kind: 'ai' }
87 default:
88 // A tile's name opens its detail.
89 return head !== undefined && isTileId(head) ? { kind: 'detail', tile: head } : { kind: 'help' }
90 }
91}
92src/derive.ts 290 lines1// Readings derived from the cockpit: pace now against your usual pace (or,
2// until 3 past days are kept, this session's own average), the five-hour
3// window as a fuel gauge (used, where the trend ends at the reset, how much of
4// the window has passed), and the threshold level of each gauge. Pure.
5
6import { dayPace, HOUR, median, MINUTE, nowRate, paceAt, paceCoverageMs } from './metrics'
7import { limitsAt, type Cockpit, type Window } from './model'
8
9/** Colour only on thresholds: `none` draws in the theme's own text colour. */
10export type Level = 'none' | 'ok' | 'warn' | 'bad'
11
12/** Below 10 minutes of data the pace needle is grey and only the number shows. */
13export const PACE_MIN_DATA_MS = 10 * MINUTE
14
15/** The dial reads 0 to 2× your usual pace: usual straight up, amber from 1.5×, red from 1.8×. */
16export const DIAL_MAX_RATIO = 2
17export const PACE_WARN_RATIO = 1.5
18export const PACE_BAD_RATIO = 1.8
19
20/** A day as the store rolls it up: what `dayPace` takes. */
21export type DayTotals = { day: string; tokens: number; activeMin: number }
22
23/** Where "usual" comes from: the median of your last days (3 at least), or this session's own average. */
24export type Usual = { perHour: number; basis: 'days' | 'session'; n: number }
25
26/**
27 * Your usual pace in tokens per active hour: the median day of the last 14
28 * (3 days or more), else this session's own average once it has 10 active
29 * minutes, else unknown. `n` is how many days, or how many active minutes.
30 */
31export function usualOf(c: Cockpit, days: readonly DayTotals[], today: string): Usual | undefined {
32 const past = days.filter(d => d.day < today && d.activeMin > 0 && d.tokens > 0).slice(-14)
33 if (past.length >= 3) return { perHour: median(past.map(d => dayPace(d.tokens, d.activeMin))) ?? 0, basis: 'days', n: past.length }
34 const activeMin = c.activeMs / MINUTE
35 if (activeMin >= 10 && c.tokens > 0) return { perHour: dayPace(c.tokens, activeMin), basis: 'session', n: Math.floor(activeMin) }
36 return undefined
37}
38
39/** Your usual pace's figure alone (see `usualOf`). */
40export function usualPace(c: Cockpit, days: readonly DayTotals[], today: string): number | undefined {
41 return usualOf(c, days, today)?.perHour
42}
43
44export type Eta =
45 | { kind: 'empties'; at: number }
46 | { kind: 'holds'; resetsAt: number }
47 | { kind: 'flat' }
48
49export const FIVE_HOUR_MS = 5 * 60 * MINUTE
50export const SEVEN_DAY_MS = 7 * 24 * 60 * MINUTE
51/** The readings a five-hour forecast fits a line through. */
52export const FIT_SPAN_MS = 90 * MINUTE
53const FIT_MIN_READINGS = 3
54
55/** A reading whose window reset has come: that window is over, whatever it said. */
56export function isReset(w: Window, now: number): boolean {
57 return w.resetsAt !== undefined && w.resetsAt <= now
58}
59
60/** Points per millisecond, by least squares through the readings (or undefined with no spread in time). */
61function slope(points: readonly [number, number][]): number | undefined {
62 const n = points.length
63 const mx = points.reduce((a, [x]) => a + x, 0) / n
64 const my = points.reduce((a, [, y]) => a + y, 0) / n
65 const sxx = points.reduce((a, [x]) => a + (x - mx) ** 2, 0)
66 const sxy = points.reduce((a, [x, y]) => a + (x - mx) * (y - my), 0)
67 return sxx > 0 ? sxy / sxx : undefined
68}
69
70export type Forecast = {
71 /** Points per millisecond. */
72 rate: number
73 /** `fit`: a least-squares line through the last 90 minutes of readings; `average`: the window's use so far over its time so far. */
74 how: 'fit' | 'average'
75 /** The percent the window reaches at its reset at this rate (past 100 when it runs out first). */
76 atReset?: number
77 /** When it reaches 100%, if before its reset. */
78 emptiesAt?: number
79}
80
81/**
82 * Where a plan window is heading, from its own readings only. The five-hour
83 * window fits a least-squares line through the readings of the last 90
84 * minutes (3 at least, 10 minutes apart at least); without that, and for the
85 * seven-day window always, the rate is what it used over the time it has
86 * been open (its reset less its length), once 10 minutes have passed. A
87 * window that has reset has no forecast.
88 */
89export function forecast(w: Window | undefined, lengthMs: number, readAt: number, samples: readonly [number, number][] | undefined): Forecast | undefined {
90 if (!w || isReset(w, readAt)) return undefined
91 let rate: number | undefined
92 let how: Forecast['how'] = 'fit'
93 if (samples) {
94 const points = samples.filter(([t]) => t > readAt - FIT_SPAN_MS && t <= readAt)
95 const first = points[0]
96 const last = points.at(-1)
97 if (first && last && points.length >= FIT_MIN_READINGS && last[0] - first[0] >= PACE_MIN_DATA_MS) rate = slope(points)
98 }
99 if (rate === undefined && w.resetsAt !== undefined) {
100 const open = readAt - (w.resetsAt - lengthMs)
101 if (open >= PACE_MIN_DATA_MS && open <= lengthMs) {
102 rate = w.pct / open
103 how = 'average'
104 }
105 }
106 if (rate === undefined) return undefined
107 rate = Math.max(0, rate)
108 const out: Forecast = { rate, how }
109 if (w.resetsAt !== undefined) out.atReset = w.pct + rate * (w.resetsAt - readAt)
110 if (rate > 0 && w.pct < 100) {
111 const at = readAt + (100 - w.pct) / rate
112 if (w.resetsAt === undefined || at < w.resetsAt) out.emptiesAt = at
113 }
114 return out
115}
116
117/** The five-hour window's forecast, from the cockpit's readings. */
118export function fiveHourForecast(c: Cockpit): Forecast | undefined {
119 const r = c.reading
120 return r ? forecast(r.fiveHour, FIVE_HOUR_MS, r.at, c.fiveSamples) : undefined
121}
122
123/** The seven-day window's forecast: its average so far (a 90-minute slope would read a working hour as the week). */
124export function sevenDayForecast(c: Cockpit): Forecast | undefined {
125 const r = c.reading
126 return r ? forecast(r.sevenDay, SEVEN_DAY_MS, r.at, undefined) : undefined
127}
128
129/** When the five-hour window empties at the rate its readings show, or that it lasts to its reset. */
130export function fiveHourEta(c: Cockpit, now: number): Eta | undefined {
131 const w = c.reading?.fiveHour
132 if (!w || isReset(w, now)) return undefined
133 const f = fiveHourForecast(c)
134 if (!f) return undefined
135 if (f.rate <= 0.01 / MINUTE) return { kind: 'flat' }
136 if (f.emptiesAt !== undefined) return { kind: 'empties', at: f.emptiesAt }
137 return w.resetsAt !== undefined ? { kind: 'holds', resetsAt: w.resetsAt } : { kind: 'flat' }
138}
139
140/** Under this long to its reset, a window the forecast says will last raises no caution. */
141export const CALM_BEFORE_RESET_MS = 30 * MINUTE
142
143/** Whether a window that lasts until its reset at this rate resets soon enough that a caution would be noise. */
144export function lastsToReset(w: Window, f: Forecast | undefined, now: number): boolean {
145 return w.resetsAt !== undefined && w.resetsAt - now < CALM_BEFORE_RESET_MS && f?.atReset !== undefined && f.atReset < 100
146}
147
148export type PaceReading = {
149 /** New tokens an hour now: each response weighed by how recent it is, halving every 5 minutes. */
150 perHour: number
151 /** New tokens over the last 60 minutes. */
152 hourSum: number
153 hasData: boolean
154 usual?: number
155 /** Where the usual comes from: your last days, or this session's own average (until 3 days are kept). */
156 usualBasis?: Usual['basis']
157 /** The needle, 0 to 1 across the dial; absent without a usual pace. */
158 needle?: number
159 /** Now against usual. */
160 ratio?: number
161 /** The last hour's sum against usual: a slower reading, which health's pace part takes. */
162 hourRatio?: number
163 eta?: Eta
164 level: Level
165 /** With no turn running, how long since the last one ended (or since quackpit began watching). */
166 idleMs?: number
167}
168
169export function paceReading(c: Cockpit, now: number, days: readonly DayTotals[], today: string): PaceReading {
170 const perHour = nowRate(c.recent, now)
171 const hourSum = paceAt(c.recent, now)
172 const hasData = paceCoverageMs(c.startedAt, now) >= PACE_MIN_DATA_MS
173 const usual = usualOf(c, days, today)
174 const eta = fiveHourEta(c, now)
175 const ratio = hasData && usual !== undefined && usual.perHour > 0 ? perHour / usual.perHour : undefined
176 const hourRatio = hasData && usual !== undefined && usual.perHour > 0 ? hourSum / usual.perHour : undefined
177 let level: Level = 'none'
178 if (ratio !== undefined && ratio >= PACE_BAD_RATIO) level = 'bad'
179 else if (ratio !== undefined && ratio >= PACE_WARN_RATIO) level = 'warn'
180 if (eta?.kind === 'empties' && level === 'none') level = 'warn'
181 const lastEnd = c.history.at(-1)?.endedAt ?? c.startedAt
182 return {
183 perHour,
184 hourSum,
185 hasData,
186 level,
187 ...(usual !== undefined ? { usual: usual.perHour, usualBasis: usual.basis } : {}),
188 ...(ratio !== undefined ? { ratio, needle: Math.min(1, ratio / DIAL_MAX_RATIO) } : {}),
189 ...(hourRatio !== undefined ? { hourRatio } : {}),
190 ...(eta ? { eta } : {}),
191 ...(c.turn ? {} : { idleMs: Math.max(0, now - lastEnd) }),
192 }
193}
194
195/** Readings older than this are stale: the fuel gauge's needle goes grey. */
196export const STALE_MS = 15 * MINUTE
197/** A run-out this near is red: there is little left to do about it. */
198export const RUNS_OUT_SOON_MS = 30 * MINUTE
199
200/**
201 * The five-hour window as a fuel gauge, for the whole account (other
202 * sessions, other machines and claude.ai draw on it too): how much is used,
203 * where the trend ends at the reset (the 90-minute fit, or the window's
204 * average so far), how much of the window has passed, and when it runs out if
205 * that comes first. All of it as of the readings' own time: they come only
206 * with this session's responses, so while it is idle they go stale, and
207 * `asOf` says since when.
208 */
209export type FuelGauge = {
210 pct: number
211 resetsAt?: number
212 /** How much of the window has passed, 0 to 1: the dial's tick. */
213 elapsed?: number
214 /** The percent the window reaches at its reset at this trend (past 100 when it runs out first): where the dial's grey arc ends. */
215 atReset?: number
216 /** Points an hour: how fast the window has been filling. */
217 rate?: number
218 how?: Forecast['how']
219 /** When it reaches 100%, if before its reset. */
220 emptiesAt?: number
221 isReset: boolean
222 isUsedUp: boolean
223 asOf: number
224 isStale: boolean
225 level: Level
226}
227
228export function fuelGauge(c: Cockpit, now: number): FuelGauge | undefined {
229 const r = c.reading
230 const w = r?.fiveHour
231 if (!r || !w) return undefined
232 const asOf = limitsAt(c) ?? r.at
233 const base = { pct: w.pct, ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), asOf, isStale: now - asOf > STALE_MS }
234 if (isReset(w, now)) return { ...base, isReset: true, isUsedUp: false, level: 'none' }
235 const isUsedUp = w.pct >= 100
236 const f = fiveHourForecast(c)
237 const elapsed = w.resetsAt !== undefined ? Math.max(0, Math.min(1, 1 - (w.resetsAt - now) / FIVE_HOUR_MS)) : undefined
238 const runsOutSoon = f?.emptiesAt !== undefined && f.emptiesAt - now < RUNS_OUT_SOON_MS
239 const level: Level = isUsedUp || w.pct >= 90 || runsOutSoon ? 'bad' : w.pct >= 80 || f?.emptiesAt !== undefined ? 'warn' : 'none'
240 return {
241 ...base,
242 isReset: false,
243 isUsedUp,
244 ...(elapsed !== undefined ? { elapsed } : {}),
245 ...(f?.atReset !== undefined ? { atReset: f.atReset } : {}),
246 ...(f ? { rate: f.rate * HOUR, how: f.how } : {}),
247 ...(f?.emptiesAt !== undefined && !isUsedUp ? { emptiesAt: f.emptiesAt } : {}),
248 level,
249 }
250}
251
252export function contextLevel(pct: number | undefined): Level {
253 if (pct === undefined) return 'none'
254 if (pct >= 90) return 'bad'
255 if (pct >= 70) return 'warn'
256 return 'none'
257}
258
259export function fiveHourLevel(w: Window | undefined, now?: number): Level {
260 if (!w || (now !== undefined && isReset(w, now))) return 'none'
261 if (w.pct >= 90) return 'bad'
262 if (w.pct >= 80) return 'warn'
263 return 'none'
264}
265
266export function sevenDayLevel(w: Window | undefined, now?: number): Level {
267 if (!w || (now !== undefined && isReset(w, now))) return 'none'
268 if (w.pct >= 90) return 'bad'
269 if (w.pct >= 75) return 'warn'
270 return 'none'
271}
272
273/**
274 * The cost of the turn running now: the ledger's growth since the turn began,
275 * once a reading shows any. The ledger is read as the turn starts and pushed
276 * again only when a plan window moves a point or the turn ends, so until then
277 * the growth reads 0, which is no figure: every response costs something.
278 */
279export function liveTurnUsd(c: Cockpit): number | undefined {
280 const start = c.turn?.costAtStart
281 const now = c.reading?.costUsd
282 return start !== undefined && now !== undefined && now > start ? now - start : undefined
283}
284
285/** The worse of two levels. */
286export function worse(a: Level, b: Level): Level {
287 const rank: Record<Level, number> = { none: 0, ok: 1, warn: 2, bad: 3 }
288 return rank[a] >= rank[b] ? a : b
289}
290src/duck.ts 173 lines1// The duck is quackpit's Master Caution. It stays still with its head down
2// until one of four things needs attention:
3// - a failed check (the latest run of a test, typecheck, lint or build),
4// - the five-hour window at 80% or more (unless it resets within 30 minutes
5// and its forecast says it lasts until then, or its reset has passed),
6// - the context within 10% of the point where Claude Code compacts it by
7// itself (at 90% of the window while auto-compact is off),
8// - a turn that cost over 3x the session's median turn.
9// Then it raises its head and its cell says "Quack:". Pressing it
10// acknowledges, as a pilot presses the caution light; the gauge stays lit
11// until the cause clears. Only a failed check and the five-hour window at
12// 90% may raise an OS notification, at most 3 a day. Pure.
13
14import type { CheckKind } from './checks'
15import { fiveHourForecast, isReset, lastsToReset } from './derive'
16import { formatPercent } from './format'
17import { t, type Locale } from './i18n'
18import { formatTokens } from './format'
19import { contextNow, contextShare, type Cockpit } from './model'
20import { displayWidth, fit } from './text'
21
22export type CautionKind = 'check' | 'five' | 'context' | 'runaway'
23
24export type Caution = {
25 /** Stable while the cause lasts; a new cause gets a new key. */
26 key: string
27 kind: CautionKind
28 level: 'warn' | 'bad'
29 /** Worth an OS notification (a failed check, five_hour at 90%+). */
30 isNotify: boolean
31 /** When its cause happened: the check's end, the turn's end, the latest reading for the gauges. */
32 at: number
33 checkKind?: CheckKind
34 pct?: number
35 /** Tokens left before the auto-compact point, when auto-compact is on. */
36 left?: number
37 turn?: number
38 factor?: number
39}
40
41export const FIVE_CAUTION_PCT = 80
42export const FIVE_NOTIFY_PCT = 90
43/** Of the auto-compact point while auto-compact is on, else of the window. */
44export const CONTEXT_CAUTION_PCT = 90
45export const NOTIFY_PER_DAY = 3
46
47export function cautionsOf(c: Cockpit, now: number = c.lastAt): Caution[] {
48 const out: Caution[] = []
49 // Each kind of check whose latest run failed: a chain counts for every check it ran, so a
50 // later pass of a kind clears its failure wherever that pass ran (decision 98).
51 for (const failure of c.failures) {
52 out.push({
53 key: `check:${failure.kind}:${failure.at}`,
54 kind: 'check',
55 level: 'bad',
56 isNotify: true,
57 at: failure.at,
58 checkKind: failure.kind,
59 })
60 }
61 const five = c.reading?.fiveHour
62 // A window that has reset is over; one that resets within 30 minutes and lasts until then needs no one.
63 if (five && five.pct >= FIVE_CAUTION_PCT && !isReset(five, now) && !lastsToReset(five, fiveHourForecast(c), now)) {
64 const isHigh = five.pct >= FIVE_NOTIFY_PCT
65 out.push({
66 key: `five:${isHigh ? FIVE_NOTIFY_PCT : FIVE_CAUTION_PCT}:${five.resetsAt ?? 0}`,
67 kind: 'five',
68 level: isHigh ? 'bad' : 'warn',
69 isNotify: isHigh,
70 at: c.reading?.at ?? 0,
71 pct: five.pct,
72 })
73 }
74 const fill = contextNow(c)
75 const share = contextShare(fill)
76 if (share !== undefined && share >= CONTEXT_CAUTION_PCT) {
77 const left = fill.autoCompact !== undefined && fill.tokens !== undefined ? Math.max(0, fill.autoCompact - fill.tokens) : undefined
78 out.push({ key: `ctx:${c.compactions}`, kind: 'context', level: 'bad', isNotify: false, at: c.lastAt, ...(fill.pct !== undefined ? { pct: fill.pct } : {}), ...(left !== undefined ? { left } : {}) })
79 }
80 const last = c.history.at(-1)
81 if (last?.runaway && !c.turn) {
82 out.push({
83 key: `runaway:${last.n}`,
84 kind: 'runaway',
85 level: 'warn',
86 isNotify: false,
87 at: last.endedAt,
88 turn: last.n,
89 ...(last.factor !== undefined ? { factor: last.factor } : {}),
90 })
91 }
92 // Worst first, then by kind, then the newest check. Kind before time, because the
93 // gauges' cautions carry the latest reading's time: ordered by time, the one the
94 // duck names would change with every reading.
95 const rank = { bad: 1, warn: 0 }
96 const order: Record<CautionKind, number> = { check: 0, five: 1, context: 2, runaway: 3 }
97 return out.sort((a, b) => rank[b.level] - rank[a.level] || order[a.kind] - order[b.kind] || b.at - a.at)
98}
99
100export type DuckState = {
101 pose: 'idle' | 'alert'
102 /** The caution the cell names: the worst one not yet acknowledged. */
103 caution?: Caution
104 /** How many cautions are live, acknowledged or not. */
105 live: number
106}
107
108export function duckState(c: Cockpit, cautions: readonly Caution[] = cautionsOf(c)): DuckState {
109 const open = cautions.filter(k => !c.ack.includes(k.key))
110 const first = open[0]
111 return first ? { pose: 'alert', caution: first, live: cautions.length } : { pose: 'idle', live: cautions.length }
112}
113
114/** Cautions not yet announced: each gets one toast (and maybe a notification). */
115export function freshCautions(c: Cockpit, cautions: readonly Caution[] = cautionsOf(c)): Caution[] {
116 return cautions.filter(k => !c.seen.includes(k.key))
117}
118
119/** The words for a caution, without the "Quack:" prefix. */
120export function cautionText(k: Caution, locale: Locale): string {
121 switch (k.kind) {
122 case 'check':
123 return t(locale, 'caution.check', { kind: t(locale, `kind.${k.checkKind ?? 'test'}`) })
124 case 'five':
125 return t(locale, 'caution.five', { pct: formatPercent(k.pct) })
126 case 'context':
127 return k.left !== undefined ? t(locale, 'caution.compact', { left: formatTokens(k.left) }) : t(locale, 'caution.context', { pct: formatPercent(k.pct) })
128 case 'runaway':
129 return t(locale, 'caution.runaway', { n: k.turn ?? 0, x: k.factor !== undefined ? k.factor.toFixed(1) : '3+' })
130 }
131}
132
133/**
134 * The caution in at most `room` columns, for the band's duck: its words where
135 * they fit, else their short form (`5h at 84%`, `turn 12: 3.4×`), never a
136 * reading cut off.
137 */
138export function cautionFit(k: Caution, locale: Locale, room: number): string {
139 const long = cautionText(k, locale)
140 if (displayWidth(long) <= room) return long
141 switch (k.kind) {
142 case 'check':
143 return fit(t(locale, 'caution.check', { kind: t(locale, `kindShort.${k.checkKind ?? 'test'}`) }), room)
144 case 'five':
145 return fit(t(locale, 'caution.fiveShort', { pct: formatPercent(k.pct) }), room)
146 case 'context':
147 return fit(k.left !== undefined ? long : t(locale, 'caution.contextShort', { pct: formatPercent(k.pct) }), room)
148 case 'runaway':
149 return fit(t(locale, 'caution.runawayShort', { n: k.turn ?? 0, x: k.factor !== undefined ? k.factor.toFixed(1) : '3+' }), room)
150 }
151}
152
153export function quackLine(k: Caution, locale: Locale): string {
154 return `${t(locale, 'duck.quack')} ${cautionText(k, locale)}`
155}
156
157/** The per-day notification counter as the store keeps it. */
158export type NotifyRecord = { day: string; count: number }
159
160/** Takes one of the day's 3 notification slots, if one is left. */
161export function takeNotifySlot(record: NotifyRecord | undefined, day: string): { record: NotifyRecord; isTaken: boolean } {
162 const current = record && record.day === day ? record : { day, count: 0 }
163 if (current.count >= NOTIFY_PER_DAY) return { record: current, isTaken: false }
164 return { record: { day, count: current.count + 1 }, isTaken: true }
165}
166
167/** Gives a slot back when the notification did not go out. */
168export function returnNotifySlot(record: NotifyRecord | undefined, day: string): NotifyRecord {
169 if (!record || record.day !== day) return { day, count: 0 }
170 return { day, count: Math.max(0, record.count - 1) }
171}
172
173src/format.ts 182 lines1// Numbers, money, durations and clock times as the band and the handover
2// print them. Pure; locale-aware only where words are involved.
3
4import { t, type Locale } from './i18n'
5import { HOUR, MINUTE } from './metrics'
6
7/** 950, 12.3k, 340k, 1.2M, 15M. */
8export function formatTokens(n: number): string {
9 if (!Number.isFinite(n) || n <= 0) return '0'
10 if (n < 1000) return String(Math.round(n))
11 if (n < 10_000) return `${trim1(n / 1000)}k`
12 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
13 if (n < 10_000_000) return `${trim1(n / 1_000_000)}M`
14 return `${Math.round(n / 1_000_000)}M`
15}
16
17function trim1(x: number): string {
18 const s = x.toFixed(1)
19 return s.endsWith('.0') ? s.slice(0, -2) : s
20}
21
22/** $0.004, $0.42, $3.10, $12.4, $128. */
23export function formatUsd(usd: number | undefined): string {
24 if (usd === undefined || !Number.isFinite(usd)) return '—'
25 const v = Math.max(0, usd)
26 if (v === 0) return '$0'
27 // Under a tenth of a cent it would round to $0.000, which reads as nothing spent.
28 if (v < 0.0005) return '<$0.001'
29 if (v < 0.01) return `$${v.toFixed(3)}`
30 if (v < 10) return `$${v.toFixed(2)}`
31 if (v < 100) return `$${v.toFixed(1)}`
32 return `$${Math.round(v)}`
33}
34
35/** 0.4 s, 3.1 s, 42 s, 4m 05s, 1h 12m. */
36export function formatDuration(ms: number): string {
37 if (!Number.isFinite(ms) || ms < 0) return '—'
38 if (ms < 10_000) return `${(ms / 1000).toFixed(1)} s`
39 if (ms < MINUTE) return `${Math.round(ms / 1000)} s`
40 if (ms < HOUR) {
41 const m = Math.floor(ms / MINUTE)
42 const s = Math.floor((ms % MINUTE) / 1000)
43 return `${m}m ${String(s).padStart(2, '0')}s`
44 }
45 const h = Math.floor(ms / HOUR)
46 const m = Math.floor((ms % HOUR) / MINUTE)
47 return `${h}h ${String(m).padStart(2, '0')}m`
48}
49
50/** A turn clock: 0:42, 3:05, 1:12:09. */
51export function formatClock(ms: number): string {
52 const total = Math.max(0, Math.floor(ms / 1000))
53 const h = Math.floor(total / 3600)
54 const m = Math.floor((total % 3600) / 60)
55 const s = total % 60
56 const ss = String(s).padStart(2, '0')
57 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
58}
59
60/**
61 * A length in whole minutes, for what is still going or shown on a 30-second
62 * redraw: <1m, 4m, 1h12m. Nothing in the band ticks seconds.
63 */
64export function formatMinutes(ms: number): string {
65 if (!Number.isFinite(ms) || ms < 0) return '—'
66 const min = Math.floor(ms / MINUTE)
67 if (min < 1) return '<1m'
68 if (min < 60) return `${min}m`
69 return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
70}
71
72/** A finished length as the pane's task and agent rows print it: 4m12, 52s, 1h04m. */
73export function formatSpan(ms: number): string {
74 if (!Number.isFinite(ms) || ms < 0) return '—'
75 const s = Math.floor(ms / 1000)
76 if (s < 60) return `${s}s`
77 const m = Math.floor(s / 60)
78 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}`
79 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
80}
81
82/** An estimate of time left, whole minutes, never finer: 9m, 1h20m. The caller adds the `~`. */
83export function formatLeft(ms: number): string {
84 const min = Math.max(1, Math.round(ms / MINUTE))
85 return min < 60 ? `${min}m` : `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
86}
87
88/** A countdown in whole minutes: 45m, 1h12, 3d4h. */
89export function formatCountdown(ms: number): string {
90 if (!Number.isFinite(ms)) return '—'
91 const min = Math.max(0, Math.ceil(ms / MINUTE))
92 if (min < 60) return `${min}m`
93 const h = Math.floor(min / 60)
94 if (h < 24) return `${h}h${String(min % 60).padStart(2, '0')}`
95 const d = Math.floor(h / 24)
96 return `${d}d${h % 24}h`
97}
98
99/** Local wall-clock time, 24-hour: 14:52. */
100export function clockText(at: number): string {
101 const d = new Date(at)
102 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
103}
104
105/** Local date and time for the handover: 2026-10-11 14:52. */
106export function stampText(at: number): string {
107 const d = new Date(at)
108 const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
109 return `${date} ${clockText(at)}`
110}
111
112/** The local calendar day of a moment, as YYYY-MM-DD. */
113export function dayKey(at: number): string {
114 const d = new Date(at)
115 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
116}
117
118/** How long ago, in whole minutes: just now, 4 min ago, 2 h ago, in the person's language. No seconds: the band redraws every 30 s. */
119export function formatAge(ms: number, locale: Locale): string {
120 const s = Math.max(0, Math.round(ms / 1000))
121 if (s < 60) return t(locale, 'age.now')
122 if (s < 3600) return t(locale, 'age.min', { n: Math.floor(s / 60) })
123 if (s < 86_400) return t(locale, 'age.hour', { n: Math.floor(s / 3600) })
124 return t(locale, 'age.day', { n: Math.floor(s / 86_400) })
125}
126
127/** 0 to 100 as a whole percent; one decimal under 10 when it has one. */
128export function formatPercent(pct: number | undefined): string {
129 if (pct === undefined || !Number.isFinite(pct)) return '—'
130 const v = Math.max(0, pct)
131 if (v < 10 && Math.round(v * 10) % 10 !== 0) return `${v.toFixed(1)}%`
132 return `${Math.round(v)}%`
133}
134
135/** Cuts text to `max` characters on a word boundary where one is near, with an ellipsis. */
136export function clip(text: string, max: number): string {
137 const one = text.replace(/\s+/g, ' ').trim()
138 if (one.length <= max) return one
139 if (max <= 1) return '…'
140 const cut = one.slice(0, max - 1)
141 const space = cut.lastIndexOf(' ')
142 return `${space > max * 0.6 ? cut.slice(0, space) : cut}…`
143}
144
145/**
146 * A long text cut to about `max` characters, its lines kept: the head (where a
147 * request or a plan says what it is) and the tail, an ellipsis line between.
148 */
149export function squeeze(text: string, max: number): string {
150 const whole = text.trim()
151 if (whole.length <= max) return whole
152 const head = Math.ceil(max * 0.6)
153 return `${whole.slice(0, head).trimEnd()}\n…\n${whole.slice(whole.length - (max - head)).trimStart()}`
154}
155
156/**
157 * Words added to words kept so far, held to `max` characters: once over, the
158 * first `head` stay (a plan is said first) and the latest fill the rest, so a
159 * long turn's words stay as long as this however often they are added to.
160 */
161export function addWords(kept: string | undefined, words: string, max = 1600, head = 900): string {
162 const more = words.trim()
163 if (more === '') return kept ?? ''
164 const joined = kept ? `${kept}\n${more}` : more
165 if (joined.length <= max) return joined
166 // Untrimmed, so the head is the same 900 characters every time and the ellipsis never reaches the tail.
167 return `${joined.slice(0, head)}\n…\n${joined.slice(joined.length - (max - head - 3))}`
168}
169
170/** The last segment of a path. */
171export function baseName(path: string): string {
172 const parts = path.split(/[\\/]/).filter(Boolean)
173 return parts.at(-1) ?? path
174}
175
176/** A path shown relative to the project root when it lies under it. */
177export function relativePath(path: string, root: string | undefined): string {
178 if (!root) return path
179 const r = root.endsWith('/') ? root : `${root}/`
180 return path.startsWith(r) ? path.slice(r.length) : path
181}
182src/gauges.ts 231 lines1// The gauges as drawings, for each way a surface can show them:
2// - text: lines of block characters in theme colours, which any terminal
3// draws the same way: a tachometer ramp of eighth blocks for pace, a
4// half-block ring for context. The designed fallback.
5// - cells: a Raster for what needs two colours in a cell (the duck).
6// - pixels: an RGBA Image the terminal shows where it speaks the kitty
7// graphics protocol (kitty, Ghostty): a needle dial, a smooth ring.
8// The SVG variant for the desktop lives in svg.ts. A graphic is described
9// once (`Graphic`) and every drawer reads the same description. Pure.
10
11import { quadrantCells, type RasterCells } from './cells'
12import type { Level } from './derive'
13import { hex, PixelCanvas, type ImageSource, type Rgba } from './pixels'
14import { DUCK_12x4, DUCK_16, DUCK_8x2, DUCK_COLUMNS, DUCK_RGBA, IDLE_EYE, spritePixels, STEADY, type Frame, type Pose } from './sprites'
15import type { Line, Span, Tone } from './text'
16
17export type Graphic =
18 | { kind: 'dial'; cols: number; rows: number; needle?: number; isGrey: boolean; warnFrom: number; badFrom: number; sweep?: number; bug?: number; needleLevel?: Level; noZones?: boolean }
19 | { kind: 'ring'; cols: number; rows: number; pct?: number; level: Level }
20 | { kind: 'duck'; cols: number; rows: number; pose: Pose }
21
22/** Threshold colours for pictures, mid-tones that read on dark and light themes alike. */
23export const HEX = {
24 ok: '#3fb950',
25 warn: '#e3a008',
26 bad: '#f85149',
27 track: '#6e7681',
28 grey: '#8b949e',
29 /** A needle and a ring's fill: light enough for dark themes, dark enough for light ones. */
30 neutral: '#a3acb7',
31} as const
32
33function levelTone(level: Level): Tone | undefined {
34 return level === 'bad' ? 'bad' : level === 'warn' ? 'warn' : level === 'ok' ? 'ok' : undefined
35}
36
37const EIGHTHS = ' ▁▂▃▄▅▆▇█'
38
39// ------------------------------------------------------------------ text
40
41/** Spans of one character each, merged where neighbours share a look. */
42function merge(spans: Span[]): Line {
43 const out: Line = []
44 for (const s of spans) {
45 const last = out.at(-1)
46 if (last && last.tone === s.tone && last.bg === s.bg && last.bold === s.bold) last.text += s.text
47 else out.push({ ...s })
48 }
49 return out
50}
51
52/**
53 * A dial as a tachometer: `cols` bars rising left to right over `rows` rows,
54 * lit up to the needle; the lead bar is the needle itself. The caution and
55 * red zones are lit in their colours (or, on a dial with no zones, every lit
56 * bar takes the needle's threshold colour), and with too little data every
57 * lit bar is grey. A sweep (the fuel gauge's trend to its reset) lights the
58 * bars beyond the needle in grey up to its end; the bug's bar is the accent.
59 */
60export function rampLines(g: Extract<Graphic, { kind: 'dial' }>): Line[] {
61 const n = g.cols
62 const eighths = g.rows * 8
63 const lit = g.needle === undefined ? 0 : Math.max(g.needle > 0 ? 1 : 0, Math.round(g.needle * n))
64 const sweep = g.sweep === undefined || g.isGrey ? lit : Math.max(lit, Math.round(Math.min(1, g.sweep) * n))
65 const bug = g.bug === undefined ? -1 : Math.min(n - 1, Math.floor(g.bug * n))
66 const needleTone = levelTone(g.needleLevel ?? 'none')
67 const lines: Span[][] = Array.from({ length: g.rows }, () => [])
68 for (let i = 0; i < n; i++) {
69 const height = Math.max(1, Math.round((eighths * (i + 1)) / n))
70 const f = (i + 0.5) / n
71 let tone: Tone | undefined
72 if (i >= lit) tone = i === bug ? 'accent' : i < sweep ? 'grey' : 'track'
73 else if (g.isGrey) tone = 'grey'
74 else if (g.noZones) tone = needleTone
75 else tone = f >= g.badFrom ? 'bad' : f >= g.warnFrom ? 'warn' : undefined
76 const isNeedle = i === lit - 1 && !g.isGrey
77 for (let row = 0; row < g.rows; row++) {
78 // Row 0 is the top: it holds what is left above the rows beneath it.
79 const below = (g.rows - 1 - row) * 8
80 const part = Math.max(0, Math.min(8, height - below))
81 lines[row]?.push({ text: EIGHTHS[part] ?? ' ', ...(tone ? { tone } : {}), ...(isNeedle ? { bold: true } : {}) })
82 }
83 }
84 return lines.map(merge)
85}
86
87/** The ring's outline, clockwise from 12 o'clock, on a `w` x `h` pixel grid. */
88export function ringPixels(w: number, h: number): [number, number][] {
89 const top: [number, number][] = []
90 for (let x = 1; x < w - 1; x++) top.push([x, 0])
91 const right: [number, number][] = []
92 for (let y = 1; y < h - 1; y++) right.push([w - 1, y])
93 const bottom: [number, number][] = []
94 for (let x = w - 2; x > 0; x--) bottom.push([x, h - 1])
95 const left: [number, number][] = []
96 for (let y = h - 2; y > 0; y--) left.push([0, y])
97 const ring = [...top, ...right, ...bottom, ...left]
98 const start = Math.floor((w - 2) / 2)
99 return [...ring.slice(start), ...ring.slice(0, start)]
100}
101
102/**
103 * Context as a ring of half-block pixels (`cols` wide, `rows * 2` tall),
104 * filled clockwise from the top in the level's colour, the rest unlit.
105 */
106export function ringLines(g: Extract<Graphic, { kind: 'ring' }>): Line[] {
107 const w = g.cols
108 const h = g.rows * 2
109 const ring = ringPixels(w, h)
110 const pct = Math.max(0, Math.min(100, g.pct ?? 0))
111 const lit = Math.round((ring.length * pct) / 100)
112 const tone = levelTone(g.level)
113 const grid: (Tone | 'lit' | undefined)[][] = Array.from({ length: h }, () => Array.from({ length: w }, () => undefined))
114 ring.forEach(([x, y], i) => {
115 const row = grid[y]
116 if (row) row[x] = i < lit ? (tone ?? 'lit') : 'track'
117 })
118 const lines: Line[] = []
119 for (let cy = 0; cy < g.rows; cy++) {
120 const spans: Span[] = []
121 for (let x = 0; x < w; x++) {
122 const top = grid[cy * 2]?.[x]
123 const bottom = grid[cy * 2 + 1]?.[x]
124 const as = (t: Tone | 'lit'): Tone | undefined => (t === 'lit' ? undefined : t)
125 if (top === undefined && bottom === undefined) spans.push({ text: ' ' })
126 else if (top !== undefined && bottom !== undefined) {
127 if (top === bottom) spans.push({ text: '█', ...(as(top) ? { tone: as(top) } : {}) })
128 else spans.push({ text: '▀', ...(as(top) ? { tone: as(top) } : {}), ...(as(bottom) ? { bg: as(bottom) } : {}) })
129 } else if (top !== undefined) spans.push({ text: '▀', ...(as(top) ? { tone: as(top) } : {}) })
130 else if (bottom !== undefined) spans.push({ text: '▄', ...(as(bottom) ? { tone: as(bottom) } : {}) })
131 }
132 lines.push(merge(spans))
133 }
134 return lines
135}
136
137/** A graphic as text lines, for surfaces drawing designed text. The duck has none. */
138export function graphicLines(g: Graphic): Line[] | undefined {
139 if (g.kind === 'dial') return rampLines(g)
140 if (g.kind === 'ring') return ringLines(g)
141 return undefined
142}
143
144// ------------------------------------------------------------------ cells
145
146/** The duck as Raster cells: a frame of the 2-row duck (12x4 in quadrants, 6 columns), or the one-row band's 4 columns. */
147export function duckCells(frame: Frame, rows: 1 | 2): RasterCells {
148 if (rows === 1) return quadrantCells(spritePixels(DUCK_8x2), 8)
149 return quadrantCells(spritePixels(DUCK_12x4[frame]), 12, frame === 'idle' ? IDLE_EYE : {})
150}
151
152// ------------------------------------------------------------------ pixels
153
154/** Pixels per cell for the Image variant: twice the usual 8x16 cell, for sharp edges. */
155export const PX_W = 16
156export const PX_H = 32
157
158function levelRgba(level: Level, fallback: Rgba): Rgba {
159 return level === 'bad' ? hex(HEX.bad) : level === 'warn' ? hex(HEX.warn) : level === 'ok' ? hex(HEX.ok) : fallback
160}
161
162const dialAngle = (f: number) => Math.PI * (1 - Math.max(0, Math.min(1, f)))
163
164/** A needle gauge: caution and red arcs, five ticks, the needle grey with too little data. */
165export function dialImage(g: Extract<Graphic, { kind: 'dial' }>): ImageSource {
166 const w = g.cols * PX_W
167 const h = g.rows * PX_H
168 const canvas = new PixelCanvas(w, h)
169 const cx = w / 2
170 const cy = h - 7
171 const thick = Math.max(3, Math.round(h / 11))
172 const r = Math.min(w / 2 - thick - 2, cy - thick - 2)
173 canvas.arc(cx, cy, r, thick, 0, Math.PI, hex(HEX.track, 150))
174 canvas.arc(cx, cy, r, thick, dialAngle(g.badFrom), dialAngle(g.warnFrom), hex(HEX.warn))
175 canvas.arc(cx, cy, r, thick, 0, dialAngle(g.badFrom), hex(HEX.bad))
176 for (const f of [0, 0.25, 0.5, 0.75, 1]) {
177 const a = dialAngle(f)
178 const inner = r - thick - (f === 0.5 ? 5 : 3)
179 canvas.line(cx + inner * Math.cos(a), cy - inner * Math.sin(a), cx + (r - thick) * Math.cos(a), cy - (r - thick) * Math.sin(a), f === 0.5 ? 2.5 : 1.5, hex(HEX.grey, 200))
180 }
181 // With too little data the needle is a faint grey; otherwise solid.
182 const needleColor = g.isGrey ? hex(HEX.grey, 110) : hex(HEX.neutral)
183 const a = dialAngle(g.isGrey || g.needle === undefined ? 0 : g.needle)
184 canvas.line(cx, cy, cx + (r - 2) * Math.cos(a), cy - (r - 2) * Math.sin(a), 3, needleColor)
185 canvas.disc(cx, cy, 5, needleColor)
186 return canvas.toSource()
187}
188
189export function ringImage(g: Extract<Graphic, { kind: 'ring' }>): ImageSource {
190 const w = g.cols * PX_W
191 const h = g.rows * PX_H
192 const canvas = new PixelCanvas(w, h)
193 const size = Math.min(w, h)
194 const thick = Math.max(5, Math.round(size / 7))
195 const r = size / 2 - thick / 2 - 2
196 const cx = w / 2
197 const cy = h / 2
198 canvas.arc(cx, cy, r, thick, 0, 2 * Math.PI, hex(HEX.track, 110))
199 const pct = Math.max(0, Math.min(100, g.pct ?? 0))
200 if (pct > 0) {
201 const start = Math.PI / 2 - (2 * Math.PI * pct) / 100
202 canvas.arc(cx, cy, r, thick, start, Math.PI / 2, levelRgba(g.level, hex(HEX.neutral)))
203 }
204 return canvas.toSource()
205}
206
207/** The 16x16 duck at four pixels a pixel, centred in its 2-row cell's own shape so the terminal does not stretch it. */
208export function duckImage(pose: Frame): ImageSource {
209 const width = DUCK_COLUMNS * PX_W
210 const canvas = new PixelCanvas(width, 2 * PX_H)
211 canvas.sprite(DUCK_16[pose], DUCK_RGBA, Math.floor((width - 64) / 2), 0, 4)
212 return canvas.toSource()
213}
214
215export function graphicImage(g: Graphic): ImageSource {
216 switch (g.kind) {
217 case 'dial':
218 return dialImage(g)
219 case 'ring':
220 return ringImage(g)
221 case 'duck':
222 return duckImage(STEADY[g.pose])
223 }
224}
225
226/** A 1x1 clear pixel: what the band blits once to learn whether images show. */
227export function probeImage(): ImageSource {
228 return new PixelCanvas(1, 1).toSource()
229}
230
231src/context.ts 656 lines1// What fills the context. The window as /context breaks it down: the rows it
2// counts outside its Messages row (the system prompt, the tools, MCP, custom
3// agents, memory files, skills: quackpit's "setup"), the Messages row, the
4// free space and the compaction buffer. The Messages row is then split by
5// source from the conversation as the next request carries it
6// (`$.session.messages({ as: 'api' })`): each part measured (text and tool
7// input by characters, a picture by its pixels, Claude's thinking by the size
8// of its signature), and the shares scaled to the engine's own Messages row,
9// which it reconciles to the API's real usage. So the total is the engine's
10// and only the split is quackpit's estimate. Only sizes, tool names and, for
11// the largest parts, a path or a command masked as quackpit masks them are
12// kept: never the messages' words. Pure.
13
14import { shownCommand } from './checks'
15import { clip, relativePath } from './format'
16import { maskSecrets } from './mask'
17import type { Line, Span } from './text'
18
19/** Where what fills the window came from, in the bar's fixed order: setup first, then the conversation, then the tools. */
20export const SOURCE_IDS = ['setup', 'chat', 'thinking', 'files', 'edits', 'commands', 'agents', 'web', 'other'] as const
21export type SourceId = (typeof SOURCE_IDS)[number]
22/** The sources inside /context's Messages row. */
23export type MessageSource = Exclude<SourceId, 'setup'>
24export const MESSAGE_SOURCES: readonly MessageSource[] = SOURCE_IDS.filter((id): id is MessageSource => id !== 'setup')
25
26/**
27 * One fixed colour a source, never assigned by rank: chosen by drawing
28 * candidates on dark and light themes with the README's renderer. Mid-tones
29 * that read on both; never amber, red or green, which mean state; one grey
30 * only (setup), so no two greys stand side by side as /context's do (early in
31 * a session setup and other meet with nothing between them: other is brown);
32 * and in the bar's order neighbours differ in hue or in lightness.
33 */
34export const SOURCE_COLORS: Readonly<Record<SourceId, string>> = {
35 setup: '#6e7681',
36 chat: '#c9a47e',
37 thinking: '#d4a6e8',
38 files: '#4c8df6',
39 edits: '#1aaeb8',
40 commands: '#8a6cf0',
41 agents: '#cf5fae',
42 web: '#5bb8ee',
43 other: '#a3794f',
44}
45
46// ------------------------------------------------------------------ /context's rows
47
48/** One of /context's rows, as the adapter flattened it. */
49export type CategoryInput = { name: string; tokens: number; kind: string }
50
51/** The breakdown `$.session.usage({ breakdown: 'summary' })` answers, flattened: its rows and the lists beneath them. */
52export type BreakdownInput = {
53 categories: readonly CategoryInput[]
54 mcpTools?: readonly { serverName: string; tokens: number; isLoaded: boolean }[]
55 memoryFiles?: readonly { path: string; tokens: number }[]
56 agents?: readonly { agentType: string; tokens: number }[]
57 skills?: { totalSkills: number; includedSkills: number; tokens: number }
58}
59
60/** /context's rows outside Messages, by what they hold; another row keeps its own name. */
61export const SETUP_KEYS = ['prompt', 'tools', 'mcp', 'mcpNotes', 'agents', 'memory', 'skills'] as const
62export type SetupKey = (typeof SETUP_KEYS)[number]
63
64const SETUP_ROW: Readonly<Record<string, SetupKey>> = {
65 'System prompt': 'prompt',
66 'System tools': 'tools',
67 'MCP tools': 'mcp',
68 'MCP server instructions': 'mcpNotes',
69 'Custom agents': 'agents',
70 'Memory files': 'memory',
71 Skills: 'skills',
72}
73
74/** The Messages row's name: the one row a breakdown is told apart by its name, since its `kind` is `used` like setup's. */
75const MESSAGES_ROW = 'Messages'
76
77export type SetupRow = { key: SetupKey | string; tokens: number }
78
79/** The largest single parts of the conversation: a tool's call and its result, a long prompt, a block of thinking. */
80export type ContextItem = {
81 source: MessageSource
82 /** The tool that made it, for a tool's call and result. */
83 tool?: string
84 /** What the call was about: a path relative to the project, a command, a host, masked and cut. */
85 what?: string
86 /** For a part no tool made: what it is. */
87 kind?: 'prompt' | 'reply' | 'summary' | 'image' | 'note' | 'notice' | 'thinking'
88 /** For one of Claude Code's notes: which kind, from a fixed set (never its words). */
89 note?: NoteKind
90 tokens: number
91}
92
93/** Claude Code's notes by what they are, read from how each begins: the deferred tools' names, hooks' output, the skills in use, the session's environment. */
94export type NoteKind = 'tools' | 'hooks' | 'skills' | 'session'
95
96/** Which kind of note a note's body is, where it says; else none. */
97export function noteKind(body: string): NoteKind | undefined {
98 if (/^The following (?:deferred )?tools (?:are now|just became) available/.test(body)) return 'tools'
99 if (/^\S+ hook (?:success|additional context|blocking error|error)|^SessionStart\b|^UserPromptSubmit\b|^(?:Pre|Post)ToolUse\b|^Stop hook\b/.test(body)) return 'hooks'
100 if (/^The following skills were invoked/.test(body)) return 'skills'
101 if (/^# Environment|^You are powered by the model|^Today's date is|^<total_tokens>|^As you answer the user's questions|^Attribution for git/.test(body)) return 'session'
102 return undefined
103}
104
105/**
106 * What fills the context, as measured at the end of a turn: /context's rows
107 * outside Messages (the engine's), its Messages row (the engine's) and that
108 * row split by source (quackpit's estimate, in the row's tokens).
109 */
110export type ContextSplit = {
111 at: number
112 /** The turns completed when it was measured. */
113 turn: number
114 setup: SetupRow[]
115 /** MCP tools inside the window, by server, the largest first. */
116 mcp?: [string, number][]
117 /** Memory files: each one's path (from the home folder or the project) and tokens. */
118 memory?: [string, number][]
119 /** Custom agents described, and the skills listed of those there are. */
120 agentCount?: number
121 skills?: { included: number; total: number }
122 /** /context's Messages row. */
123 messages: number
124 /** The Messages row by source, in its tokens. */
125 sources: Partial<Record<MessageSource, number>>
126 /** The largest single parts, the largest first. */
127 items: ContextItem[]
128 /** Tool schemas loaded on demand, outside the window: never part of the bar. */
129 deferred?: { mcp: number; mcpTools: number; tools: number }
130}
131
132/** What a context split read back must hold; anything else is none. */
133export function asContextSplit(x: unknown): ContextSplit | undefined {
134 if (typeof x !== 'object' || x === null) return undefined
135 const s = x as Partial<ContextSplit>
136 const num = (n: unknown) => typeof n === 'number' && Number.isFinite(n)
137 if (!num(s.at) || !num(s.turn) || !num(s.messages) || !Array.isArray(s.setup) || typeof s.sources !== 'object' || s.sources === null || !Array.isArray(s.items)) return undefined
138 return s as ContextSplit
139}
140
141// ------------------------------------------------------------------ measuring the conversation
142
143/** A message as the Messages API carries it (`$.session.messages({ as: 'api' })`): what is read of it. */
144export type ApiMessageInput = { role?: unknown; content?: unknown }
145
146type Block = Record<string, unknown>
147const obj = (x: unknown): Block | undefined => (typeof x === 'object' && x !== null && !Array.isArray(x) ? (x as Block) : undefined)
148const str = (x: unknown): string | undefined => (typeof x === 'string' ? x : undefined)
149
150/** Where each tool's call and result go. */
151const TOOL_SOURCE: Readonly<Record<string, MessageSource>> = {
152 Read: 'files',
153 Grep: 'files',
154 Glob: 'files',
155 LS: 'files',
156 NotebookRead: 'files',
157 LSP: 'files',
158 Write: 'edits',
159 Edit: 'edits',
160 MultiEdit: 'edits',
161 NotebookEdit: 'edits',
162 Bash: 'commands',
163 PowerShell: 'commands',
164 BashOutput: 'commands',
165 KillShell: 'commands',
166 KillBash: 'commands',
167 Monitor: 'commands',
168 TaskOutput: 'commands',
169 TaskStop: 'commands',
170 Agent: 'agents',
171 Task: 'agents',
172 SendMessage: 'agents',
173 Workflow: 'agents',
174 TeamCreate: 'agents',
175 TeamDelete: 'agents',
176 WebFetch: 'web',
177 WebSearch: 'web',
178 ListMcpResourcesTool: 'web',
179 ReadMcpResourceTool: 'web',
180 ReadMcpResourceDirTool: 'web',
181}
182
183/** A tool's source: its own, an MCP server's (`mcp__…`) as the web's, any other tool's as other. */
184export function toolSource(tool: string): MessageSource {
185 return TOOL_SOURCE[tool] ?? (tool.startsWith('mcp__') ? 'web' : 'other')
186}
187
188/**
189 * Claude Code's notes that /context counts in its own rows outside Messages:
190 * the skills listed, the memory files, the MCP servers' instructions, the
191 * agent types. Measured again as messages, they would count twice.
192 */
193const SETUP_NOTES: readonly string[] = ['The following skills are available for use with the Skill tool', 'Codebase and user instructions are shown below', '# MCP Server Instructions', 'Available agent types for the Agent tool']
194
195const REMINDER = '<system-reminder>'
196const REMINDER_END = '</system-reminder>'
197
198/**
199 * A text's own words and the notes Claude Code put in it: it adds its
200 * reminders to the conversation inside the last tool result of a message
201 * (a deferred tool's arrival, MCP servers' instructions, the token budget),
202 * which are its notes and none of the tool's output.
203 */
204export function splitNotes(text: string): { rest: string; notes: string[] } {
205 if (!text.includes(REMINDER)) return { rest: text, notes: [] }
206 const notes: string[] = []
207 let rest = ''
208 let at = 0
209 while (at < text.length) {
210 const open = text.indexOf(REMINDER, at)
211 if (open < 0) break
212 const close = text.indexOf(REMINDER_END, open)
213 const end = close < 0 ? text.length : close + REMINDER_END.length
214 rest += text.slice(at, open)
215 notes.push(text.slice(open, end))
216 at = end
217 }
218 rest += text.slice(at)
219 return { rest: rest.trim() === '' ? '' : rest, notes }
220}
221
222/** Where a user message's text came from, and what it is; `skip` for a note /context counts in setup. */
223export function textSource(text: string): { source: MessageSource; kind: NonNullable<ContextItem['kind']>; tool?: string; note?: NoteKind } | 'skip' {
224 const t = text.trimStart()
225 if (t.startsWith(REMINDER)) {
226 const body = t.slice(REMINDER.length).trimStart()
227 if (SETUP_NOTES.some(head => body.startsWith(head))) return 'skip'
228 // After a compaction, the files read before come back as notes of the call and its result.
229 const tool = /^(?:Called the (\S+) tool|Result of calling the (\S+) tool)/.exec(body)
230 const name = tool?.[1] ?? tool?.[2]
231 if (name) return { source: toolSource(name), kind: 'note', tool: name }
232 if (/^Note: \S/.test(body)) return { source: 'files', kind: 'note' }
233 const note = noteKind(body)
234 return { source: 'other', kind: 'note', ...(note ? { note } : {}) }
235 }
236 if (t.startsWith('<task-notification>')) {
237 const summary = /<summary>([\s\S]*?)<\/summary>/.exec(t)?.[1]?.trim() ?? ''
238 return { source: /^(?:Background command|Monitor)\b/.test(summary) ? 'commands' : /^Agent\b|workflow/i.test(summary) ? 'agents' : 'other', kind: 'notice' }
239 }
240 if (/^<(?:teammate-message|agent-message)\b/.test(t) || /^Another Claude session sent a message/.test(t)) return { source: 'agents', kind: 'notice' }
241 if (/^<(?:bash-input|bash-stdout|bash-stderr)>/.test(t)) return { source: 'commands', kind: 'prompt' }
242 if (/^<(?:command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat)>/.test(t)) return { source: 'other', kind: 'note' }
243 if (/^This session is being continued from a previous conversation/.test(t)) return { source: 'chat', kind: 'summary' }
244 return { source: 'chat', kind: 'prompt' }
245}
246
247/**
248 * About how many tokens a text takes: a character in four for ASCII, more for
249 * other scripts (a Hangul syllable or a kanji is about a token or two).
250 */
251export function textTokens(text: string): number {
252 let wide = 0
253 for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) > 0x7f) wide++
254 return (text.length - wide) / 4 + wide / 1.5
255}
256
257/**
258 * Claude's thinking by the size of what came back of it: its words where
259 * Claude Code keeps them, else its signature, which carries it encrypted (on
260 * 2.1.296 the words are withheld). A signature is about 2.2 characters a
261 * token of thinking past a few hundred characters of its own: measured
262 * against turns whose reported output was mostly thinking.
263 */
264export function thinkingTokens(block: Block): number {
265 const words = str(block.thinking)
266 if (words && words.trim() !== '') return textTokens(words)
267 const sealed = str(block.signature) ?? str(block.data) ?? ''
268 return Math.max(0, sealed.length - 400) / 2.2
269}
270
271/** The most a picture costs: one of about 1.15 megapixels. */
272export const IMAGE_MAX_TOKENS = 1600
273
274const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
275
276/** The first bytes of base64 data, at most `max`. */
277function headBytes(data: string, max: number): number[] {
278 const out: number[] = []
279 const end = Math.min(data.length, Math.ceil((max * 4) / 3 / 4) * 4)
280 for (let i = 0; i + 3 < end; i += 4) {
281 const n = [0, 1, 2, 3].reduce((acc, k) => (acc << 6) | Math.max(0, B64.indexOf(data[i + k] ?? 'A')), 0)
282 out.push((n >> 16) & 255, (n >> 8) & 255, n & 255)
283 }
284 return out
285}
286
287/** A picture's pixels, from its header: PNG, GIF, WebP and JPEG; undefined for anything else. */
288export function imageSize(data: string): { width: number; height: number } | undefined {
289 const b = headBytes(data, 64)
290 const at = (i: number) => b[i] ?? 0
291 if (at(0) === 0x89 && at(1) === 0x50 && at(2) === 0x4e && at(3) === 0x47) return { width: ((at(16) << 24) | (at(17) << 16) | (at(18) << 8) | at(19)) >>> 0, height: ((at(20) << 24) | (at(21) << 16) | (at(22) << 8) | at(23)) >>> 0 }
292 if (at(0) === 0x47 && at(1) === 0x49 && at(2) === 0x46) return { width: at(6) | (at(7) << 8), height: at(8) | (at(9) << 8) }
293 if (at(0) === 0x52 && at(1) === 0x49 && at(8) === 0x57 && at(9) === 0x45) {
294 const chunk = String.fromCharCode(at(12), at(13), at(14), at(15))
295 if (chunk === 'VP8 ') return { width: (at(26) | (at(27) << 8)) & 0x3fff, height: (at(28) | (at(29) << 8)) & 0x3fff }
296 if (chunk === 'VP8L') return { width: 1 + (((at(22) & 0x3f) << 8) | at(21)), height: 1 + (((at(24) & 0xf) << 10) | (at(23) << 2) | ((at(22) & 0xc0) >> 6)) }
297 if (chunk === 'VP8X') return { width: 1 + (at(24) | (at(25) << 8) | (at(26) << 16)), height: 1 + (at(27) | (at(28) << 8) | (at(29) << 16)) }
298 return undefined
299 }
300 if (at(0) === 0xff && at(1) === 0xd8) {
301 // A JPEG's size is in its frame header, after whatever comes first (its EXIF, its tables).
302 const j = headBytes(data, 96 * 1024)
303 let i = 2
304 while (i + 9 < j.length) {
305 if (j[i] !== 0xff) return undefined
306 const marker = j[i + 1] ?? 0
307 const length = ((j[i + 2] ?? 0) << 8) | (j[i + 3] ?? 0)
308 if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { height: ((j[i + 5] ?? 0) << 8) | (j[i + 6] ?? 0), width: ((j[i + 7] ?? 0) << 8) | (j[i + 8] ?? 0) }
309 i += 2 + length
310 }
311 }
312 return undefined
313}
314
315/** A picture's tokens: its pixels over 750, once scaled to fit 1568 pixels a side and about 1.15 megapixels. */
316export function imageTokens(source: unknown): number {
317 const data = str(obj(source)?.data)
318 const size = data ? imageSize(data) : undefined
319 if (!size || size.width <= 0 || size.height <= 0) return IMAGE_MAX_TOKENS
320 let { width, height } = size
321 const long = Math.max(width, height)
322 if (long > 1568) {
323 width = (width * 1568) / long
324 height = (height * 1568) / long
325 }
326 const area = width * height
327 if (area > 1_150_000) {
328 const k = Math.sqrt(1_150_000 / area)
329 width *= k
330 height *= k
331 }
332 return Math.min(IMAGE_MAX_TOKENS, Math.max(1, Math.ceil((width * height) / 750)))
333}
334
335/** A document's tokens (a PDF the person attached): its base64 by a rule of thumb, a few hundred a page. */
336function documentTokens(source: unknown): number {
337 const data = str(obj(source)?.data)
338 return data ? data.length * 0.06 : textTokens(JSON.stringify(source ?? '') ?? '')
339}
340
341/** A tool result's content: its text, its pictures by their pixels; Claude Code's notes in it kept apart. */
342function resultParts(content: unknown): { tokens: number; notes: string[] } {
343 if (typeof content === 'string') {
344 const { rest, notes } = splitNotes(content)
345 return { tokens: textTokens(rest), notes }
346 }
347 if (!Array.isArray(content)) return { tokens: 0, notes: [] }
348 let tokens = 0
349 const notes: string[] = []
350 for (const part of content) {
351 const p = obj(part)
352 if (!p) continue
353 if (p.type === 'text') {
354 const split = splitNotes(str(p.text) ?? '')
355 tokens += textTokens(split.rest)
356 notes.push(...split.notes)
357 } else if (p.type === 'image') tokens += imageTokens(p.source)
358 else if (p.type === 'document') tokens += documentTokens(p.source)
359 else tokens += textTokens(safeJson(p))
360 }
361 return { tokens, notes }
362}
363
364function safeJson(x: unknown): string {
365 try {
366 return JSON.stringify(x) ?? ''
367 } catch {
368 return ''
369 }
370}
371
372/** What a tool's call was about, masked as quackpit masks what it keeps, and cut. */
373function subjectOf(tool: string, input: Block | undefined, root: string | undefined): string | undefined {
374 if (!input) return undefined
375 const path = str(input.file_path) ?? str(input.notebook_path) ?? str(input.path)
376 if (path && (tool === 'Read' || tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit' || tool === 'NotebookRead' || tool === 'LS')) return tailOf(maskSecrets(relativePath(path, root)), 40)
377 const command = str(input.command)
378 if (command && (tool === 'Bash' || tool === 'PowerShell' || tool === 'Monitor')) return shownCommand(command, 40)
379 if (tool === 'Grep' || tool === 'Glob') return str(input.pattern) ? clip(maskSecrets(String(input.pattern)), 32) : undefined
380 if (tool === 'WebFetch') {
381 const url = str(input.url)
382 return url ? (/^[a-z]+:\/\/([^/?#]+)/i.exec(url)?.[1] ?? clip(url, 32)) : undefined
383 }
384 if (tool === 'WebSearch') return str(input.query) ? clip(maskSecrets(String(input.query)), 32) : undefined
385 if (tool === 'Agent' || tool === 'Task' || tool === 'Workflow') return str(input.description) ? clip(maskSecrets(String(input.description)), 32) : undefined
386 if (tool.startsWith('mcp__')) {
387 const [, server, name] = tool.split('__')
388 return server && name ? `${server} · ${name}` : undefined
389 }
390 return undefined
391}
392
393/** A path cut to `max` characters, its end kept: the file's name says most. */
394const tailOf = (text: string, max: number) => (text.length > max ? `…${text.slice(-(max - 1))}` : text)
395
396/** The conversation measured: each source's parts summed, in estimated tokens, and the largest parts. */
397export type Measured = { raw: Record<MessageSource, number>; items: ContextItem[] }
398
399/** How many of the largest parts are kept. */
400export const KEEP_ITEMS = 5
401
402/**
403 * The conversation the next request is built from, measured by source: a
404 * tool's call and its result go to the tool's source together; the person's
405 * words, Claude's replies, the compaction summary and pasted pictures are the
406 * chat; Claude's thinking is its own; Claude Code's notes (hook output, the
407 * session's surroundings, reminders) and every other tool are other. The
408 * notes /context counts in setup are left out. Sizes only.
409 */
410export function measureMessages(messages: readonly ApiMessageInput[], root?: string): Measured {
411 const raw: Record<MessageSource, number> = { chat: 0, thinking: 0, files: 0, edits: 0, commands: 0, agents: 0, web: 0, other: 0 }
412 const calls = new Map<string, ContextItem>()
413 const items: ContextItem[] = []
414 const add = (source: MessageSource, tokens: number, item?: ContextItem) => {
415 if (!(tokens > 0)) return
416 raw[source] += tokens
417 if (item) item.tokens += tokens
418 }
419 // Claude Code's own notes, each where it belongs: the ones setup counts left out.
420 const addText = (text: string) => {
421 const from = textSource(text)
422 if (from === 'skip') return
423 const item: ContextItem = { source: from.source, kind: from.kind, ...(from.tool ? { tool: from.tool } : {}), ...(from.note ? { note: from.note } : {}), tokens: 0 }
424 items.push(item)
425 add(from.source, textTokens(text), item)
426 }
427 for (const m of messages) {
428 const role = m.role
429 const content = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : Array.isArray(m.content) ? m.content : []
430 for (const part of content) {
431 const block = obj(part)
432 if (!block) continue
433 switch (block.type) {
434 case 'text': {
435 const text = str(block.text) ?? ''
436 if (role === 'assistant') {
437 const item: ContextItem = { source: 'chat', kind: 'reply', tokens: 0 }
438 items.push(item)
439 add('chat', textTokens(text), item)
440 break
441 }
442 // Each note on its own, then what is left: the person's words, a summary, a notice.
443 const { rest, notes } = splitNotes(text)
444 for (const n of notes) addText(n)
445 if (rest !== '') addText(rest)
446 break
447 }
448 case 'thinking':
449 case 'redacted_thinking': {
450 const item: ContextItem = { source: 'thinking', kind: 'thinking', tokens: 0 }
451 items.push(item)
452 add('thinking', thinkingTokens(block), item)
453 break
454 }
455 case 'tool_use':
456 case 'server_tool_use': {
457 const tool = str(block.name) ?? 'tool'
458 const source = toolSource(tool)
459 const what = subjectOf(tool, obj(block.input), root)
460 const item: ContextItem = { source, tool, ...(what ? { what } : {}), tokens: 0 }
461 const id = str(block.id)
462 if (id) calls.set(id, item)
463 items.push(item)
464 add(source, textTokens(safeJson(block.input ?? {})), item)
465 break
466 }
467 case 'tool_result':
468 case 'web_search_tool_result': {
469 const id = str(block.tool_use_id)
470 const item = id ? calls.get(id) : undefined
471 const result = resultParts(block.content)
472 add(item?.source ?? (block.type === 'web_search_tool_result' ? 'web' : 'other'), result.tokens, item)
473 for (const n of result.notes) addText(n)
474 break
475 }
476 case 'image': {
477 const item: ContextItem = { source: 'chat', kind: 'image', tokens: 0 }
478 items.push(item)
479 add('chat', imageTokens(block.source), item)
480 break
481 }
482 case 'document': {
483 const item: ContextItem = { source: 'chat', kind: 'prompt', tokens: 0 }
484 items.push(item)
485 add('chat', documentTokens(block.source), item)
486 break
487 }
488 default:
489 add('other', textTokens(safeJson(block)))
490 }
491 }
492 }
493 const largest = items
494 .filter(item => item.tokens > 0)
495 .sort((a, b) => b.tokens - a.tokens)
496 .slice(0, KEEP_ITEMS)
497 return { raw, items: largest }
498}
499
500// ------------------------------------------------------------------ the split
501
502/** The breakdown's rows outside Messages that occupy the window, by what they hold. */
503function setupRows(b: BreakdownInput): SetupRow[] {
504 return b.categories
505 .filter(c => c.kind === 'used' && c.name !== MESSAGES_ROW && c.tokens > 0)
506 .map(c => ({ key: SETUP_ROW[c.name] ?? clip(c.name, 40), tokens: Math.round(c.tokens) }))
507}
508
509/** A memory file's path as the detail shows it: from the project or the home folder, masked, its end kept. */
510function memoryPath(path: string, root: string | undefined, home: string | undefined): string {
511 const fromProject = relativePath(path, root)
512 const shown = fromProject !== path ? fromProject : home && path.startsWith(`${home.replace(/\/+$/, '')}/`) ? `~/${path.slice(home.replace(/\/+$/, '').length + 1)}` : path
513 return tailOf(maskSecrets(shown), 48)
514}
515
516/**
517 * What fills the context, from /context's breakdown and the conversation
518 * measured: the setup rows and the Messages row as the engine counts them,
519 * and the Messages row split by source in the measured shares.
520 */
521export function splitOf(b: BreakdownInput, measured: Measured, at: number, turn: number, paths: { root?: string; home?: string } = {}): ContextSplit {
522 const messages = Math.max(0, Math.round(b.categories.find(c => c.kind === 'used' && c.name === MESSAGES_ROW)?.tokens ?? 0))
523 const total = MESSAGE_SOURCES.reduce((sum, id) => sum + measured.raw[id], 0)
524 const scale = total > 0 ? messages / total : 0
525 const sources: Partial<Record<MessageSource, number>> = {}
526 for (const id of MESSAGE_SOURCES) {
527 const tokens = Math.round(measured.raw[id] * scale)
528 if (tokens > 0) sources[id] = tokens
529 }
530 const items = measured.items.map(item => ({ ...item, tokens: Math.round(item.tokens * scale) })).filter(item => item.tokens > 0)
531 const servers = new Map<string, number>()
532 let deferredTools = 0
533 for (const tool of b.mcpTools ?? []) {
534 if (tool.isLoaded) servers.set(tool.serverName, (servers.get(tool.serverName) ?? 0) + tool.tokens)
535 else deferredTools++
536 }
537 const mcp = [...servers.entries()].filter(([, tokens]) => tokens > 0).sort((x, y) => y[1] - x[1]).slice(0, 6).map(([name, tokens]): [string, number] => [clip(name, 40), Math.round(tokens)])
538 const memory = (b.memoryFiles ?? []).slice(0, 6).map((f): [string, number] => [memoryPath(f.path, paths.root, paths.home), Math.round(f.tokens)])
539 const deferred = b.categories.filter(c => c.kind === 'deferred')
540 const deferredMcp = deferred.filter(c => /mcp/i.test(c.name)).reduce((sum, c) => sum + c.tokens, 0)
541 const deferredOther = deferred.reduce((sum, c) => sum + c.tokens, 0) - deferredMcp
542 return {
543 at,
544 turn,
545 setup: setupRows(b),
546 ...(mcp.length > 0 ? { mcp } : {}),
547 ...(memory.length > 0 ? { memory } : {}),
548 ...((b.agents ?? []).length > 0 ? { agentCount: (b.agents ?? []).length } : {}),
549 ...(b.skills && b.skills.totalSkills > 0 ? { skills: { included: b.skills.includedSkills, total: b.skills.totalSkills } } : {}),
550 messages,
551 sources,
552 items,
553 ...(deferred.length > 0 ? { deferred: { mcp: Math.round(deferredMcp), mcpTools: deferredTools, tools: Math.round(deferredOther) } } : {}),
554 }
555}
556
557/** Every source's tokens in a split, setup's as /context's rows sum, the largest first; the empty ones left out. */
558export function sourceTokens(split: ContextSplit): { id: SourceId; tokens: number }[] {
559 const setup = split.setup.reduce((sum, row) => sum + row.tokens, 0)
560 return [{ id: 'setup' as SourceId, tokens: setup }, ...MESSAGE_SOURCES.map(id => ({ id: id as SourceId, tokens: split.sources[id] ?? 0 }))].filter(s => s.tokens > 0).sort((a, b) => b.tokens - a.tokens || SOURCE_IDS.indexOf(a.id) - SOURCE_IDS.indexOf(b.id))
561}
562
563// ------------------------------------------------------------------ the bar
564
565/** One stretch of the bar: a source's share of what is used, the free space, the compaction buffer. */
566export type BarPart = { id: SourceId | 'free' | 'buffer'; tokens: number }
567
568/** The window as the bar draws it. */
569export type BarFill = { tokens: number; window: number; autoCompact?: number }
570
571/**
572 * The bar's parts across the whole window: what is used (the fill now, split
573 * in the measured shares, in the sources' fixed order), the free space up to
574 * the auto-compact point, and the buffer past it (none while auto-compact is
575 * off). Deferred tools are outside the window and never part of it.
576 */
577export function barParts(split: ContextSplit, fill: BarFill): BarPart[] {
578 const window = Math.max(1, fill.window)
579 const used = Math.max(0, Math.min(window, fill.tokens))
580 const point = fill.autoCompact !== undefined ? Math.max(0, Math.min(window, fill.autoCompact)) : window
581 const shares = SOURCE_IDS.map(id => ({ id, tokens: id === 'setup' ? split.setup.reduce((sum, row) => sum + row.tokens, 0) : (split.sources[id] ?? 0) }))
582 const total = shares.reduce((sum, s) => sum + s.tokens, 0)
583 const parts: BarPart[] = total > 0 ? shares.map(s => ({ id: s.id, tokens: (s.tokens / total) * used })) : []
584 parts.push({ id: 'free', tokens: Math.max(0, point - used) }, { id: 'buffer', tokens: Math.max(0, window - Math.max(point, used)) })
585 return parts
586}
587
588/** Whole eighths of a cell for each part, summing to the bar's (largest remainder): no part is given a minimum. */
589export function eighths(parts: readonly BarPart[], columns: number): number[] {
590 const units = Math.max(0, columns) * 8
591 const total = parts.reduce((sum, p) => sum + p.tokens, 0)
592 if (total <= 0) return parts.map(() => 0)
593 const exact = parts.map(p => (p.tokens / total) * units)
594 const whole = exact.map(Math.floor)
595 let left = units - whole.reduce((sum, n) => sum + n, 0)
596 const order = exact.map((x, i) => ({ i, rest: x - (whole[i] ?? 0) })).sort((a, b) => b.rest - a.rest)
597 for (const { i } of order) {
598 if (left <= 0) break
599 whole[i] = (whole[i] ?? 0) + 1
600 left--
601 }
602 return whole
603}
604
605const LEFT = ' ▏▎▍▌▋▊▉█'
606
607/** One cell of the bar as text: its glyph and the sources whose colours it takes. */
608export type BarCell = { glyph: string; fg?: SourceId; bg?: SourceId; muted?: true }
609
610/**
611 * The bar as text cells, a boundary at an eighth of a cell: a cell two parts
612 * share is the first part's left block in its colour over the second's.
613 * Where more meet in one cell, the first and the largest of the rest show.
614 * The free space is dim dots, the buffer dim hatching (`░`): they differ by
615 * glyph, never by colour, so where the dots end is the auto-compact point.
616 */
617export function barCells(parts: readonly BarPart[], columns: number): BarCell[] {
618 const n = eighths(parts, columns)
619 const seq: BarPart['id'][] = []
620 parts.forEach((p, i) => {
621 for (let k = 0; k < (n[i] ?? 0); k++) seq.push(p.id)
622 })
623 const cells: BarCell[] = []
624 for (let x = 0; x < columns; x++) {
625 const unit = seq.slice(x * 8, x * 8 + 8)
626 const first = unit[0]
627 if (first === undefined) {
628 cells.push({ glyph: ' ' })
629 continue
630 }
631 const run = unit.filter(id => id === first).length
632 const rest = unit.filter(id => id !== first)
633 const counts = new Map<BarPart['id'], number>()
634 for (const id of rest) counts.set(id, (counts.get(id) ?? 0) + 1)
635 const second = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
636 if (first === 'free') cells.push({ glyph: '·', muted: true })
637 else if (first === 'buffer') cells.push({ glyph: '░', muted: true })
638 else if (second === undefined) cells.push({ glyph: '█', fg: first })
639 else if (second === 'free' || second === 'buffer') cells.push({ glyph: LEFT[run] ?? '█', fg: first })
640 else cells.push({ glyph: LEFT[run] ?? '█', fg: first, bg: second })
641 }
642 return cells
643}
644
645/** The bar as a line of spans in the sources' own colours, neighbours of one look merged. */
646export function barLine(parts: readonly BarPart[], columns: number): Line {
647 const out: Span[] = []
648 for (const cell of barCells(parts, columns)) {
649 const span: Span = { text: cell.glyph, ...(cell.fg ? { color: SOURCE_COLORS[cell.fg] } : {}), ...(cell.bg ? { bgColor: SOURCE_COLORS[cell.bg] } : {}), ...(cell.muted ? { tone: 'muted' as const } : {}) }
650 const last = out.at(-1)
651 if (last && last.color === span.color && last.bgColor === span.bgColor && last.tone === span.tone) last.text += span.text
652 else out.push(span)
653 }
654 return out
655}
656src/handover.ts 165 lines1// The handover: a deterministic Markdown note for the next session, built
2// from this session's events alone (no model, nothing summarised): Mission,
3// Your words (the person's later prompts, the last one whole), Waiting on you
4// (the question Claude's last answer ended on, when it did), Telemetry, How to
5// run (the last passing command of each kind of check), Unverified (files
6// edited after the last passing check), Anomalies and Next shift. It says to
7// treat every line as a starting point, not a fact. The adapter writes it to
8// `.claude/handover.md` in the project and shows it in the pane; quackpit
9// never submits it anywhere. Pure.
10
11import { clockText, formatPercent, formatTokens, formatUsd, relativePath, stampText } from './format'
12import { t, type Locale } from './i18n'
13import { maskSecrets } from './mask'
14import { contextNow, unverified, type Cockpit } from './model'
15import { progressOf, type Task } from './progress'
16
17export const HANDOVER_PATH = '.claude/handover.md'
18
19export type HandoverInput = {
20 c: Cockpit
21 now: number
22 locale: Locale
23 /** The project root, so paths read relative to it. */
24 root?: string
25}
26
27function quote(text: string): string {
28 return text
29 .split('\n')
30 .map(line => `> ${line}`.trimEnd())
31 .join('\n')
32}
33
34function fence(text: string): string {
35 const mark = text.includes('```') ? '~~~' : '```'
36 return `${mark}\n${text}\n${mark}`
37}
38
39function taskLine(task: Task): string {
40 const box = task.status === 'completed' ? '[x]' : '[ ]'
41 const tail = task.status === 'in_progress' ? ' _(in progress)_' : ''
42 return `- ${box} ${maskSecrets(task.title)}${tail}`
43}
44
45export function buildHandover(input: HandoverInput): string {
46 const { c, now, locale: L, root } = input
47 // What a resumed transcript gave back has no time of its own: say so rather than print the resume's.
48 const stamp = (at: number, replayed?: true) => (replayed ? t(L, 'resume.before') : clockText(at))
49 const out: string[] = []
50 out.push(`# ${t(L, 'ho.title')}`, '')
51 out.push(t(L, 'ho.generated', { stamp: stampText(now) }), t(L, 'ho.treat'), t(L, 'ho.paste'), '')
52
53 // Mission
54 out.push(`## ${t(L, 'ho.mission')}`, '')
55 if (c.goal) out.push(t(L, 'ho.goal'), '', quote(maskSecrets(c.goal)), '')
56 else out.push(t(L, c.goalLost ? 'ho.goalLost' : 'ho.noGoal'), '')
57 const p = progressOf(c.tasks)
58 if (c.tasks && p) {
59 out.push(t(L, 'ho.tasks', { done: p.done, total: p.total }), '')
60 for (const task of c.tasks.items) out.push(taskLine(task))
61 out.push('')
62 } else {
63 out.push(t(L, 'ho.noTasks'), '')
64 }
65
66 // Your words: what the person said after the goal, the last of it whole.
67 out.push(`## ${t(L, 'ho.words')}`, '')
68 const earlier = c.prompts.slice(0, -1)
69 if (c.lastPrompt === undefined) out.push(t(L, 'ho.noWords'), '')
70 else {
71 if (earlier.length > 0) {
72 out.push(t(L, 'ho.wordsEarlier'), '')
73 for (const said of earlier) out.push(`- ${said.replace(/\s+/g, ' ')}`)
74 out.push('')
75 }
76 out.push(t(L, 'ho.wordsLast'), '', quote(c.lastPrompt), '')
77 }
78
79 // Waiting on you: only when Claude's last answer ended on a question.
80 if (c.question !== undefined) out.push(`## ${t(L, 'ho.waiting')}`, '', t(L, 'ho.waitingIntro'), '', quote(c.question), '')
81
82 // Telemetry
83 out.push(`## ${t(L, 'ho.telemetry')}`, '')
84 const files = [...c.files].sort((a, b) => b.lastAt - a.lastAt)
85 if (files.length > 0) {
86 out.push(t(L, 'ho.files', { n: files.length }))
87 for (const f of files.slice(0, 40)) out.push(`- \`${relativePath(f.path, root)}\` (×${f.edits}${f.created ? ', new' : ''})`)
88 if (files.length > 40) out.push(`- … +${files.length - 40}`)
89 out.push('')
90 } else {
91 out.push(t(L, 'ho.noFiles'), '')
92 }
93 const commands = c.commands.slice(-15)
94 if (commands.length > 0) {
95 out.push(t(L, 'ho.commands', { n: commands.length }))
96 for (const run of commands) {
97 const mark = run.ok === true ? '✓' : run.ok === false ? '✗' : '·'
98 out.push(`- ${mark} \`${run.command.replace(/`/g, "'")}\` (${stamp(run.at, run.replayed)})`)
99 }
100 out.push('')
101 } else {
102 out.push(t(L, 'ho.noCommands'), '')
103 }
104 const r = c.reading
105 const fill = contextNow(c)
106 const ctx =
107 fill.pct !== undefined
108 ? `${formatPercent(fill.pct)}${fill.tokens !== undefined && fill.window ? ` (${formatTokens(fill.tokens)} / ${formatTokens(fill.window)})` : ''}`
109 : '—'
110 const turns = c.turnCount === 1 ? t(L, 'ho.turns.one') : t(L, 'ho.turns.many', { n: c.turnCount })
111 out.push(t(L, 'ho.usage', { tokens: formatTokens(c.tokens), turns, cost: formatUsd(r?.costUsd), ctx }), '')
112
113 // How to run: the last passing command of each kind of check.
114 out.push(`## ${t(L, 'ho.run')}`, '')
115 if (c.passing.length === 0) out.push(t(L, 'ho.noRun'), '')
116 else {
117 out.push(t(L, 'ho.runIntro'))
118 for (const pass of c.passing) out.push(`- ${t(L, `kind.${pass.kind}`)}: \`${pass.command.replace(/`/g, "'")}\` (${stamp(pass.at, pass.replayed)})`)
119 out.push('')
120 }
121
122 // Unverified: what was edited after the last passing check.
123 out.push(`## ${t(L, 'ho.unverified')}`, '')
124 const unseen = unverified(c)
125 const lastPass = c.checks.findLast(ch => ch.status === 'pass')
126 if (!unseen || !lastPass) out.push(t(L, 'ho.noPass'), '')
127 else if (unseen.paths.length === 0) out.push(t(L, 'ho.noUnverified'), '')
128 else {
129 out.push(t(L, 'ho.unverifiedIntro', { command: lastPass.command.replace(/`/g, "'"), stamp: stamp(lastPass.endedAt ?? lastPass.startedAt, lastPass.replayed) }))
130 for (const path of unseen.paths) out.push(`- \`${relativePath(path, root)}\``)
131 out.push('')
132 }
133
134 // Anomalies
135 out.push(`## ${t(L, 'ho.anomalies')}`, '')
136 const anomalies: string[] = []
137 for (const check of c.checks.filter(ch => ch.status === 'fail').slice(-8)) {
138 anomalies.push(`- ${t(L, 'ho.failedCheck', { kind: t(L, `kind.${check.kind}`), command: check.command.replace(/`/g, "'"), stamp: stamp(check.endedAt ?? check.startedAt, check.replayed) })}`)
139 }
140 for (const turn of c.history.filter(h => h.runaway)) {
141 anomalies.push(`- ${t(L, 'ho.runaway', { n: turn.n, amount: turn.costUsd !== undefined ? formatUsd(turn.costUsd) : `${formatTokens(turn.tokens)} tokens` })}`)
142 }
143 if (c.compactions > 0) anomalies.push(`- ${t(L, 'ho.compactions', { n: c.compactions === 1 ? t(L, 'ho.times.one') : t(L, 'ho.times.many', { n: c.compactions }) })}`)
144 out.push(...(anomalies.length > 0 ? anomalies : [t(L, 'ho.none')]), '')
145
146 // Next shift
147 out.push(`## ${t(L, 'ho.next')}`, '')
148 const open = c.tasks?.items.filter(task => task.status !== 'completed') ?? []
149 if (open.length > 0) {
150 out.push(t(L, 'ho.open'), '')
151 for (const task of open) out.push(taskLine(task))
152 out.push('')
153 } else {
154 out.push(t(L, 'ho.noOpen'), '')
155 }
156 // Every kind of check whose latest run failed, the newest first: a lint that passed later
157 // does not clear a test that is still failing.
158 const failures = [...c.failures].reverse()
159 for (const failure of failures) {
160 out.push(t(L, 'ho.lastFailure', { command: failure.command.replace(/`/g, "'"), stamp: stamp(failure.at, failure.replayed) }), '', fence(failure.output || '(no output)'), '')
161 }
162 if (failures.length === 0) out.push(t(L, 'ho.noFailure'), '')
163 return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
164}
165src/hud.ts 82 lines1// First run for claude-hud users: its config (read only, never written)
2// says which lines they keep on, and each maps to a tile, so the band starts
3// out familiar. Pure: the adapter reads the two files and hands their text in.
4//
5// claude-hud quackpit
6// todos (display.showTodos) progress (the hero, always first)
7// context (always on) context
8// usage (display.showUsage, on) limits, pace
9// tools (display.showTools) checks, files
10// agents (display.showAgents) agents
11// git (gitStatus.enabled, on) session
12// cost (display.showCost) cost
13// duration (display.showDuration) session
14
15import type { TileId } from './tiles'
16
17/** Where claude-hud keeps its config, relative to the Claude config directory. */
18export const HUD_FILES = ['plugins/claude-hud/config.json', 'claude-hud.json'] as const
19
20type Json = Record<string, unknown>
21
22function obj(x: unknown): Json | undefined {
23 return typeof x === 'object' && x !== null && !Array.isArray(x) ? (x as Json) : undefined
24}
25
26/** Parses a config file's text; undefined when it is no JSON object. */
27export function parseHudConfig(text: string | undefined): Json | undefined {
28 if (text === undefined) return undefined
29 try {
30 return obj(JSON.parse(text))
31 } catch {
32 return undefined
33 }
34}
35
36/** Layers the per-directory file over the shared one, as claude-hud does. */
37export function layerHud(base: Json | undefined, over: Json | undefined): Json | undefined {
38 if (!base && !over) return undefined
39 const merge = (a: Json | undefined, b: Json | undefined): Json => {
40 const out: Json = { ...(a ?? {}) }
41 for (const [k, v] of Object.entries(b ?? {})) {
42 const inner = obj(v)
43 out[k] = inner && obj(out[k]) ? merge(obj(out[k]), inner) : v
44 }
45 return out
46 }
47 return merge(base, over)
48}
49
50export type HudLines = { todos: boolean; context: boolean; usage: boolean; tools: boolean; agents: boolean; git: boolean; cost: boolean; duration: boolean }
51
52/** Which of claude-hud's lines are on, with claude-hud's own defaults. */
53export function hudLines(config: Json): HudLines {
54 const display = obj(config.display) ?? {}
55 const git = obj(config.gitStatus) ?? {}
56 return {
57 todos: display.showTodos === true,
58 context: true,
59 usage: display.showUsage !== false,
60 tools: display.showTools === true,
61 agents: display.showAgents === true,
62 git: git.enabled !== false,
63 cost: display.showCost === true,
64 duration: display.showDuration === true,
65 }
66}
67
68/** The tiles claude-hud's lines map to, in quackpit's order. */
69export function hudTiles(config: Json): TileId[] {
70 const on = hudLines(config)
71 const tiles: TileId[] = ['progress']
72 if (on.usage) tiles.push('pace')
73 if (on.context) tiles.push('context')
74 if (on.usage) tiles.push('limits')
75 if (on.tools) tiles.push('checks')
76 if (on.cost) tiles.push('cost')
77 if (on.agents) tiles.push('agents')
78 if (on.tools) tiles.push('files')
79 if (on.git || on.duration) tiles.push('session')
80 return tiles
81}
82