SLOPSHOPPER

track

A pane beside the transcript: the questions you asked (jump to where each was asked and answered) and the steps the model set itself, with completion rings

newpanebandspinnerrowsguard
v0.1.0no licenseupdated 2026-10-08ohade/claude-mods/track
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · track
│ ┃ Session Tracker ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ─────────────────── SESSION TRACKER ──────── │ track │ │ ┃ ● track: track: unsave│ track: unsaved — undefined is not an │ │ ┃ Questions ○ — ● track: track: unsave│ object (evaluating 'stream.result.catch') │ │ ┃ None yet. ⏺ Read(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines ╭────────────────────────────────────────────╮ │ ┃ ──────────── ⏺ Update(src/auth.ts) │ track │ │ ┃ Steps ○ — ⎿ Added 2 lines, re│ track: unsaved — undefined is not an │ │ ┃ None yet. ⏺ Bash(bun test) │ object (evaluating 'stream.result.catch') │ │ ┃ ⎿ 3 pass, 1 fail ╰────────────────────────────────────────────╯ │ ┃ c: clear completed /track hides · ctrl+x x │ ┃ Unsaved ⟨Claude Code's own drawing⟩ │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /track │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session Tracker
─────────────────── SESSION TRACKER ──────────────────── Questions ○ — None yet. ──────────────────────────────────────────────────────── Steps ○ — None yet. c: clear completed /track hides · ctrl+x x closes for good Unsaved
Claude's reply
⟨Claude Code's own drawing⟩
README

claude-mods

Personal customizations for Claude Code: function-hook plugins ("mods") and the status line.

Get them with git clone https://github.com/ohade/claude-mods.git; each section says how to load its mod.

image-thumbs

Shows each image you paste into a prompt as a small framed thumbnail under your message.

  • Click the picture to expand it in place, at full resolution, up to 24 rows tall. Click again to shrink it.
  • open in pane under the frame opens the image in a pane. The pane docks beside the transcript in the fullscreen layout when the terminal is at least 110 columns wide; otherwise it opens above the prompt.
  • /image [n] opens image #n, or the latest one, in the same pane.

Requirements: macOS (the mod uses sips and base64), Claude Code 2.1.289 or later with function-hook plugins, and a terminal that draws images with the kitty graphics protocol (Ghostty, kitty). Elsewhere the thumbnail shows its [Image #n] label instead.

Limits: thumbnails appear after you submit the prompt, not while you type. Images in a resumed session get no thumbnail. The decoded originals live in a private folder under $TMPDIR and are deleted when the session ends.

Load it in every session with one of:

ln -s ../../git/claude-mods/image-thumbs ~/.claude/skills/image-thumbs   # skills folder
claude --plugin-dir ~/git/claude-mods/image-thumbs                     # one session

or list the folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Check it with claude plugin validate image-thumbs, and type-check it with tsc -p image-thumbs once Claude Code has loaded it (loading writes the API types into .claude-plugin/types/).

track

A pane beside the transcript, titled Session Tracker, that keeps substantive questions, their answers, and meaningful work. Claude decides what to register from any sender, including later content in a running turn. Doorbells and informational notifications alone need no row. Track has no transport-specific workflow.

  • Questions. The model sends each question to the pane with track_question and closes it with mark_answered (answered or deferred); a standing rule in the system prompt asks it to. A question with a verified user source links to that source. Otherwise its tracking call is the visible source. The answer update draws one dim ✓ Q<n>. answered line. An answered question turns green. The instruction explicitly includes short follow-up questions before answering; a row still requires Claude to register it.
  • Jump. The fixed [ Q ] and [ A ] columns scroll to the verified source and answer. The row you land on lights up and fades out over about two seconds: your prompt or the actual answer text alone. The acknowledgement is a separate row. [ A ] stays faded until captured answer text exists; an acknowledgement or an older answer with no saved text does not make it ready. Question and step labels use Q1. and S1.. The digits 1–9 press the jumps while the pane has focus (ctrl+x tab).
  • Layout. The title stays at the top and the banner at the bottom. Questions and Steps are fixed regions, about a third and two thirds; each scrolls on its own under the wheel, and its header counts the rows hidden above and below (↑2 ↓5). Empty sections say only "None yet." Below 40 body columns, counts use 12/39, questions place their Q/A controls below the text, and step clocks use a separate line. Short controls and hints fit a 17-column body. The banner keeps the state on one line and omits background counts when space is tight. Claude Code owns the draggable transcript/dock divider. Its 2.1.293 plugin API exposes no hover mouse-cursor control for that divider; Track cannot set a horizontal resize cursor there.
  • Banner. A full-width colored line at the bottom says where the session stands: Working, Waiting on agents (an Agent call or background agents), Waiting on tasks (background shell tasks), Waiting on you (a question dialog or an explicit waiting step), Paused, Activity unknown, Unsaved, or Idle. Use waiting only when the user must act; peer waits use paused or pending with an optional note. mark_step({ delegated: true }) identifies work owned by agents, including agents launched through other tools. It shows the brown hourglass and agents banner. Use delegated: false when the main session resumes that step. This is explicit ownership, not a peer-liveness probe. Completed steps clear it. Concurrent main work stays grey on its own row. Explicit ownership leaves agent counts unknown; only native-only activity supplies a count.
  • Steps. Filled from the model's own TaskCreate, TaskUpdate, TodoWrite and explicit track_steps calls. Successful plan approval adds one reminder to reuse open steps and register missing work. It leaves the entire register unchanged. A Task named like a plan step links to it. The step in progress shows who is on it: a spinner breathing in grey while the main session works, an amber hourglass while it waits on agents, a still purple ◆ while it waits on you.
  • Rings. ◑ 4 of 8 · 50% per section, counted over visible rows. Clear completed (c) hides finished rows and removes them from those counts.
  • Clear all. q and s empty the Questions or the Steps section; cleared open questions are withdrawn, so the model is told not to answer them. The Steps controls (s: clear all, c: clear completed) appear twice, in the Steps header and in the bottom bar, and neither moves when the steps scroll.
  • Chat plans. When the model starts work of more than one step (a skill such as /retro, a plan in chat, a multi-step task), it registers the steps with track_steps and ticks them with mark_step. Work that joins a running plan, such as review comments from Plannotator, is inserted with track_steps({ steps, after }) after the step it follows. While a managed plugin bypasses the system-prompt rule, each typed prompt, skill command and plugin prompt carries the instruction beside it; built-in commands do not. After reload, only the exact current composed instruction suppresses this fallback. A legacy boolean delivery flag is insufficient.
  • Handoffs. A handoff that clears the session and seeds a fresh one leaves the pane empty. restore_tracker({ from_session }) copies the previous session's steps back, in order, with their ids and statuses, and its questions not cleared, with new ids after this session's own. Task ids start again in each session, so a restored Task step's id gains restored: and keeps no link to the old Task. When the model marks a question answered, the mod keeps the answer's text (up to 1,000 characters), and the restore call's row in the transcript shows each restored question with its answer, its deferral note, or "(answer text was not saved)" for one answered before answers were kept. [ Q ] and [ A ] target their own question or answer inside that row. The call refuses to overwrite unrelated steps unless replace: true is passed. Repeated restoration reuses stable source identities and keeps local progress. A model call supplies its displayed restore row; a programmatic call first appends and validates a visible system snapshot. A refused or altered snapshot leaves the ledger unchanged. Repeat calls reuse its native message UUID. A matching saved snapshot receives separate keyed targets through the native InfoNotice render hook. Active target records stay while their questions are uncleared; only inactive snapshot history is capped at three. Store capacity failures remain visible.
  • Withdraw. ✕ removes a question; your next prompt tells the model not to answer it.
  • Nag. While a question is open, each prompt carries a one-line reminder; a Stop hook holds a turn once if a question the model tracked in that turn is neither answered nor deferred. Track publishes its own small gate snapshot for its ordinary command Stop hook; it does not use handoff's relay. Existing Stop blocks are preserved. A managed policy can deny or bypass plugin capabilities; such a denial stays unresolved and does not count as equivalent Stop behavior.
  • Rewind and resume. A /rewind drops the questions asked in the rewound turns. Each finished turn saves the register, and native session startup loads it for /resume. Rewind observations are coalesced and fenced to their session; compaction and capped transcript reads do not imply that older calls were rewound. A refused rewind save holds the prompt until the save can succeed. Answer jumps use event order within the question's actual tracking turn. Text before the tracking call cannot become its answer, even when wall-clock timestamps are equal. A later turn may answer an older question. A mark before text binds the next response only when one question is pending. An explicit answer_request_id must match the latest host-observed response; unknown sources are refused. An already captured answer stays unchanged. Step updates name the affected step.

It opens by itself by the built-in diff panel's rule, less the git condition: in the fullscreen layout, at least 144 columns wide, and never after you closed it by hand (ctrl+x x). A reload of the mod leaves an open pane open. /track shows or hides it at any width, and like /btw it acts at once while a turn runs and adds nothing to the session; /track status prints the open questions. To keep the diff panel out of the slot, type /diff once.

Requirements: Claude Code with function-hook plugins, macOS/POSIX file locks, and Python 3. The first compatibility target is the generated 2.1.292 API. Engine and fresh-process checks use 2.1.293. A 2.1.292 runtime run and a seated managed-policy Stop run are not available on this Mac.

Load it from the clone folder (cd claude-mods), wherever it sits, with one of:

mkdir -p ~/.claude/skills && ln -s "$PWD/track" ~/.claude/skills/track   # every session
claude --plugin-dir "$PWD/track"                                         # one session

or list the clone's track folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Check it with claude plugin validate track, test it with claude plugin test track, and type-check it with tsc -p track once Claude Code has loaded it: track/tsconfig.json extends the API types that loading writes into track/.claude-plugin/types/.

What it saves: acknowledged tracking mutations, captured answers, explicit UI actions, completed turns, and session end write through the existing $.store API and verify the result. Save failures remain visibly Unsaved. Rendering performs no persistence. For each session the store holds:

  • the first line of each prompt you type, without image tags, cut to 200 characters, with its time; prompts that start with / are left out; the last 200 prompts;
  • each tracked question as the model summed it up, cut to 200 Unicode code points, with its status, optional note, stable source identity, and answer text (up to 1,000 code points); up to 200;
  • the title and status of each step, cut to 200 characters; the last 300.

The store budgets serialized UTF-8 conservatively below its 4 MiB limit. It prefers 20 sessions and a 3 MiB budget, pruning cleared/completed history first. Unfinished work is never silently discarded. When safe pruning cannot make room, the save returns a visible capacity failure. One process owns a session writer lease, and a shared lock serializes store writes. Stale revisions cannot overwrite a newer durable ledger. Beside the buckets it holds an index and one flag: set when you close the pane by hand, cleared when /track shows it again. Explicit pane choices use the same owned, verified save queue. A refused preference remains unsaved in reload-persistent state and is retried by the next acknowledged save. The mod makes no network calls of its own; what it tells the model, such as tool results and the open-question reminder, goes with the rest of the conversation.

Use one canonical loading path on a machine: the plugin's loading identity selects its store. For a path change, retain both legacy stores and export each with /track export <absolute-path>. Load the destination alone and use /track import <absolute-path>. Import checks the bundle checksum, rejects conflicting session records before writing, and reads back every copied record. Export and import join the save queue, require writer ownership, and reconcile pending save recovery under the shared store lock before reading records. Repeated imports are safe. Do not retire a legacy store until its exact records have been verified.

Local checkpoint/restore contract v1

mcp__track__checkpoint({ expected_session }) saves and reads back the current ledger. It returns JSON as tool-result text, with v, ok, source_session, revision, checksum, and counts: { questions, answers, steps }; failures include reason.

mcp__track__restore_tracker({ from_session, replace?, expected_checkpoint? }) validates an expected checkpoint before restoring. Its receipt also names destination_session and applied_checksum, calculated from the actual destination questions, answer text, notes, and steps in source order, including explicit delegated ownership. The optional true field participates in the checksum; absent fields preserve prior v1 checksums. A successful attempted call alone does not prove complete restoration. Source links and display ids are not authority. Track works independently of handoff.

Run claude plugin test track for engine FIXTURE checks and, from the Track root, python3 -m unittest discover -s tests/helpers -p 'test_*.py' for real helper locks and Stop parsing. Fresh-process acceptance remains separate. The test-only tests/fixtures/performance-probe plugin offers /trackbench <absolute-private-output-path> for 500 runtime-selected native tool updates; its receipts do not measure physical terminal paint. Use matching pane geometry for render comparisons.

Remove it: delete the symlink (rm ~/.claude/skills/track), or take the folder out of CLAUDE_CODE_PLUGIN_DIRS. The saved register stays behind in Claude Code's plugin store.

statusline

A two-line status line: model and effort, folder and branch, prompt-cache state, context use, and live 5-hour and weekly quota. usage-live.py reads Claude Code's OAuth credential from the macOS Keychain to fetch the quota; it never prints or stores the token.

Install it, which copies the scripts into ~/.claude and points statusLine in ~/.claude/settings.json at them after a backup:

./statusline/install.sh

statusline/README.md has the details. This folder was ohade/claude-statusline-setup, merged here with its history on 2026-10-04.

Source 3 files
hooks/register.tsx 2466 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, RenderElement, Timer } from 'claude-code'
3
4import type { Activity, Ledger, Pane, Prompt, Question, Restore, ScrollAt, Step, Turn } from '../types'
5import { appliedChecksum, checkpointFailure, checksumOf, describeCheckpoint, matchesCheckpoint } from './checkpoint'
6import type { Checkpoint } from './checkpoint'
7
8const PANE = 'track'
9// The pane's name: its tab label, and its first line, since a lone pane shows no tab.
10const TITLE = 'Session Tracker'
11const PANE_COLUMNS = 48
12// Rows the pane asks for when it sits inline above the prompt (main screen or narrow terminal).
13const PANE_ROWS = 16
14// The rewind check a prompt-hint redraw schedules: run after this delay, at most once per gap.
15// Module memory, not $.state: a reload only resets the debounce.
16const REWIND_CHECK_DELAY_MS = 1000
17const REWIND_CHECK_GAP_MS = 3000
18const rewindCheck = { isScheduled: false, lastAt: -Infinity, running: undefined as Promise<boolean> | undefined }
19// The built-in diff panel's rule, less its git condition (the tracker does not need git, and a
20// session often starts outside a repository): it opens by itself only from this width, in the
21// fullscreen layout, and never after the person closed it by hand.
22const AUTO_OPEN_MIN_COLUMNS = 144
23const AUTO_OPEN_DELAY_MS = 50
24const autoOpen = { isScheduled: false }
25// Saved registers kept across sessions, the newest first; older buckets are deleted. The store
26// holds 4 MiB of JSON in all, so the buckets keep under STORE_BUDGET UTF-8 bytes together, which
27// leaves room for the index and the closed-by-hand flag.
28const MAX_SESSIONS = 20
29const STORE_BUDGET = 3 * 1024 * 1024
30// The store key of the buckets' index: per session id, when its bucket was saved and its size.
31const SAVED_INDEX = 'saved'
32const TRACK_QUESTION = 'mcp__track__track_question'
33const MARK_ANSWERED = 'mcp__track__mark_answered'
34const MARK_STEP = 'mcp__track__mark_step'
35const TRACK_STEPS = 'mcp__track__track_steps'
36const RESTORE_TRACKER = 'mcp__track__restore_tracker'
37const CHECKPOINT = 'mcp__track__checkpoint'
38// A Claude Code session id; restore_tracker reads only the store key of a real one.
39const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
40const MAX_STEPS = 300
41const MAX_PLAN_STEPS = 30
42// paused: started, then parked; waiting: needs the person's answer.
43const STEP_STATUSES = ['pending', 'in_progress', 'completed', 'paused', 'waiting'] as const
44const QUESTION_STATUSES = ['open', 'answered', 'deferred'] as const
45
46// Caps: heads are short, lists are bounded, so the ledger stays small in $.state and $.store.
47// Long enough to keep a question whole; the pane wraps it rather than cutting it.
48const HEAD_CHARS = 200
49const MAX_PROMPTS = 200
50const MAX_QUESTIONS = 200
51// An answer is kept for the next session to read, cut to this many characters. With
52// MAX_QUESTIONS it bounds the answers in one register near 200 KB, well inside STORE_BUDGET.
53const ANSWER_CHARS = 1000
54// Recent inactive restore rows. A record that still owns a shown question's
55// target stays too; the question and store budgets bound those active copies.
56const MAX_RESTORES = 3
57// A restored question's turn: no turn of this session has it, so the Stop gate never holds a
58// turn for it and turn.start does not claim it.
59const RESTORED_TURN = 'restored'
60// Under a restored answered question whose answer was given before answers were kept.
61const NOT_SAVED = '(answer text was not saved)'
62const HOTKEYS = 9
63// Rows sit this many columns in under their section header, questions and steps alike.
64const ROW_INDENT = 2
65
66// How many open questions the per-turn context row names.
67const OPEN_LISTED = 5
68
69// A jump lights the row it lands on, then fades it: the background at each level, brightest
70// last, held FLASH_HOLD_MS at full and stepped down every FLASH_STEP_MS.
71const FLASH_SHADES = ['#2b2a1e', '#4a4120', '#6e5a1c'] as const
72const FLASH_HOLD_MS = 1200
73const FLASH_STEP_MS = 250
74// The fade's own timers, a generation per jump, and one queue every flash and lit write runs
75// through in order, so a fade step already under way cannot clear a newer jump's lit list.
76// Module memory, as a reload only cuts a fade short; session.start puts out what is left.
77const fade = { timers: [] as Timer[], generation: 0, queue: Promise.resolve() as Promise<void> }
78
79// The standing rule, sent once per request as a byte-stable system-prompt section.
80// The steps instruction, one wording for the standing rule, the per-prompt line and the tool.
81const STEPS =
82  'For work of more than one step (a skill or slash command such as /retro, a plan, a multi-step task), call mcp__track__track_steps with the steps before the first one; when new work joins a running plan (review comments, a follow-up), call it with `after` set to the id of the step the new ones follow. Mark each step with mcp__track__mark_step as you go: paused when you park it, waiting when it needs the user\'s answer.'
83
84const RULE = 'track: For meaningful work or substantive questions, regardless of sender, reuse open items or call mcp__track__track_steps / mcp__track__track_question for missing items before doing the work or answering, including short follow-up questions; insert additions with after. Update status with mcp__track__mark_step / mcp__track__mark_answered. This applies to later content in this turn. Doorbells and informational notifications alone need no row. Use waiting only for action required from the user; use paused or pending with a note for peer/background waits. Set mark_step delegated:true while agents own the work and delegated:false when you resume it.'
85
86// An organization's managed plugin can bypass prompt.compose (the debug log then reads "track:
87// prompt.compose bypassed by <plugin>"), and a /retro ran its steps unlisted. While the rule has
88// not reached the model this session, each prompt that can start work carries the steps
89// instruction beside it.
90const STEPS_LINE = RULE
91
92// The banner pinned at the bottom of the pane: what the session is doing, in one colored line.
93const BANNERS = {
94  working: { text: ' Working ', compact: 'Working', color: 'suggestion' },
95  agents: { text: ' Waiting on agents ', compact: 'Waiting agents', color: 'warning' },
96  tasks: { text: ' Waiting on tasks ', compact: 'Waiting tasks', color: 'warning' },
97  you: { text: ' Waiting on you ', compact: 'Waiting on you', color: 'permission' },
98  paused: { text: ' Paused ', compact: 'Paused', color: 'warning' },
99  unknown: { text: ' Activity unknown ', compact: 'Unknown', color: 'warning' },
100  unsaved: { text: ' Unsaved ', compact: 'Unsaved', color: 'warning' },
101  done: { text: ' Idle · Safe to close ', compact: 'Idle', color: 'success' },
102} as const
103// The step in progress breathes while work runs: one phase every PULSE_MS, a
104// spinner and grey shades while the main session works, an hourglass and amber shades while it
105// waits on agents. The phase is $.state, so each tick redraws the pane alone.
106const PULSE_MS = 400
107const PULSE_PHASES = 12
108const SPINNER = ['◐', '◓', '◑', '◒'] as const
109const GREY_SHADES = ['#5f6670', '#7a828c', '#979fa9', '#b4bcc6', '#979fa9', '#7a828c'] as const
110const AMBER_SHADES = ['#7a5410', '#9a6c16', '#bb861d', '#dba126', '#bb861d', '#9a6c16'] as const
111const pulsing: { timer?: Timer } = {}
112// Each step row ends in its wall clock. It moves every second under an hour, then once a minute.
113const TICK_MS = 1000
114const HOUR_MS = 3_600_000
115const ticking: { timer?: Timer } = {}
116
117// The pane's layout: the title pinned at the top, the banner and footer pinned at the bottom,
118// and between them Questions and Steps, each a fixed region that scrolls alone. Questions get
119// about a third of the room; room one side does not need goes to the other. `regions` is where the last drawing put each region, for routing a wheel tick.
120const QUESTION_SHARE = 0.35
121// Columns a question row spends on its dot, its [ Q ] [ A ] buttons and ✕.
122const QUESTION_CHROME = 18
123// Columns between the items of a header or bar; a narrow pane wraps between items, never inside one.
124const HEADER_GAP = 2
125// Below this width the Q/A columns crowd out the question; place them on the next line.
126const COMPACT_COLUMNS = 40
127const HINT = '/track hides · ctrl+x x closes for good'
128const NONE_YET = '  None yet.'
129const ALL_CLEARED = '  all cleared'
130const regions = { qTop: 0, qBottom: 0, sTop: 0, sBottom: 0, qStart: 0, sStart: 0, qLast: 0, sLast: 0, last: 'steps' as 'questions' | 'steps' }
131// Fast wheel ticks are summed and written once per SCROLL_FLUSH_MS, so a quick flick costs one
132// state write and one redraw, not one per tick (one write per tick made the pane lag).
133const SCROLL_FLUSH_MS = 30
134const pendingScroll: { questions: number; steps: number; timer?: Timer } = { questions: 0, steps: 0 }
135// A stored position never runs past the last one that shows anything (observed: 733 for 20 steps).
136const clampTo = (last: number, value: number): number => Math.min(last, Math.max(0, value))
137
138// The rows from `start` whose lines fit `rows`: [start, end).
139const windowOf = (lines: number[], rows: number, start: number): [number, number] => {
140  let end = start
141  let used = 0
142  while (rows > 0 && end < lines.length && used + (lines[end] ?? 1) <= rows) {
143    used += lines[end] ?? 1
144    end++
145  }
146
147  return [start, rows > 0 && end === start && start < lines.length ? start + 1 : end]
148}
149
150// The first row from which the list's last rows fill `rows`.
151const lastStart = (lines: number[], rows: number): number => {
152  let start = lines.length
153  let used = 0
154  while (start > 0 && used + (lines[start - 1] ?? 1) <= rows) {
155    used += lines[start - 1] ?? 1
156    start--
157  }
158
159  return Math.min(start, Math.max(0, lines.length - 1))
160}
161
162// The newest `max` rows. Room is made from the oldest rows already cleared, then the oldest done
163// ones. Unfinished work is never removed to make room.
164const capRows = <T extends { cleared?: true }>(rows: T[], max: number, isDone: (row: T) => boolean): T[] => {
165  let excess = rows.length - max
166  if (excess <= 0) {
167    return rows
168  }
169  const dropped = new Set<T>()
170  for (const isSpent of [(row: T) => row.cleared === true, isDone]) {
171    for (const row of rows) {
172      if (excess > 0 && !dropped.has(row) && isSpent(row)) {
173        dropped.add(row)
174        excess--
175      }
176    }
177  }
178
179  return rows.filter(row => !dropped.has(row))
180}
181
182const capSteps = (steps: Step[]): Step[] => capRows(steps, MAX_STEPS, s => s.status === 'completed')
183
184// A step at a new status. Its clock starts the first time it goes in progress and stops when it
185// is done; a done step that is opened again runs on from its first start.
186const withStatus = (s: Step, status: Step['status'], now: number): Step => {
187  const { endedAt: _ended, delegated, ...rest } = s
188  const startedAt = s.startedAt ?? (status === 'in_progress' ? now : undefined)
189  const endedAt = status !== 'completed' || startedAt === undefined ? undefined : s.status === 'completed' && s.endedAt !== undefined ? s.endedAt : now
190
191  return { ...rest, status, ...(delegated === true && status !== 'completed' && status !== 'waiting' && { delegated: true as const }), ...(startedAt !== undefined && { startedAt }), ...(endedAt !== undefined && { endedAt }) }
192}
193
194// A stretch of wall-clock time: m:ss under an hour, then 1h 05m.
195const clockText = (ms: number): string => {
196  const total = Math.max(0, Math.floor(ms / 1000))
197  const hours = Math.floor(total / 3600)
198  const minutes = Math.floor((total % 3600) / 60)
199
200  return hours > 0 ? `${hours}h ${String(minutes).padStart(2, '0')}m` : `${minutes}:${String(total % 60).padStart(2, '0')}`
201}
202
203// The shown steps whose clock runs: started, not done.
204const runningClocks = (l: Ledger): Step[] => l.steps.filter(s => s.cleared !== true && s.startedAt !== undefined && s.endedAt === undefined)
205
206// Lines `text` takes word-wrapped at `width` columns, as the terminal wraps it: a word that does
207// not fit starts a new line, and a word longer than a line is broken.
208const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' })
209const graphemesOf = (text: string): string[] => Array.from(segmenter.segment(text), part => part.segment)
210// Conservative terminal widths: emoji clusters and East Asian wide glyphs use
211// two columns. Combining marks stay with their base through Intl.Segmenter.
212const columnsOf = (text: string): number => graphemesOf(text).reduce((sum, cluster) => sum + (
213  /^[\p{Mark}\p{Cf}]+$/u.test(cluster) ? 0
214    : /[\p{Extended_Pictographic}\p{Regional_Indicator}\u1100-\u115f\u2329\u232a\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff01-\uff60\uffe0-\uffe6]/u.test(cluster) ? 2 : 1
215), 0)
216const wrappedLines = (text: string, width: number): number => {
217  let lines = 1
218  let used = 0
219  for (const word of text.split(' ')) {
220    const columns = columnsOf(word)
221    if (used > 0 && used + 1 + columns <= width) {
222      used += 1 + columns
223    } else if (columns > 0) {
224      const extra = Math.floor((columns - 1) / width)
225      lines += (used > 0 ? 1 : 0) + extra
226      used = columns - extra * width
227    }
228  }
229
230  return lines
231}
232
233// Rows a header or bar takes when its items wrap at `width`: each item stays whole, `gap` apart.
234const flowRows = (items: number[], width: number, gap: number): number => {
235  let rows = 1
236  let used = 0
237  for (const item of items.filter(w => w > 0)) {
238    if (used > 0 && used + gap + item > width) {
239      rows++
240      used = item
241    } else {
242      used = used === 0 ? item : used + gap + item
243    }
244  }
245
246  return rows
247}
248
249// The columns a button takes: the engine draws a hotkey button as "q: label".
250const buttonWidth = (hotkey: string, label: string): number => hotkey.length + 2 + label.length
251
252// A row's text, cut with an ellipsis to the lines it may take: for a row taller than its whole
253// region, whose last lines no scroll could reach.
254const fitLines = (text: string, width: number, lines: number): string => {
255  const most = Math.max(1, lines)
256  if (wrappedLines(text, width) <= most) {
257    return text
258  }
259  const clusters = graphemesOf(text)
260  let cut = Math.min(clusters.length, Math.max(1, width * most - 1))
261  while (cut > 0 && wrappedLines(`${clusters.slice(0, cut).join('').trimEnd()}…`, width) > most) {
262    cut--
263  }
264
265  return `${clusters.slice(0, cut).join('').trimEnd()}…`
266}
267
268const hidden = (above: number, below: number): string =>
269  [above > 0 ? `↑${above}` : '', below > 0 ? `↓${below}` : ''].filter(Boolean).join(' ')
270
271// A background task's id in its notification text.
272const TASK_ID = /<task-id>([^<]+)<\/task-id>/g
273// A background task Stop lists that is an agent: a subagent or a workflow of them. The rest (a
274// shell, a monitor) is a task, so a hung shell never reads as an agent the person waits on.
275const AGENT_TASK = /agent|workflow/i
276
277const EMPTY_LEDGER: Ledger = { v: 1, nextQuestionId: 1, prompts: [], questions: [], steps: [] }
278
279const ledger = atom({ plugin: 'track', key: 'ledger' } as const, EMPTY_LEDGER)
280const turn = atom({ plugin: 'track', key: 'turn' } as const, { currentId: null, gatedTurnId: null } as Turn)
281const pane = atom({ plugin: 'track', key: 'pane' } as const, { isOpen: false, hidden: false, closedByPerson: false } as Pane)
282// One level per transcript row (by its requestId, or `text:` and a key for an answer's text):
283// 0 unlit, up to FLASH_SHADES.length at full. `lit` names the rows a jump lit.
284const flash = atom({ plugin: 'track', key: 'flash' } as const, 0)
285const lit = atom({ plugin: 'track', key: 'lit' } as const, [] as string[])
286const IDLE: Activity = { isWorking: false, agentCalls: [], askCalls: [], background: [], tasks: [] }
287const activity = atom({ plugin: 'track', key: 'activity' } as const, IDLE)
288const pulse = atom({ plugin: 'track', key: 'pulse' } as const, 0)
289const tick = atom({ plugin: 'track', key: 'tick' } as const, 0)
290// Each region's first shown row; null follows the news (the newest question, the step at work).
291const scrollAt = atom({ plugin: 'track', key: 'scroll' } as const, { questions: null, steps: null } as ScrollAt)
292
293// The transcript draws a prompt row under the stored row's id with its last group zeroed
294// (observed on 2.1.289, see image-thumbs), so both sides key on the first four groups.
295// The UserMessage render later writes the real requestId over this provisional key.
296const rowKey = (id: string): string => id.split('-').slice(0, 4).join('-')
297
298// The first line of a prompt, without image tags, cut to HEAD_CHARS.
299const headOf = (text: string): string => {
300  const line = text.replace(/\[Image #\d+\]/g, '').trim().split('\n')[0] ?? ''
301
302  return truncate(line, HEAD_CHARS)
303}
304
305// "1 step", "2 steps".
306const counted = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
307
308// A row's text blocks, joined.
309const textOf = (content: ReadonlyArray<{ type: string; text?: unknown }>): string =>
310  content.map(block => (block.type === 'text' ? String(block.text) : '')).join('\n')
311
312// The engine's words around a message typed mid-turn, if the delivered row carries them; they
313// are not the person's, and would hide a slash command from the check on the head.
314const MIDTURN_FRAME = /^\s*(<system-reminder>\s*)?The user sent a new message while you were working:\s*/
315
316const truncate = (text: string, width: number): string => {
317  const points = Array.from(text)
318  return points.length > width ? `${points.slice(0, Math.max(1, width - 1)).join('')}…` : text
319}
320
321// Ring glyph for a completion fraction; `○ —` when there is nothing to count.
322const ring = (done: number, total: number): string => {
323  if (total === 0) return '○ —'
324  const pct = Math.round((100 * done) / total)
325  const glyph = pct >= 100 ? '●' : pct >= 75 ? '◕' : pct >= 50 ? '◑' : pct >= 25 ? '◔' : '○'
326
327  return `${glyph} ${done} of ${total} · ${pct}%`
328}
329
330// A title for matching: lowercase, without a leading number or checkbox, punctuation, or
331// repeated spaces. A Task links to the plan step whose normalized title is the same.
332const norm = (title: string): string =>
333  title
334    .toLowerCase()
335    .replace(/^\s*(?:\d+[.)]|[-*+]\s*\[[ x]\])\s*/, '')
336    .replace(/[^\p{L}\p{N}\s]/gu, '')
337    .replace(/\s+/g, ' ')
338    .trim()
339
340
341const statusGlyph = (q: Question): string => (q.status === 'answered' ? '●' : q.status === 'deferred' ? '◌' : '○')
342
343const shade = (level: number): string | undefined => (level > 0 ? FLASH_SHADES[Math.min(level, FLASH_SHADES.length) - 1] : undefined)
344
345const light = ($: EngineInterface, id: string, level: number) => update($, memberOf(flash, { requestId: id }), () => level)
346
347const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error))
348
349const isDefined = <T,>(value: T | undefined): value is T => value !== undefined
350
351// `{ [key]: value }` when value is a string, else nothing: an optional text field of a saved row.
352const textField = <K extends string>(key: K, value: unknown): Partial<Record<K, string>> =>
353  typeof value === 'string' ? ({ [key]: value } as Record<K, string>) : {}
354
355// Only an explicit waiting step or question dialog asks the person to act.
356// Incomplete work without current runtime activity is paused or unknown.
357const sessionState = (l: Ledger, now: Activity): keyof typeof BANNERS => {
358  const steps = l.steps.filter(s => s.cleared !== true)
359  const unfinished = l.questions.some(q => q.cleared !== true && q.status !== 'answered') || steps.some(s => s.status !== 'completed')
360  if (now.askCalls.length > 0 || steps.some(s => s.status === 'waiting')) return 'you'
361  if (now.agentCalls.length > 0) return 'agents'
362  const delegated = steps.some(s => s.delegated === true && s.status !== 'completed')
363  if (delegated && !(now.isWorking && steps.some(s => s.status === 'in_progress' && s.delegated !== true))) return 'agents'
364  if (now.isWorking) return 'working'
365  if (now.background.length > 0) return 'agents'
366  if ((now.tasks ?? []).length > 0) return 'tasks'
367
368  if (steps.some(s => s.status === 'in_progress')) return 'unknown'
369  return unfinished ? 'paused' : 'done'
370}
371
372// Waiting on agents or tasks always pulses (the banner blinks amber); the main session's work
373// pulses only the step in progress, so with none there is nothing to animate.
374const isPulsing = (l: Ledger, now: Activity): boolean => {
375  const state = sessionState(l, now)
376
377  return state === 'agents' || state === 'tasks' || (state === 'working' && l.steps.some(s => s.status === 'in_progress' && s.cleared !== true))
378}
379
380// Started from the pane's drawing when a step should pulse; each tick stops it once nothing does.
381const startPulse = ($: EngineInterface): void => {
382  pulsing.timer = $.clock.every(PULSE_MS, () => {
383    void (async () => {
384      if (!isPulsing(await read($, ledger), await read($, activity))) {
385        pulsing.timer?.cancel()
386        pulsing.timer = undefined
387
388        return
389      }
390      await update($, pulse, n => (n + 1) % PULSE_PHASES)
391    })().catch(error => $.ui.log(`track: pulse tick failed: ${reason(error)}`, { to: 'debug' }))
392  })
393}
394
395// Started from the pane's drawing while a step's clock runs; each tick stops it once none does.
396// Past an hour every running clock shows minutes, so a tick writes only when the minute turns.
397const startTick = ($: EngineInterface): void => {
398  ticking.timer = $.clock.every(TICK_MS, () => {
399    void (async () => {
400      const running = runningClocks(await read($, ledger))
401      if (running.length === 0) {
402        ticking.timer?.cancel()
403        ticking.timer = undefined
404
405        return
406      }
407      const now = await $.clock.now()
408      const last = await read($, tick)
409      const everyMinute = running.every(s => now - (s.startedAt ?? now) >= HOUR_MS)
410      if (everyMinute && Math.floor(now / 60_000) === Math.floor(last / 60_000)) {
411        return
412      }
413      await update($, tick, () => now)
414    })().catch(error => $.ui.log(`track: clock tick failed: ${reason(error)}`, { to: 'debug' }))
415  })
416}
417
418// Runs a flash or lit write after every write queued before it.
419const serially = (op: () => Promise<void>): Promise<void> => {
420  const run = fade.queue.then(op)
421  fade.queue = run.catch(() => undefined)
422
423  return run
424}
425
426// Lights the rows a jump lands on and fades them out; a new jump puts out the last one first.
427// A fade step of an older jump finds a newer generation and leaves the rows to it.
428const flashRows = async ($: EngineInterface, ids: string[]): Promise<void> => {
429  for (const timer of fade.timers) timer.cancel()
430  fade.timers = []
431  const generation = ++fade.generation
432  await serially(async () => {
433    const before = await read($, lit)
434    await update($, lit, () => ids)
435    await Promise.all([...before.filter(id => !ids.includes(id)).map(id => light($, id, 0)), ...ids.map(id => light($, id, FLASH_SHADES.length))])
436  })
437  if (fade.generation !== generation) {
438    return
439  }
440  for (let level = FLASH_SHADES.length - 1; level >= 0; level--) {
441    const at = FLASH_HOLD_MS + (FLASH_SHADES.length - 1 - level) * FLASH_STEP_MS
442    fade.timers.push(
443      $.clock.after(at, () => {
444        void serially(async () => {
445          if (fade.generation !== generation) return
446          await Promise.all(ids.map(id => light($, id, level)))
447          if (level === 0 && fade.generation === generation) await update($, lit, () => [])
448        }).catch(error => $.ui.log(`track: fade to level ${level} failed: ${reason(error)}`, { to: 'debug' }))
449      }),
450    )
451  }
452}
453
454// Scrolls the transcript to the first target row, then lights the rows. The scroll starts first:
455// a transcript row moves only while the plugin answers the person's own input, a Button press
456// here, so it must not wait behind the state writes. A refusal is a toast. The debug line
457// carries the scroll's exact arguments.
458const jump = async ($: EngineInterface, ids: string[], block: 'start' | 'end', key?: string): Promise<void> => {
459  const target = { to: key === undefined ? { requestId: ids[0] as string } : { key }, block }
460  $.ui.log(`track: jump ${JSON.stringify(target)}`, { to: 'debug' })
461  const moving = $.ui.scroll(target).then(
462    moved => moved,
463    (error: unknown) => ({ deny: reason(error) }),
464  )
465  await flashRows($, ids)
466  const moved = await moving
467  if (moved.deny !== undefined) {
468    $.ui.toast(`track: cannot jump — ${moved.deny}`)
469  }
470}
471
472// $.session.messages() answers at most this many entries; past it, older tool calls look
473// absent though they are not, so the rewind check does nothing.
474const MESSAGES_CAP = 4096
475
476// /rewind raises no event, so detect it from the transcript: a question whose
477// track_question call is gone was asked in a rewound turn, and an answer whose
478// mark_answered call is gone was rewound. Only work since the last compaction is judged,
479// since compaction removes old tool calls as well.
480const observeRewind = async ($: EngineInterface): Promise<boolean> => {
481  const l = await read($, ledger)
482  const since = l.compactedAt ?? 0
483  const answerCall = (q: Question) => q.answeredBy ?? q.answerRequestId
484  const judged = (q: Question) =>
485    (q.trackedBy !== undefined && q.at > since) || (answerCall(q) !== undefined && (q.answeredAt ?? 0) > since)
486  const hasJudged = l.questions.some(judged)
487  const pending = (await read($, durability)).rewindSession
488  // Empty registers and rows without transcript provenance need no session
489  // lookup or transcript read before delivering their generic instruction.
490  if (!hasJudged && pending === undefined) return true
491  let session: string
492  try { session = await $.session.id() }
493  catch (error) {
494    persistence.failure = `rewind session identity unavailable: ${reason(error)}`
495    $.ui.log(`track: ${persistence.failure}`, { to: 'debug' })
496    return false
497  }
498  if (pending === session && !(await saveLedger($))) return false
499  if (!hasJudged) {
500    return true
501  }
502  const messages = await $.session.messages()
503  if (!Array.isArray(messages) || messages.length >= MESSAGES_CAP) {
504    return true
505  }
506  if (await $.session.id() !== session) return true
507  const present = new Set(messages.flatMap(m => m.toolUses.map(u => u.tool_use_id)))
508  if (!l.questions.some(q =>
509    (q.trackedBy !== undefined && q.at > since && !present.has(q.trackedBy)) ||
510    (answerCall(q) !== undefined && (q.answeredAt ?? 0) > since && !present.has(answerCall(q)!))
511  )) return true
512  const observed = new Map(l.questions.map(q => [q.id, q]))
513  let changed = false
514  await update<Ledger>($, ledger, cur => {
515    const compactedAt = cur.compactedAt ?? 0
516    const questions = cur.questions.flatMap(q => {
517      const old = observed.get(q.id)
518      // Only the calls judged by this read may be removed. A later tracking call,
519      // answer, or compaction wins over the older transcript observation.
520      if (old === undefined) return [q]
521      if (q.trackedBy === old.trackedBy && q.at === old.at && q.trackedBy !== undefined && q.at > compactedAt && !present.has(q.trackedBy)) return []
522      const call = answerCall(q)
523      if (call === undefined || call !== answerCall(old) || q.answeredAt !== old.answeredAt || (q.answeredAt ?? 0) <= compactedAt || present.has(call)) return [q]
524      const { answerRequestId: _a, answeredBy: _by, answeredAt: _t, answerTurnId: _turn, answerOrder: _order, note: _n, answerKey: _k, answerText: _x, ...rest } = q
525
526      return [{ ...rest, status: 'open' as const }]
527    })
528    changed = !sameValue(questions, cur.questions)
529    return changed ? { ...cur, questions } : cur
530  })
531  if (changed) {
532    await update($, durability, cur => ({ ...cur, rewindSession: session }))
533    await publishGate($)
534    return saveLedger($)
535  }
536  return true
537}
538
539const dropRewound = async ($: EngineInterface): Promise<boolean> => {
540  if (rewindCheck.running !== undefined) return rewindCheck.running
541  const run = observeRewind($)
542  rewindCheck.running = run
543  try { return await run }
544  finally { if (rewindCheck.running === run) rewindCheck.running = undefined }
545}
546
547// ✕ on a question: it leaves the ledger and both counts, and the next prompt tells the
548// model not to answer it. The Stop gate stops holding the turn for it, as it is gone.
549const withdraw = async ($: EngineInterface, id: number): Promise<void> => {
550  const q = (await read($, ledger)).questions.find(one => one.id === id)
551  if (q === undefined) {
552    return
553  }
554  await update<Ledger>($, ledger, cur => ({
555    ...cur,
556    questions: cur.questions.filter(one => one.id !== id),
557    withdrawn: [...(cur.withdrawn ?? []), { id, head: q.head }],
558  }))
559  await publishGate($)
560  await saveLedger($)
561  $.ui.toast(`track: Q${id} removed; the model is told on your next prompt.`)
562}
563
564// Called from a prompt redraw, which only draws: the cheap viewport test runs here, and the
565// store read and the open run from a timer, where state may be written.
566const scheduleAutoOpen = async ($: EngineInterface, viewport: { columns?: number; isFullscreen?: boolean } | undefined): Promise<void> => {
567  if (autoOpen.isScheduled || viewport?.isFullscreen !== true || (viewport.columns ?? 0) < AUTO_OPEN_MIN_COLUMNS) {
568    return
569  }
570  const p = await read($, pane)
571  if (p.isOpen || p.hidden || p.autoOpenDone === true) {
572    return
573  }
574  autoOpen.isScheduled = true
575  $.clock.after(AUTO_OPEN_DELAY_MS, () => {
576    void (async () => {
577      // A refused explicit choice lives in reload-persistent state until its
578      // save succeeds. The older store flag cannot reverse that choice.
579      const pending = (await read($, durability)).closedByPerson
580      const closedByPerson = pending ?? ((await $.store.get('closedByPerson')) === true)
581      if (closedByPerson) {
582        await update($, pane, cur => ({ ...cur, closedByPerson, autoOpenDone: true as const }))
583      } else {
584        await openPane($)
585        await update($, pane, cur => ({ ...cur, autoOpenDone: true as const }))
586      }
587    })().finally(() => {
588      autoOpen.isScheduled = false
589    })
590  })
591}
592
593type SavedIndex = Record<string, { at: number; bytes: number; unfinished?: boolean }>
594const utf8Bytes = (value: unknown): number => new TextEncoder().encode(JSON.stringify(value)).length
595const persistence = { queue: Promise.resolve(), failure: '' }
596const savedRevisions = new Map<string, number>()
597type SaveRecovery = { previous: unknown; bucket: unknown; removed: Array<[string, unknown]>; pendingHistory: Set<string>; previousIndex: unknown; index: unknown }
598const saveRecoveries = new Map<string, SaveRecovery>()
599const sameValue = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
600type LockStream = HookStream<ProcessSpawnChunk, ProcessSpawnResult>
601const writer = { session: '', token: crypto.randomUUID(), lease: null as LockStream | null }
602const durability = atom({ plugin: 'track', key: 'durability' } as const, { isUnsaved: false, reason: '' } as { isUnsaved: boolean; reason: string; rewindSession?: string; closedByPerson?: boolean })
603const gateWrites = { queue: Promise.resolve() }
604
605const closeLock = async (stream: LockStream): Promise<void> => { await stream.return({ code: null, signal: 'SIGTERM' }) }
606const acquireLock = async ($: EngineInterface, mode: 'lease' | 'write', session: string): Promise<LockStream> => {
607  const stream = $.process.spawn({ argv: ['python3', `${$.plugin.root}/hooks/writer-lock.py`, mode, session, writer.token] })
608  // return() closes a live stream by design, so its result rejects on normal cleanup.
609  void stream.result.catch(() => undefined)
610  let deadline: Timer | undefined
611  try {
612    const reading = (async () => {
613      let output = ''
614      let received = 0
615      while (true) {
616        const chunk = await stream.next()
617        if (chunk.done) throw new Error('writer lock returned no receipt')
618        received += chunk.value.text.length
619        if (received > 4096) throw new Error('writer lock receipt exceeded its limit')
620        if (chunk.value.stream !== 'stdout') continue
621        output += chunk.value.text
622        try { return JSON.parse(output) as { ok?: boolean; reason?: string; token?: string; mode?: string } }
623        catch { /* A stdout chunk may end inside the JSON receipt. */ }
624      }
625    })()
626    const expired = new Promise<never>((_, reject) => {
627      deadline = $.clock.after(4000, () => reject(new Error('writer lock receipt timed out')))
628    })
629    const receipt = await Promise.race([reading, expired])
630    if (receipt.ok !== true || receipt.token !== writer.token || receipt.mode !== mode) throw new Error(receipt.reason ?? 'writer lock receipt is incompatible')
631    return stream
632  } catch (error) {
633    await closeLock(stream).catch(() => undefined)
634    throw error
635  } finally {
636    deadline?.cancel()
637  }
638}
639const ensureWriter = async ($: EngineInterface, session: string): Promise<void> => {
640  if (writer.session === session && writer.lease !== null) return
641  const previous = writer.lease
642  writer.lease = null
643  writer.session = ''
644  if (previous !== null) await closeLock(previous)
645  const lease = await acquireLock($, 'lease', session)
646  writer.lease = lease
647  writer.session = session
648  // The helper owns the flock for the lifetime of this stream. An exited
649  // helper cannot authorize a later write, even when the session id is equal.
650  const ended = () => {
651    if (writer.lease !== lease) return
652    writer.lease = null
653    writer.session = ''
654    $.ui.log('track: writer lease ended; the next save must reacquire it', { to: 'debug' })
655  }
656  // HookStream.result settles only after the iterator reaches its end. Keep
657  // reading the live stream so an exit is observed between saving calls.
658  void (async () => {
659    try {
660      for await (const chunk of lease) {
661        if (chunk.text.trim() !== '') $.ui.log(`track: writer lease ${chunk.stream}: ${truncate(chunk.text, 200)}`, { to: 'debug' })
662      }
663    } catch (error) {
664      if (writer.lease === lease) $.ui.log(`track: writer lease failed: ${reason(error)}`, { to: 'debug' })
665    } finally { ended() }
666  })()
667}
668
669const publishGate = async ($: EngineInterface): Promise<void> => {
670  const run = gateWrites.queue.then(async () => {
671    const t = await read($, turn)
672    const l = await read($, ledger)
673    const open = l.questions.filter(q => q.cleared !== true && q.status === 'open' && q.turnId === t.currentId)
674    await $.env.set('TRACK_GATE_SNAPSHOT', JSON.stringify({ v: 1, session_id: await $.session.id(), turn_id: t.currentId, open: open.slice(0, OPEN_LISTED).map(q => ({ id: q.id, head: q.head })) }))
675  })
676  gateWrites.queue = run.catch(error => { $.ui.log(`track: Stop snapshot unavailable: ${reason(error)}`, { to: 'debug' }) })
677  await gateWrites.queue
678}
679
680// Every saved bucket with its time and size. Two sessions saving at once can each write the index
681// without the other's entry; a bucket the index does not list is read once for both, so it is
682// pruned by its age like the rest and never kept for good.
683const savedIndex = async ($: EngineInterface): Promise<SavedIndex> => {
684  const keys = await $.store.keys()
685  const index: SavedIndex = {}
686  for (const key of keys.filter(k => k.startsWith('s:'))) {
687    const id = key.slice(2)
688    const bucket = (await $.store.get(key)) as { savedAt?: unknown; ledger?: Ledger } | undefined
689    const l = bucket?.ledger
690    const unfinished = !Array.isArray(l?.questions) || !Array.isArray(l?.steps) || l.questions.some(q => q.cleared !== true && q.status !== 'answered') || l.steps.some(s => s.cleared !== true && s.status !== 'completed')
691    index[id] = { at: typeof bucket?.savedAt === 'number' ? bucket.savedAt : 0, bytes: utf8Bytes({ [key]: bucket ?? null }) + 1, unfinished }
692  }
693  return index
694}
695
696// A failed acknowledgement restores only this writer's attempted values, under the
697// same store lock. Keep the recovery in memory if the store also refuses rollback.
698const recoverSave = async ($: EngineInterface, id: string): Promise<void> => {
699  const recovery = saveRecoveries.get(id)
700  if (recovery === undefined) return
701  const failures: string[] = []
702  const key = `s:${id}`
703  try {
704    const current = await $.store.get(key)
705    if (!sameValue(current, recovery.previous)) {
706      if (!sameValue(current, recovery.bucket)) throw new Error('save recovery kept a changed durable ledger; reload this session before writing')
707      if (recovery.previous === undefined) await $.store.delete(key)
708      else await $.store.set(key, recovery.previous)
709      if (!sameValue(await $.store.get(key), recovery.previous)) throw new Error('save recovery read-back did not match the prior ledger')
710    }
711  } catch (error) { failures.push(reason(error)) }
712  // Recover every missing completed-history bucket even when the current ledger
713  // is uncertain. A later writer's value is kept; it must never stop other rows.
714  for (const [removedKey, value] of recovery.removed) {
715    try {
716      const stored = await $.store.get(removedKey)
717      if (sameValue(stored, value)) {
718        recovery.pendingHistory.delete(removedKey)
719        continue
720      }
721      if (stored !== undefined) {
722        if (recovery.pendingHistory.has(removedKey)) throw new Error(`save recovery could not verify ${removedKey}`)
723        $.ui.log(`track: recovery kept a later history value for ${removedKey}`, { to: 'debug' })
724        continue
725      }
726      recovery.pendingHistory.add(removedKey)
727      await $.store.set(removedKey, value)
728      if (!sameValue(await $.store.get(removedKey), value)) throw new Error(`save recovery could not verify ${removedKey}`)
729      recovery.pendingHistory.delete(removedKey)
730    } catch (error) { failures.push(reason(error)) }
731  }
732  try {
733    const index = await $.store.get(SAVED_INDEX)
734    if (!sameValue(index, recovery.previousIndex)) {
735      // The index is a cache. Rebuild a changed one from actual buckets, keeping
736      // another session's entries instead of treating metadata as ownership.
737      const restoredIndex = sameValue(index, recovery.index) ? recovery.previousIndex : await savedIndex($)
738      if (restoredIndex === undefined) await $.store.delete(SAVED_INDEX)
739      else await $.store.set(SAVED_INDEX, restoredIndex)
740      if (!sameValue(await $.store.get(SAVED_INDEX), restoredIndex)) throw new Error('save recovery could not verify the rebuilt index')
741    }
742  } catch (error) { failures.push(reason(error)) }
743  if (failures.length > 0) throw new Error(failures.join('; '))
744  saveRecoveries.delete(id)
745}
746
747// A save acknowledges the ledger and index only after both read back exactly.
748// Completed history may make room; a failed acknowledgement restores it.
749const saveLedger = async ($: EngineInterface): Promise<boolean> => {
750  let saved = false
751  const run = persistence.queue.then(async () => {
752  let lock: LockStream | undefined
753  let id: string | undefined
754  try {
755    id = await $.session.id()
756    await ensureWriter($, id)
757    lock = await acquireLock($, 'write', id)
758    await recoverSave($, id)
759    const pending = await read($, durability)
760    const preference = pending.closedByPerson
761    if (preference !== undefined) {
762      await $.store.set('closedByPerson', preference)
763      if (await $.store.get('closedByPerson') !== preference) throw new Error('pane preference read-back did not match')
764    }
765    const current = await read($, ledger)
766    const previous = await $.store.get(`s:${id}`) as { checkpoint?: Checkpoint } | undefined
767    const revision = previous?.checkpoint?.revision ?? 0
768    if (!Number.isSafeInteger(revision) || revision < 0 || (savedRevisions.has(id) && savedRevisions.get(id) !== revision)) throw new Error('stale writer revision; newer durable ledger was kept')
769    const bucket = { v: 1, savedAt: Date.now(), ledger: current, checkpoint: await describeCheckpoint(current, id, previous?.checkpoint) }
770    const bytes = utf8Bytes({ [`s:${id}`]: bucket }) + 1
771    const others = Object.entries(await savedIndex($))
772      .filter(([other]) => other !== id)
773      .sort(([, a], [, b]) => a.at - b.at)
774    const kept: SavedIndex = Object.fromEntries(others)
775    let used = bytes + others.reduce((sum, [, row]) => sum + row.bytes, 0)
776    const removals: string[] = []
777    for (const [other, row] of others) {
778      if (row.unfinished || (Object.keys(kept).length + 1 <= MAX_SESSIONS && used + utf8Bytes(kept) < STORE_BUDGET)) continue
779      delete kept[other]
780      removals.push(other)
781      used -= row.bytes
782    }
783    if (used + utf8Bytes(kept) >= STORE_BUDGET) throw new Error('capacity: unfinished history fills the UTF-8 store budget; nothing was pruned')
784    const index = { ...kept, [id]: { at: bucket.savedAt, bytes } }
785    const removed: Array<[string, unknown]> = []
786    for (const other of removals) removed.push([`s:${other}`, await $.store.get(`s:${other}`)])
787    saveRecoveries.set(id, { previous, bucket, removed, pendingHistory: new Set(), previousIndex: await $.store.get(SAVED_INDEX), index })
788    for (const other of removals) await $.store.delete(`s:${other}`)
789    await $.store.set(`s:${id}`, bucket)
790    const verified = await $.store.get(`s:${id}`) as typeof bucket | undefined
791    if (JSON.stringify(verified) !== JSON.stringify(bucket)) throw new Error('save read-back did not match the acknowledged ledger')
792    await $.store.set(SAVED_INDEX, index)
793    if (!sameValue(await $.store.get(SAVED_INDEX), index)) throw new Error('save index read-back did not match')
794    savedRevisions.set(id, bucket.checkpoint.revision)
795    saveRecoveries.delete(id)
796    persistence.failure = ''
797    saved = true
798    // A rewind or UI choice that arrived after this save's snapshot needs
799    // its own acknowledgement. Keep its gate while the queued save catches up.
800    await update($, durability, cur => cur.closedByPerson !== preference || cur.rewindSession !== pending.rewindSession
801      ? { ...cur, isUnsaved: true, reason: cur.closedByPerson !== preference ? 'pane preference save pending' : 'rewind save pending' }
802      : { isUnsaved: false, reason: '' })
803  } catch (error) {
804    persistence.failure = reason(error)
805    if (lock !== undefined && id !== undefined) {
806      try { await recoverSave($, id) }
807      catch (recoveryError) { persistence.failure += `; recovery pending: ${reason(recoveryError)}` }
808    }
809    await update($, durability, cur => ({ ...cur, isUnsaved: true, reason: persistence.failure }))
810    $.ui.log(`track: unsaved; save failed: ${persistence.failure}`, { to: 'debug' })
811    $.ui.toast(`track: unsaved — ${persistence.failure}`)
812  } finally {
813    if (lock !== undefined) await closeLock(lock).catch(error => $.ui.log(`track: writer lock cleanup failed: ${reason(error)}`, { to: 'debug' }))
814  }
815  })
816  persistence.queue = run.catch(() => undefined)
817  await run
818  return saved
819}
820
821// Migration goes through the engine's store for each loading identity. It preserves
822// raw buckets and refuses ambiguous same-session data before writing any record.
823const savePanePreference = async ($: EngineInterface, closedByPerson: boolean): Promise<void> => {
824  // The engine atom survives reload, so a refused preference is retried by the
825  // next save. It uses the ledger's queue, ownership and verified store lock.
826  await update($, durability, cur => ({ ...cur, isUnsaved: true, reason: 'pane preference save pending', closedByPerson }))
827  await saveLedger($)
828}
829
830const migrateStore = async ($: EngineInterface, mode: 'export' | 'import', path: string): Promise<string> => {
831  const run = persistence.queue.then(async () => {
832  let lock: LockStream | undefined
833  try {
834    if (!path.startsWith('/') || path.includes('\0')) throw new Error('an absolute bundle path is required')
835    const session = await $.session.id()
836    await ensureWriter($, session)
837    lock = await acquireLock($, 'write', session)
838    await recoverSave($, session)
839    if (mode === 'export') {
840      const records = [] as Array<{ key: string; value: unknown }>
841      for (const key of (await $.store.keys()).filter(key => key.startsWith('s:')).sort()) records.push({ key, value: await $.store.get(key) })
842      const checksum = await checksumOf(records)
843      await $.fs.write(path, JSON.stringify({ v: 1, source_identity: $.plugin.root, checksum, records }))
844      const verified = JSON.parse(await $.fs.read(path) as string) as { checksum?: string; records?: unknown }
845      if (verified.checksum !== checksum || await checksumOf(verified.records) !== checksum) throw new Error('export read-back does not match')
846      return JSON.stringify({ v: 1, ok: true, checksum, records: records.length })
847    }
848    const bundle = JSON.parse(await $.fs.read(path) as string) as { v?: number; checksum?: string; records?: Array<{ key: string; value: unknown }> }
849    if (bundle.v !== 1 || !Array.isArray(bundle.records) || bundle.checksum !== await checksumOf(bundle.records)) throw new Error('migration bundle is corrupt or incompatible')
850    const records = bundle.records
851    if (new Set(records.map(r => r.key)).size !== records.length || records.some(r => !r.key.startsWith('s:') || !SESSION_ID.test(r.key.slice(2)) || savedLedger(r.value) === undefined)) throw new Error('migration contains invalid or duplicate session records')
852    const missing = [] as typeof records
853    for (const record of records) {
854      const current = await $.store.get(record.key)
855      if (current === undefined) missing.push(record)
856      else if (JSON.stringify(current) !== JSON.stringify(record.value)) throw new Error(`migration conflict for session ${record.key.slice(2)}; both records were kept`)
857    }
858    const index = await savedIndex($)
859    if (Object.values(index).reduce((sum, row) => sum + row.bytes, 0) + utf8Bytes(missing) + utf8Bytes(index) >= STORE_BUDGET) throw new Error('migration capacity exceeded; existing history was kept')
860    const verified: string[] = []
861    for (const record of records) {
862      if (missing.some(r => r.key === record.key)) await $.store.set(record.key, record.value)
863      if (JSON.stringify(await $.store.get(record.key)) !== JSON.stringify(record.value)) throw new Error(`migration verification failed for session ${record.key.slice(2)}; source was kept`)
864      verified.push(record.key.slice(2))
865    }
866    await $.store.set(SAVED_INDEX, await savedIndex($))
867    return JSON.stringify({ v: 1, ok: true, checksum: bundle.checksum, imported: missing.map(r => r.key.slice(2)), verified })
868  } catch (error) {
869    return JSON.stringify({ v: 1, ok: false, reason: reason(error) })
870  } finally {
871    if (lock !== undefined) await closeLock(lock).catch(error => $.ui.log(`track: migration lock cleanup failed: ${reason(error)}`, { to: 'debug' }))
872  }
873  })
874  persistence.queue = run.then(() => undefined, () => undefined)
875  return run
876}
877
878// A saved prompt or question as a fresh row, or undefined when the row is not one.
879const savedPrompt = (row: unknown): Prompt | undefined => {
880  if (typeof row !== 'object' || row === null) {
881    return undefined
882  }
883  const r = row as Record<string, unknown>
884  if (typeof r.rowKey !== 'string' || typeof r.head !== 'string' || typeof r.at !== 'number') {
885    return undefined
886  }
887
888  return { rowKey: r.rowKey, head: headOf(r.head), at: r.at, turnId: typeof r.turnId === 'string' ? r.turnId : null, ...textField('requestId', r.requestId) }
889}
890
891const savedQuestion = (row: unknown): Question | undefined => {
892  if (typeof row !== 'object' || row === null) {
893    return undefined
894  }
895  const r = row as Record<string, unknown>
896  const status = QUESTION_STATUSES.find(one => one === r.status)
897  if (typeof r.id !== 'number' || typeof r.head !== 'string' || typeof r.at !== 'number' || status === undefined) {
898    return undefined
899  }
900
901  return {
902    id: r.id,
903    head: headOf(r.head),
904    at: r.at,
905    turnId: typeof r.turnId === 'string' ? r.turnId : null,
906    status,
907    ...textField('rowKey', r.rowKey),
908    ...textField('askedRequestId', r.askedRequestId),
909    ...textField('answerRequestId', r.answerRequestId),
910    ...textField('answeredBy', r.answeredBy),
911    ...textField('answerTurnId', r.answerTurnId),
912    ...(typeof r.answerOrder === 'number' && Number.isSafeInteger(r.answerOrder) && r.answerOrder >= 0 && { answerOrder: r.answerOrder }),
913    ...textField('answerKey', r.answerKey),
914    ...textField('trackedBy', r.trackedBy),
915    ...(typeof r.trackedOrder === 'number' && Number.isSafeInteger(r.trackedOrder) && r.trackedOrder >= 0 && { trackedOrder: r.trackedOrder }),
916    ...textField('restoredFrom', r.restoredFrom),
917    ...textField('restoredBy', r.restoredBy),
918    ...textField('sourceId', r.sourceId),
919    ...(typeof r.note === 'string' && { note: r.note.slice(0, HEAD_CHARS) }),
920    ...(typeof r.answerText === 'string' && { answerText: truncate(r.answerText, ANSWER_CHARS) }),
921    ...(typeof r.answeredAt === 'number' && { answeredAt: r.answeredAt }),
922    ...(r.cleared === true && { cleared: true as const }),
923  }
924}
925
926// A saved restore row's record, or undefined when the row is not one.
927const savedRestore = (row: unknown): Restore | undefined => {
928  if (typeof row !== 'object' || row === null) {
929    return undefined
930  }
931  const r = row as Record<string, unknown>
932  if (typeof r.by !== 'string' || typeof r.from !== 'string' || typeof r.steps !== 'number' || !Array.isArray(r.questions)) {
933    return undefined
934  }
935
936  return { by: r.by, from: r.from, steps: r.steps, questions: r.questions.map(savedQuestion).filter(isDefined).slice(-MAX_QUESTIONS) }
937}
938
939// A question from another session's register, under this session's id `id`. Its old row links
940// and turn are dropped: those rows are in the old transcript. `by` is the restore call whose row
941// shows it here.
942const asRestored = (q: Question, id: number, from: string, by: string | undefined): Question => ({
943  id,
944  head: q.head,
945  at: q.at,
946  turnId: RESTORED_TURN,
947  status: q.status,
948  ...(q.note !== undefined && { note: q.note }),
949  ...(q.answerText !== undefined && { answerText: q.answerText }),
950  ...(q.answeredAt !== undefined && { answeredAt: q.answeredAt }),
951  restoredFrom: from,
952  sourceId: q.sourceId ?? `${from}:Q${q.id}`,
953  ...(by !== undefined && { restoredBy: by }),
954})
955
956// Keep the acknowledged transcript snapshots needed by uncleared rows before
957// pruning recent history. Rendering never saves or reads the transcript.
958const keepRestores = (restores: Restore[], questions: Question[]): Restore[] => {
959  const active = new Set(questions.filter(q => q.cleared !== true).map(q => q.restoredBy).filter(isDefined))
960  const recent = new Set(restores.filter(r => !active.has(r.by)).slice(-MAX_RESTORES))
961  return restores.filter(r => active.has(r.by) || recent.has(r))
962}
963
964// Byte-stable notice text also recognizes snapshots written by the previous
965// version. Only a saved, acknowledged snapshot receives native render targets.
966const restoreNotice = (r: Restore): string => [
967  `Track source snapshot from session ${r.from}: ${counted(r.steps, 'step')}, ${counted(r.questions.length, 'question')}`,
968  'Restore status is confirmed by the tool receipt.',
969  ...r.questions.flatMap(q => [
970    `Q${q.id} ${q.status}: ${q.head}`,
971    ...(q.note !== undefined ? [`Note: ${q.note}`] : []),
972    ...(q.status === 'answered' ? [q.answerText ?? NOT_SAVED] : []),
973  ]),
974].join('\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
975
976const restoreKey = (q: Question, kind: 'q' | 'a'): string => `restored-${kind}:${q.restoredBy}:${q.id}`
977
978const renderRestore = async ($: EngineInterface, ui: ReturnType<EngineInterface['ui']['resolve']>, r: Restore): Promise<RenderElement> => {
979  const { Box, Text } = ui
980  const rows = await Promise.all(r.questions.map(async q => {
981    const questionKey = restoreKey({ ...q, restoredBy: q.restoredBy ?? r.by }, 'q')
982    const answerKey = restoreKey({ ...q, restoredBy: q.restoredBy ?? r.by }, 'a')
983    const questionLevel = await read($, memberOf(flash, { requestId: questionKey }))
984    const answerLevel = await read($, memberOf(flash, { requestId: answerKey }))
985    const below = q.status === 'answered' ? (q.answerText ?? NOT_SAVED) : q.status === 'deferred' ? q.note : undefined
986    return (
987      <Box key={`restored-${q.id}`} flexDirection="column" marginLeft={ROW_INDENT}>
988        <Box key={questionKey} backgroundColor={shade(questionLevel)} flexDirection="row" columnGap={1}>
989          <Box flexShrink={1}>
990            <Text color={q.status === 'answered' ? 'success' : undefined} dimColor={q.status === 'deferred'} wrap="wrap">{`Q${q.id}. ${q.head}`}</Text>
991          </Box>
992          <Text dimColor>{q.status}</Text>
993        </Box>
994        {below !== undefined && (
995          <Box key={answerKey} backgroundColor={shade(answerLevel)} marginLeft={ROW_INDENT}>
996            <Text dimColor={below === NOT_SAVED} wrap="wrap">{below}</Text>
997          </Box>
998        )}
999      </Box>
1000    )
1001  }))
1002  return <Box flexDirection="column"><Text bold>{`Restored from the previous session: ${counted(r.steps, 'step')}, ${counted(r.questions.length, 'question')}`}</Text>{rows}</Box>
1003}
1004
1005// A saved step as a fresh Step, or undefined when the row is not one: restore_tracker reads rows
1006// another session saved, and copies only the fields a step has.
1007const savedStep = (row: unknown): Step | undefined => {
1008  if (typeof row !== 'object' || row === null) {
1009    return undefined
1010  }
1011  const r = row as Record<string, unknown>
1012  const source = (['task', 'todo', 'plan'] as const).find(one => one === r.source)
1013  const status = STEP_STATUSES.find(one => one === r.status)
1014  if (typeof r.id !== 'string' || typeof r.subject !== 'string' || source === undefined || status === undefined) {
1015    return undefined
1016  }
1017
1018  return {
1019    id: r.id,
1020    source,
1021    subject: headOf(r.subject),
1022    status,
1023    ...(typeof r.taskId === 'string' ? { taskId: r.taskId } : {}),
1024    ...(r.cleared === true ? { cleared: true as const } : {}),
1025    ...(r.delegated === true && status !== 'completed' && status !== 'waiting' && { delegated: true as const }),
1026    ...(typeof r.startedAt === 'number' ? { startedAt: r.startedAt } : {}),
1027    ...(typeof r.endedAt === 'number' ? { endedAt: r.endedAt } : {}),
1028    ...textField('sourceId', r.sourceId),
1029    ...(typeof r.note === 'string' && { note: truncate(r.note, HEAD_CHARS) }),
1030  }
1031}
1032
1033// A saved bucket's register, or undefined when it is not one of this version: a resume reads what
1034// an earlier version or a damaged store left. Only well-formed rows are kept, and the next
1035// question id stays above every id kept or withdrawn.
1036const savedLedger = (raw: unknown): Ledger | undefined => {
1037  const bucket = raw as { v?: unknown; ledger?: Record<string, unknown> | null } | undefined
1038  const l = bucket?.ledger
1039  if (bucket?.v !== 1 || typeof l !== 'object' || l === null) {
1040    return undefined
1041  }
1042  const rows = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
1043  const questions = capRows(rows(l.questions).map(savedQuestion).filter(isDefined), MAX_QUESTIONS, q => q.status === 'answered')
1044  const withdrawn = rows(l.withdrawn).filter(
1045    (w): w is { id: number; head: string } => typeof (w as { id?: unknown })?.id === 'number' && typeof (w as { head?: unknown })?.head === 'string',
1046  )
1047  const top = Math.max(0, ...questions.map(q => q.id), ...withdrawn.map(w => w.id))
1048  const restores = keepRestores(rows(l.restores).map(savedRestore).filter(isDefined), questions)
1049
1050  return {
1051    v: 1,
1052    nextQuestionId: Math.max(top + 1, typeof l.nextQuestionId === 'number' ? l.nextQuestionId : 1),
1053    prompts: rows(l.prompts).map(savedPrompt).filter(isDefined).slice(-MAX_PROMPTS),
1054    questions,
1055    steps: capSteps(rows(l.steps).map(savedStep).filter(isDefined)),
1056    ...(withdrawn.length > 0 && { withdrawn }),
1057    ...(typeof l.compactedAt === 'number' && { compactedAt: l.compactedAt }),
1058    ...(restores.length > 0 && { restores }),
1059    ...(typeof l.restoredIds === 'object' && l.restoredIds !== null && { restoredIds: Object.fromEntries(Object.entries(l.restoredIds).filter(([, id]) => Number.isSafeInteger(id) && Number(id) > 0)) as Record<string, number> }),
1060  }
1061}
1062
1063// Clear all, per section. Unlike Clear completed, the rows leave and the ring restarts. Open
1064// and deferred questions are withdrawn, so the model is told not to answer them; ids go on
1065// counting up, so a later Q<n> never reuses one the model saw.
1066const clearQuestions = async ($: EngineInterface): Promise<void> => {
1067  await update<Ledger>($, ledger, cur => ({
1068    ...cur,
1069    questions: [],
1070    withdrawn: [
1071      ...(cur.withdrawn ?? []),
1072      ...cur.questions.filter(q => q.status !== 'answered').map(q => ({ id: q.id, head: q.head })),
1073    ],
1074  }))
1075  await publishGate($)
1076  await saveLedger($)
1077  $.ui.toast('track: questions cleared; open ones are withdrawn on your next prompt.')
1078}
1079
1080const clearSteps = async ($: EngineInterface): Promise<void> => {
1081  await update<Ledger>($, ledger, cur => ({ ...cur, steps: [] }))
1082  await saveLedger($)
1083  $.ui.toast('track: steps cleared.')
1084}
1085
1086const openPane = async ($: EngineInterface): Promise<boolean> => {
1087  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: PANE_ROWS })
1088  await update($, pane, p => ({ ...p, isOpen: opened.isPlaced }))
1089  if (!opened.isPlaced) {
1090    $.ui.toast(`track: the pane did not open — ${opened.reason}`)
1091  }
1092
1093  return opened.isPlaced
1094}
1095
1096// Every message the person typed is recorded before the model reads it: its provisional row key
1097// and a short head. Only the composer's rows count. A subagent's row carries agentId, and a
1098// hand-back or a notification comes in under its sender's origin; its row is never linked, so
1099// as the last prompt it left the next question with no [ Q ].
1100const recordPrompt = async ($: EngineInterface, e: { agentId?: string; origin: { kind: string }; uuid: string }, text: string): Promise<void> => {
1101  if (e.agentId !== undefined || e.origin.kind !== 'composer') {
1102    return
1103  }
1104  const head = headOf(text)
1105  if (head === '' || head.startsWith('/')) {
1106    return
1107  }
1108  const prompt: Prompt = { rowKey: rowKey(e.uuid), head, turnId: null, at: Date.now() }
1109  await update($, ledger, l => ({ ...l, prompts: [...l.prompts, prompt].slice(-MAX_PROMPTS) }))
1110}
1111
1112export const register: Register = on => {
1113  // One restore owns its visible receipt and save through acknowledgement.
1114  // Other ledger writers still use atom compare-and-set; a changed ledger
1115  // during a mechanical receipt is refused instead of overwriting their work.
1116  let restoreQueue = Promise.resolve()
1117  on('tool.call', { tool: RESTORE_TRACKER }, async (_, e, next) => {
1118    if (next.origin.plugin === 'engine') return next(e)
1119    const run = restoreQueue.then(() => next(e))
1120    restoreQueue = run.then(() => undefined, () => undefined)
1121    return run
1122  })
1123  // Persist acknowledged tracking changes before their callers receive success.
1124  // This hook never runs from a redraw; UI mutations save in their own handlers.
1125  const mutationTools = new Set([TRACK_QUESTION, MARK_ANSWERED, TRACK_STEPS, MARK_STEP, RESTORE_TRACKER, 'TaskCreate', 'TaskUpdate', 'TodoWrite'])
1126  on('tool.call', async ($, e, next) => {
1127    if (e.agentId !== undefined || !mutationTools.has(e.tool)) return next(e)
1128    const before = JSON.stringify(await read($, ledger))
1129    const ran = await next(e)
1130    if (ran.deny !== undefined || ran.isError === true || before === JSON.stringify(await read($, ledger))) return ran
1131    if (e.tool === RESTORE_TRACKER && e.expected_checkpoint !== undefined && typeof ran.result === 'string' && (JSON.parse(ran.result) as Checkpoint).ok !== true) return ran
1132    await publishGate($)
1133    if (await saveLedger($)) return ran
1134    const warning = `track: unsaved — ${persistence.failure}`
1135    if (e.tool === RESTORE_TRACKER && e.expected_checkpoint !== undefined && typeof ran.result === 'string') {
1136      const receipt = JSON.parse(ran.result) as Checkpoint
1137      return { ...ran, result: JSON.stringify({ ...receipt, ok: false, reason: warning }), context: [...(ran.context ?? []), warning] }
1138    }
1139    return { ...ran, ...(typeof ran.result === 'string' && { result: `${ran.result}\n${warning}` }), context: [...(ran.context ?? []), warning] }
1140  })
1141
1142  on('session.start', async ($, e, next) => {
1143    // Like /btw: typed while a turn runs, /track acts at once instead of waiting for the turn to
1144    // end, and the toggle answers with no text, so the session gets no row.
1145    await $.command.register({
1146      name: 'track',
1147      description: 'Show or hide the track pane: questions asked, where answered, and the steps',
1148      argumentHint: '[status | export <absolute-path> | import <absolute-path>]',
1149      immediate: true,
1150    })
1151    await $.tool.register({
1152      name: 'track_question',
1153      description:
1154        'Register a substantive question from any sender before answering, or reuse its existing open id. For a known user source, pass source_text as its exact first line; it is verified against current prompt rows. Alternatively pass a known source_request_id. If the source cannot be verified, the question links to this tracking call. Returns the id for mark_answered.',
1155      inputSchema: {
1156        type: 'object',
1157        properties: { summary: { type: 'string', description: 'One line, under 80 characters, restating the question' }, source_request_id: { type: 'string', description: 'Optional known source row; validated against recorded prompts' }, source_text: { type: 'string', description: 'Optional exact first line of the source user message; unique current-turn matches keep its source jump' } },
1158        required: ['summary'],
1159      },
1160    })
1161    await $.tool.register({
1162      name: 'mark_answered',
1163      description:
1164        'Mark a tracked question answered or deferred. Call it right after writing the answer. An optional answer_request_id must name the latest host-observed response in this turn; an unknown source is refused. A known answer is kept. If called before text, only one pending question can bind the next response. status "deferred" needs a note saying what it waits for.',
1165      inputSchema: {
1166        type: 'object',
1167        properties: {
1168          id: { type: 'number', description: 'The question id returned by track_question' },
1169          status: { type: 'string', enum: ['answered', 'deferred'] },
1170          note: { type: 'string', description: 'For deferred: what the answer waits for' },
1171          answer_request_id: { type: 'string', description: 'Optional exact response UUID, verified against the latest host-observed answer text; never guessed from a user prompt' },
1172        },
1173        required: ['id', 'status'],
1174      },
1175    })
1176
1177    await $.tool.register({
1178      name: 'track_steps',
1179      description: `Register meaningful work from any sender. ${STEPS} Reuse existing open steps. Without after, explicit plan rows are replaced; with after, new work is inserted. TaskCreate tasks appear on their own. Plan approval only adds a reminder.`,
1180      inputSchema: {
1181        type: 'object',
1182        properties: {
1183          steps: { type: 'array', items: { type: 'string' }, description: 'Step titles, in order, each one line' },
1184          after: { type: 'string', description: 'Insert after this step id (plan:2, task:7, ...) and keep the plan' },
1185        },
1186        required: ['steps'],
1187      },
1188    })
1189    await $.tool.register({
1190      name: 'restore_tracker',
1191      description:
1192        'Restore the complete steps, uncleared questions, statuses, notes and saved answers from from_session. Repeat calls reuse stable source IDs and preserve local progress, including cleared displayed rows. Unrelated existing steps cause a refusal; replace:true explicitly replaces prior restored rows and steps. The call row shows saved answers; programmatic calls use an acknowledged source snapshot. expected_checkpoint validates the source before applying and returns a v1 JSON text receipt with actual applied values.',
1193      inputSchema: {
1194        type: 'object',
1195        properties: {
1196          from_session: { type: 'string', description: 'The previous session id' },
1197          replace: { type: 'boolean', description: 'Replace the steps this session already has, and the questions already restored from that session' },
1198          expected_checkpoint: { type: 'object', description: 'Optional v1 checkpoint receipt. Validate source session, revision, checksum and counts before changing anything.' },
1199        },
1200        required: ['from_session'],
hooks/checkpoint.ts 67 lines
1import type { Ledger } from '../types'
2
3// v1 local checkpoint/restore contract. The handoff reliability release requires
4// receipts for actual Q/A and step values; display-row ids are not authority.
5export type Checkpoint = {
6  v: 1
7  ok: boolean
8  source_session: string
9  destination_session?: string
10  applied_checksum?: string
11  revision: number
12  checksum: string
13  counts: { questions: number; answers: number; steps: number }
14  reason?: string
15}
16
17export const checkpointValues = (l: Ledger, session: string) => ({
18  questions: l.questions.filter(q => q.cleared !== true).map(q => ({
19    sourceId: q.sourceId ?? `${session}:Q${q.id}`, head: q.head, status: q.status,
20    note: q.note ?? null, answerText: q.answerText ?? null, answeredAt: q.answeredAt ?? null,
21  })),
22  steps: l.steps.filter(s => s.cleared !== true).map(s => ({
23    sourceId: s.sourceId ?? `${session}:${s.id}`, subject: s.subject, status: s.status,
24    note: s.note ?? null, startedAt: s.startedAt ?? null, endedAt: s.endedAt ?? null,
25    ...(s.delegated === true && { delegated: true }),
26  })),
27})
28
29export const checksumOf = async (value: unknown): Promise<string> => {
30  const bytes = new TextEncoder().encode(JSON.stringify(value))
31  const hash = await crypto.subtle.digest('SHA-256', bytes)
32  return Array.from(new Uint8Array(hash), b => b.toString(16).padStart(2, '0')).join('')
33}
34
35export const appliedChecksum = async (source: Ledger, destination: Ledger, from: string, to: string): Promise<string> => {
36  const wanted = checkpointValues(source, from)
37  const actual = checkpointValues(destination, to)
38  // Compare the actual destination rows in the source's order. Missing rows
39  // participate as null, so an attempted or steps-only restore cannot match.
40  return checksumOf({
41    questions: wanted.questions.map(q => actual.questions.find(one => one.sourceId === q.sourceId) ?? null),
42    steps: wanted.steps.map(s => actual.steps.find(one => one.sourceId === s.sourceId) ?? null),
43  })
44}
45
46export const describeCheckpoint = async (l: Ledger, session: string, previous?: Checkpoint): Promise<Checkpoint> => {
47  const values = checkpointValues(l, session)
48  const checksum = await checksumOf(values)
49  return {
50    v: 1, ok: true, source_session: session,
51    revision: previous?.checksum === checksum ? previous.revision : (previous?.revision ?? 0) + 1,
52    checksum,
53    counts: { questions: values.questions.length, answers: values.questions.filter(q => q.status === 'answered').length, steps: values.steps.length },
54  }
55}
56
57export const checkpointFailure = (session: string, reason: string, destination?: string): Checkpoint => ({
58  v: 1, ok: false, source_session: session, ...(destination !== undefined && { destination_session: destination }),
59  revision: 0, checksum: '', counts: { questions: 0, answers: 0, steps: 0 }, reason,
60})
61
62export const matchesCheckpoint = (actual: Checkpoint, expected: unknown): boolean => {
63  if (expected === null || typeof expected !== 'object') return false
64  const e = expected as Checkpoint
65  return e.v === 1 && e.ok === true && e.source_session === actual.source_session && e.revision === actual.revision && e.checksum === actual.checksum && e.counts?.questions === actual.counts.questions && e.counts?.answers === actual.counts.answers && e.counts?.steps === actual.counts.steps
66}
67
types/index.d.ts 140 lines
1// A typed prompt row, recorded by the harness before the model runs. `rowKey` is the
2// stored row's uuid with its last group dropped; `requestId` is the id the transcript
3// drew the row under, written once the row renders (the authoritative jump target).
4export type Prompt = {
5  rowKey: string
6  requestId?: string
7  turnId: string | null
8  head: string
9  at: number
10}
11
12// A question the model sent to the panel with `track_question`. `askedRequestId` is
13// the prompt row it was asked in; `answerRequestId` identifies verified answer text.
14// Legacy ledgers may still have the acknowledgement there; jumps use answerKey.
15export type Question = {
16  id: number
17  head: string
18  at: number
19  rowKey?: string
20  askedRequestId?: string
21  turnId: string | null
22  status: 'open' | 'answered' | 'deferred'
23  answerRequestId?: string
24  // The status call is kept separately when a later response is the visible answer.
25  answeredBy?: string
26  // A mark before the real text can bind only a later response in this same turn.
27  answerTurnId?: string
28  answerOrder?: number
29  // The answer's last text row, by its row key (the row uuid's first four groups).
30  answerKey?: string
31  note?: string
32  cleared?: true
33  // The tool_use_id of the track_question call that minted it, and when it was answered:
34  // a /rewind is detected by these ids leaving the transcript.
35  trackedBy?: string
36  // Monotonic order of the tracking event, compared with text in the same turn.
37  trackedOrder?: number
38  answeredAt?: number
39  // The answer's words, from that last text row, cut to ANSWER_CHARS: a later session shows them
40  // in its restore row, since the row itself is in this session's transcript.
41  answerText?: string
42  // Brought back after a handoff: the session it came from, and the restore_tracker call whose
43  // row shows it here (that row is where [ Q ] and [ A ] jump).
44  restoredFrom?: string
45  restoredBy?: string
46  // Stable identity across restores and handoffs, independent of the display id.
47  sourceId?: string
48}
49
50// What one restore_tracker call brought back, fixed at that call: its transcript row is drawn from
51// this, so clearing the pane later leaves the row as it was.
52export type Restore = {
53  by: string
54  from: string
55  steps: number
56  questions: Question[]
57}
58
59// A step the model set itself: a Task, a todo line, or an explicit track_steps row.
60export type Step = {
61  id: string
62  source: 'task' | 'todo' | 'plan'
63  subject: string
64  // paused: started, then parked; waiting: needs the person's answer.
65  status: 'pending' | 'in_progress' | 'completed' | 'paused' | 'waiting'
66  taskId?: string
67  createdRequestId?: string
68  sourceId?: string
69  note?: string
70  // Explicit work ownership, independent of whether agents run through Agent or a shell.
71  delegated?: true
72  cleared?: true
73  // The step's wall clock: when it first went in progress, and when it was done.
74  startedAt?: number
75  endedAt?: number
76}
77
78export type Ledger = {
79  v: 1
80  nextQuestionId: number
81  prompts: Prompt[]
82  questions: Question[]
83  steps: Step[]
84  // Questions the user withdrew with ✕, told to the model once on the next prompt.
85  withdrawn?: Array<{ id: number; head: string }>
86  // When the transcript was last compacted. Questions older than that are never dropped by the
87  // rewind check, because compaction removes their tool calls too. Saved with the register, so a
88  // resumed session keeps it.
89  compactedAt?: number
90  // The newest restore_tracker calls, by tool_use_id, for drawing their rows.
91  restores?: Restore[]
92  restoredIds?: Record<string, number>
93}
94
95// `lastText`: the main loop's last text row, its text, and the turn it was written in.
96// Only the exact composedRule proves that the current instruction reached the model.
97export type Turn = {
98  currentId: string | null
99  gatedTurnId: string | null
100  eventOrder?: number
101  lastText?: { row: string; requestId?: string; turnId: string | null; order?: number; text?: string }
102  composeSeen?: true
103  composedRule?: string
104}
105
106// The first shown row of each pane region, or null to follow the news.
107export type ScrollAt = { questions: number | null; steps: number | null }
108
109// What the session is doing, for the banner: the main turn running, Agent and AskUserQuestion
110// calls in flight (tool_use ids), background agents still running, and other background work
111// still running, such as shell tasks (their ids). An activity saved before tasks existed has none.
112export type Activity = { isWorking: boolean; agentCalls: string[]; askCalls: string[]; background: string[]; tasks?: string[] }
113
114// `hidden` is this session's `/track` toggle; `closedByPerson` is the persistent off
115// (ctrl+x x), mirrored to `$.store`.
116// `autoOpenDone`: this session already judged the auto-open rule, so prompt redraws stop asking.
117export type Pane = { isOpen: boolean; hidden: boolean; closedByPerson: boolean; autoOpenDone?: true }
118
119declare module 'claude-code' {
120  interface PluginState {
121    track: {
122      ledger: Ledger
123      turn: Turn
124      pane: Pane
125      // A jump's highlight on a transcript row, 0 when unlit; keyed by the row's requestId, or
126      // by an answer's text key. `lit` lists the rows lit now, so a reload can put them out.
127      flash: StateFamily<number>
128      lit: string[]
129      activity: Activity
130      // The phase of the in-progress step's pulse, advanced by a timer while work runs.
131      pulse: number
132      // Each pane region's first shown row; null follows the newest question or the step at work.
133      scroll: ScrollAt
134      // The time the step clocks were last moved on, by a timer while a step is under way.
135      tick: number
136      durability: { isUnsaved: boolean; reason: string; rewindSession?: string; closedByPerson?: boolean }
137    }
138  }
139}
140