SLOPSHOPPER

quackpit

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…

newpanebandspinnerguardcommand
v0.1.0MITupdated 2026-10-14imsungbin/quackpit-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quackpit
│ ┃ quackpit ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 1: pane-view-overview 2: time 3: turns 4: ba │ quackpit │ │ ┃ ━━━━━━━━━──────────────────────────────────… ⏺ Read(src/auth.ts) │ quackpit is above the prompt: /quackpit │ │ ┃ ○ Standing by · last turn <1m · 0 tools · j… ⎿ Read 6 lines │ opens its pane (so does a click on any │ │ ┃ ⏺ Update(src/auth.ts) │ gauge), and /quackpit help explains each │ │ ┃ cell-pace │ cell-limits ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ ▗▄▖ 66k/h │ 31% u ⏺ Bash(bun test) │ ┃ ▄█▀▀▀▗▄ warming up │ ▄█▀▀▀█▄ trend ⎿ 3 pass, 1 fail │ ┃ █▘▄▄ ▝▟ │ █▘▝▙ ▝▟ │ ┃ ───────────────────────────┼──────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ──────────── │ ┃ cell-context │ cell-cost ✻ Worked… │ ┃ 49% · 97k of 200k │ sessi 7.9k tokens │ ┃ │ turn │ ┃ no compaction yet │ █ no me › /quackpit │ ┃ ───────────────────────────┼──────────────── │ ┃ ──────────── │ ┃ cell-checks │ cell-files │ ┃ — │ ━━━━━ cache.ts │ ┃ none run yet │ ━━━━━ audit.ts │ ┃ │ ━━━━━ auth.ts × │ ┃ ───────────────────────────┼──────────────── │ ┃ ──────────── │ ┃ cell-health │ cell-session │ ┃ 100 smooth │ Opus 5.5 │ ┃ ▄█▀▀▀█▄ all parts clear │ up 30m · workin ▖ █-▄ │tile-progress │ ▁▃▄▆█ tile-pace│▄▀▀▀▀▄ tile-context│tile-limits│tile-checks │tile-cos ▀███▛ │last turn <1m · 0 tools │▂▄▅▇█████ pace │▀▄▄▄▄▀ ctx 97k │7d — │checks · none run yet│█ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▖ █-▄ │tile-progress │ ▁▃▄▆█ tile-pace│▄▀▀▀▀▄ tile-context│tile-limits│tile-checks ▀███▛ │last turn <1m · 0 tools │▂▄▅▇█████ pace │▀▄▄▄▄▀ ctx 97k │7d — │checks · none
Pane · quackpit
1: pane-view-overview 2: time 3: turns 4: band 5: handoff 6: ━━━━━━━━━─────────────────────────────────────────────── ○ Standing by · last turn <1m · 0 tools · just now cell-pace │ cell-limits ▗▄▖ 66k/h │ 31% used ▄█▀▀▀▗▄ warming up │ ▄█▀▀▀█▄ trend: measuring █▘▄▄ ▝▟ │ █▘▝▙ ▝▟ ───────────────────────────┼──────────────────────────── cell-context │ cell-cost 49% · 97k of 200k │ session $0.42 │ turn — no compaction yet │ █ no median yet ───────────────────────────┼──────────────────────────── cell-checks │ cell-files — │ ━━━━━ cache.ts ×1 none run yet │ ━━━━━ audit.ts ×1 │ ━━━━━ auth.ts ×1 ───────────────────────────┼──────────────────────────── cell-health │ cell-session 100 smooth │ Opus 5.5 ▄█▀▀▀█▄ all parts clear │ up 30m · working 0m █▘ ▄▄▝█ a judgement │ 1 turn · app click the pane for its keys · Tab, Enter: open a gauge
Turn line
✻ Worked… 7.9k tokens
README

quackpit

A cockpit above your Claude Code prompt. The duck reads the gauges.

Version 0.1.0 Claude Code 2.1.296+ Model tokens: 0 by default License: MIT

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.

quackpit's band in a real Claude Code session: Claude runs the unit tests and the duck wakes with "Quack: tests failed"; Claude fixes sum.js, the tests pass and the duck goes back to sleep; then Claude sends the full suite to the background and ends its turn, and the duckling crosses the pond toward the flag with the suite's time left, until its notice comes back and the checks tile says it passed

<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>

Contents

Why quackpit

  • One panel, not a dozen mods. Progress, pace, context, limits, checks and cost sit side by side as tiles you can add, remove and reorder.
  • Knows how long. quackpit estimates how long the work in hand will take from measured lengths: Claude's task list, the subagents it started and the background jobs it will come back to, ending at the latest of them. It shows the estimate as a duckling crossing a pond. Time spent waiting on you never counts, and the estimate keeps going after the turn ends while a background job runs. With nothing to measure, a long turn says how it compares with your usual turns. If you turn it on, Claude Haiku estimates the rest, and is asked again when the turn outruns its answer.
  • Free to run. By default it spends zero model tokens, makes no network requests and starts no processes. It adds nothing to the model's context. The opt-in AI ETA is the one exception, and it counts every token it spends.
  • Honest numbers. Readings come from Claude Code's own events and ledger. Forecasts come from real timestamps, are marked ~ 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.
  • Every number one press from its source. Press an instrument in the pane and its detail shows what it is made of: each check run with its failing lines, what fills the context source by source, the turns by cost, the readings behind each dial and their age.
  • A panel, not a page. The pane is views under a header that stays put, sized to the window, its instruments all one size; it keeps to the keyboard as well as the mouse.
  • Quiet by default. The duck stays asleep and the pane never opens by itself. Colour appears only when a reading crosses a threshold.
  • Plays well with others. quackpit keeps any other mod's band and draws its own rows beneath it, nearest the prompt.

Install

Requirements

  • Claude Code 2.1.296 or later, in a terminal or the desktop app. Mods (function hooks) are in early access, and their API can change between releases.

From the marketplace

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.

Update, disable, remove

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

From a clone, for one session

git clone https://github.com/imsungbin/quackpit-mod ~/projects/quackpit-mod
claude --plugin-dir ~/projects/quackpit-mod

The band

The band in a real session while Claude runs the tests: what the turn is doing and its count of tools, the pace an hour, the context and how far auto-compact is, the latest check and the session's cost

Six tiles are on after install, in this order:

TileWhat it showsWhere the number comes from
progressWith 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.
paceNew 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.
contextHow 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.
limitsThe 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.
checksThe 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.
costThe 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 ✓.

How long it will take

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=1 in its environment (or in the env of your settings.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, TaskUpdate and TaskList, or TodoWrite.

The work in hand is everything Claude will finish or come back to, and each part has a measured length:

  • Tasks run one after another, each at the length a task is expected to take: the median of this list's finished tasks once two are done, and before that the median of your recent tasks (this session's earlier lists and your sessions of the last week).
  • Subagents Claude started, in the foreground or the background, run to the usual length of their type (Explore, general, and so on), from their finished runs. A type that has never finished runs to the usual length of all subagent runs, and says so; with no runs at all, it is listed as running with no expected end and adds nothing.
  • Background jobs. A command Claude runs in the background runs to the median of its last runs in this project (one is enough), else, for a test, typecheck, lint or build, to that kind of check's usual length. A dev server, which has neither, is listed as running and adds nothing; after its first 30 minutes it gives the headline back and runs on as one quiet line (pnpm dev · background 2h05). A monitor or a workflow Claude starts is followed from its call.
  • The AI's answer, if you turned the AI ETA on, covers the rest of the turn.

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.

The pane while Claude waits on a question in a real session: "Asking you: Which colour should I use, red or blue?", the duck holding still on its pond beside "⏸ waiting on you · 1m01" and "paused at ~1m left", a line saying nothing counts down until you answer, the AI's guess the pause holds, and the two dials under it

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 band after the turn ended, while the full test suite runs in the background: "node --test · background" with the duckling crossing toward "~1m left", and the checks tile saying "◌ background tests · <1m" with "~1m left" under it

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.

The band in a real session with the task tools turned on (CLAUDE_CODE_ENABLE_TODO_TOOLS=1): the list at 3/5 with "Build the project" running, the duckling crossing toward the flag with about 1 minute left, and the latest tests passing

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.

The AI ETA (opt-in)

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.

A long turn with no task list: "Run wait.js with 60 in the foreground", Claude's own description of its command, and the duckling well across the pond with "~1m left (AI, revised)": Claude Haiku's second answer, asked after a question was answered

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.

  • Its limits: 4 calls a turn at most. The second waits at least 2 minutes after the first, the third 4 minutes after the second, the fourth 8 minutes after the third. No call goes out while another is out or while Claude waits on you; the turn's end cancels one not yet answered, and an answer made for a state the turn has since left (you were asked something meanwhile, or the work changed shape) is dropped.
  • What it sends: the prompt the turn began with (credentials masked, long text cut to its head and tail), what Claude has said so far this turn (its stated plan), the task list if there is one, how long the turn has worked, and your usual turn lengths for calibration; on a re-ask, also its earlier answers and what happened since. Nothing else: no files, no transcript.
  • What it costs: each call is one $.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.
  • What it never does: it is a completion of its own, so nothing is added to your conversation's context, and it never forks your session.
  • How good it is: when a turn it answered ends, quackpit compares each answer with the time that was really left, beside the history-only estimate made at the same moment (the median of your recorded turns that ran longer). First answers and revisions are scored apart, since a revision comes later with less left to get wrong. Once 20 first answers are scored, the pane shows how often each landed within 2×. Only numbers are kept, never the prompt or the reply.

Its reply must be a number alone; anything else draws nothing.

The duck

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:

CauseWhen
A failed checkThe latest run of a test, typecheck, lint or build failed.
The 5h windowIt reaches 80% and will not last to its reset. A window that resets within 30 minutes and will last until then raises nothing.
The contextIt reaches 90% of the way to the auto-compact point (Quack: 11k to compact).
A runaway turnA turn costs over 3× the session's median turn, after 5 turns.

The duck awake beside "Quack: tests failed" while the task list is at 1/5 with "Fix the bug in sum.js" next, and the duckling sets off across the pond

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.

The pane

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

The overview in a real session, docked beside the transcript: on top the work in hand, here the full test suite running in the background with the duck crossing toward its flag and the time left; under it eight instruments in pairs, the pace dial and the five-hour fuel gauge, the context as one bar coloured by source with its three largest named under it and the cost of each turn, the checks by kind and the edits per file, health's dial and the session

The overview

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.

InstrumentIts graphicIts words
PACE, this sessionA 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

Source 53 files
hooks/register.tsx 2144 lines
1// 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 lines
1// 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}
332
src/cache.ts 51 lines
1// 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}
51
src/checks.ts 366 lines
1// 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}
366
src/commands.ts 92 lines
1// `/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}
92
src/derive.ts 290 lines
1// 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}
290
src/duck.ts 173 lines
1// 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
173
src/format.ts 182 lines
1// 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}
182
src/gauges.ts 231 lines
1// 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
231
src/context.ts 656 lines
1// 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}
656
src/handover.ts 165 lines
1// 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}
165
src/hud.ts 82 lines
1// 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