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

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.
Shows each image you paste into a prompt as a small framed thumbnail under your message.
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/).
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.
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.[ 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).↑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.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.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.◑ 4 of 8 · 50% per section, counted over visible rows. Clear completed (c) hides finished rows and removes them from those counts.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./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.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.✕ removes a question; your next prompt tells the model not to answer it./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:
/ are left out; the last 200 prompts;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.
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.
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.
hooks/register.tsx 2466 lines1import { 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 lines1import 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}
67types/index.d.ts 140 lines1// 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