Session dashboard: a /tether pane for a working-on note, context, the assumptions Claude is making, the work it puts off, action items, subagents and cost…

A session dashboard for Claude Code: a /tether pane of folding sections for what Claude is waiting on you for, what it's assuming, what it put off, and where your context and money went.
Adapted from ClariSortAi's Terminal Desk (MIT, see LICENSE).
/plugin marketplace add mgstegmaier/claude-plugins. You only need this once./plugin install tether@mgstegmaier./reload-plugins, or start a new session./tether to open it at any width.You need Claude Code 2.1.289 or later. To check your version, run claude --version in a terminal.
/tether opens a pane with eight sections. Each section is a card with its name at the top: click the name to fold or unfold it. Context starts folded, which hides its legend and keeps its bar in view, and so does Changes.
No button in the pane sends anything. Each one puts a message in your prompt box, and you press Enter to send it as it stands or edit it first.
Working on is a line about what this session is for. It follows the session's title, which arrives with your second prompt and changes when you rename the session. Edit opens it as a text field: type your own note and press Enter to save it, and from then on the title no longer replaces it. Save an empty note to go back to following the title. Your note never renames the session. Folded, the section shows the note on its header line, cut with an ellipsis when it's long; unfold it to see the whole note and Edit. The note belongs to the session, and Claude doesn't read it.
Context is the same bar and legend as the context-band card: what fills the context window, in Anthropic's palette, with a tick where Claude Code compacts. On the desktop app, hover a part of the bar for its detail.
Assumptions lists the last five judgment calls Claude reported, newest first, each with its reason and what it affects. Approve takes an assumption you agree with off the list. Reject, or the number next to it, drafts a correction. If Claude later overturns an assumption itself, the old entry is struck through, and Clear takes it off the list.
Loose ends lists work Claude put off, each written as one concrete action Claude could take if you told it to. Lines Claude writes into a file that mark unfinished work (TODO, a skipped test, "not implemented") go straight in. Everything else goes through a second, small model that reads your request against Claude's final report and rewrites each loose end so it names the exact file, command or target. That model runs after any answer with a sentence that defers something ("for now", "I haven't", "placeholder"), and after any turn with five or more tool calls. It drops list headings, work skipped on purpose with a reason, and decisions only you can make. While any loose end is open, the same model also runs after every answer and clears the ones your message or Claude's report shows are done, dropped or moot. A loose end still open 10 turns after it appeared clears itself. Do now, or the letter next to an entry, drafts "You left this undone: ... Do it now." Clear drops one entry, and x clears the list.
Action items lists what Claude is waiting on you for: decisions, choices, questions, things to check, and tasks only you can do. Each ask gets buttons that fit it: Approve and Deny, one per option, Answer, Looks good, Done and Cancel, plus Discuss on every ask. A finished reply ends with a blank line, so you can press buttons on several asks and send the replies together as separate paragraphs. Answer and Discuss leave the cursor after their colon for you to type. An ask leaves the list when you send the reply its button drafted, even if you add to it first. Discuss is the exception: it starts a conversation, so its ask stays until Claude resolves it. Dismiss, on a question, drops it without a message. Clear all, under the list, drops every ask without a message. Old asks also clear themselves: when Claude adds a new batch, every ask from an earlier turn leaves the list, answered or not, including Discuss and drafted ones. A turn that adds nothing leaves the list alone.
Claude adds asks itself through a tool, track, and resolves them when your reply answers them. If Claude ends a turn with something that looks like an ask and didn't call track, a backstop runs: one small Claude Haiku request reads the reply and adds what was missed. It runs after the turn ends and skips any ask that matches one open or recently answered.
Subagents lists every subagent in the session with its status, type, model, run time and tool calls.
Cost and tokens shows session cost, tokens read and written, and the share served from cache.
Changes is the session's footprint, kept only until the session closes. Its first list is everything that reached outside your machine or is hard to undo: pushes, PR merges, branch deletes, rm, plugin installs, deploys and MCP calls that write, each marked passed or failed. Its second list is the files Claude edited, with edit counts; click a file name to open the file. The last line says whether tests, lint or a build ran after the last code edit, and whether they passed; edits to notes and other prose (.md, .txt and similar) don't ask for one. Folded, the header sums it up, for example "3 outside · 5 files · unchecked".
Settings, at the bottom of the pane or through /tether settings, lists every section with Show or Hide and buttons to move it up or down. Reset restores the default order with every section shown. Your choices are saved for every new session.
| Command | What it does | | :- | :- | | /tether | Open the pane | | /tether settings | Choose which sections show and their order | | /tether check off | Stop sending finished turns to the second model | | /tether check on | Turn that check back on (the default) |
The pane is drawn locally from numbers Claude Code already keeps. Drawing them makes no model calls and no network calls.
The assumptions and Open asks sections are the parts that cost tokens. Each adds a short instruction to the system prompt (roughly 200 tokens each) and registers one tool (note_assumption and track). For assumptions, note_assumption is the tool that Claude calls when it makes a judgment call you did not state. Each logged assumption is a small tool call, and when Claude logs one as a separate step, that is one extra request at the cached rate. On a large conversation that can be several cents each.
The Loose ends section's file scan is local and free. Its second-model check is one small request to Claude Haiku 4.5 after each answer that defers something, follows five or more tool calls, or ends with a loose end still open, sent with your request and the last part of Claude's answer. /tether check off stops it, and with it every loose end that comes from Claude's answers.
Mods are not sandboxed. This one reads session usage, tool call names and subagent activity, adds text to the system prompt, and registers a tool. It reads the text Claude writes into files to look for unfinished-work markers, and it sends your request and Claude's final answer to a second Claude model for the check described above. It does not write files or contact any other service. The mod is two files, hooks/register.tsx and hooks/split.ts, so you can check that for yourself.
MIT
hooks/register.tsx 1135 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, Ask, Assumption, Layout, Stats, Undone } from '../types'
5import { BACKSTOP_SYSTEM, GUIDANCE, SCHEMA, SEND, TRACK, isDuplicate, looksLikeAsk, parseChange } from './asks'
6import type { Change } from './asks'
7import { WRITERS, checkState, footprint } from './changes'
8import { barCells, barSvg, fmt, pillColor, toSnapshot } from './split'
9
10const PANE = 'tether'
11// All Mammal palette, the same family as the context bar's colors.
12const ACCENT = '#d97757' // terracotta: needs attention
13const SOFT = '#e8a58c' // light terracotta: warning
14const OLIVE = '#788c5d' // fine, running, done
15const SLATE = '#6a9bcc' // information
16const STONE = '#b0aea5' // borders only, never text
17const INK = '#141413' // text on a filled pill
18const NOTE = 'mcp__tether__note_assumption'
19const SHOWN_NOTES = 5
20
21// What the model is told, as a section of the system prompt.
22const GUIDE = [
23 '# Surfacing your assumptions',
24 'The user watches a live panel of the assumptions you make about what they want.',
25 `Whenever you make a judgment call the user did not state, and it changes what you do next, call the ${NOTE} tool once, before you act on it. If that tool is not loaded yet, load it by its exact name first.`,
26 'Log: the scope you settled on, which files or systems you took to be in or out of play, what you chose to keep or delete, a naming or structure choice in a refactor, which of two readings of the request you took, what you took "done" to mean.',
27 'Do not log: facts you verified, routine steps, or anything the user said outright.',
28 'One plain sentence per assumption. When a later finding overturns an earlier one, call the tool again with `replaces` set to that assumption\'s id.',
29 'A small task may have none; a large refactor has many.',
30].join('\n')
31const MINUTE = 60_000
32const MAX_AGENTS = 12
33
34const EMPTY: Stats = {
35 openedAt: 0,
36 now: 0,
37 turns: 0,
38 tools: 0,
39 fails: 0,
40 freshTokens: 0,
41 cacheReadTokens: 0,
42 cacheWriteTokens: 0,
43 outTokens: 0,
44 costUsd: null,
45 ctxPercent: null,
46 ctxTokens: null,
47 ctxWindow: null,
48 snapshot: null,
49 deferredTokens: 0,
50 assumptions: [],
51 nextNote: 1,
52 agents: [],
53 needsMeasure: false,
54 undone: [],
55 nextUndone: 1,
56 lastPrompt: '',
57 turnTools: 0,
58 isChecking: true,
59 tickError: '',
60 effects: [],
61 touched: [],
62 lastEditAt: 0,
63 lastCheck: null,
64}
65
66const SHOWN_UNDONE = 5
67const UNDONE_KEYS = ['a', 'b', 'c', 'd', 'e']
68// A turn with fewer tool calls than this did too little work to be checked.
69const CHECK_MIN_TOOLS = 5
70const CHECKER = 'claude-haiku-4-5-20251001'
71const CHECK_SYSTEM = [
72 'You read what a user asked a coding assistant to do and the assistant\'s final report, and list the loose ends:',
73 'concrete work the assistant could still do itself if the user told it "do it now".',
74 'Each loose end is one line under 25 words, starts with a verb, and names the exact thing (file, command, branch, plugin, version, target) so it makes sense to someone who never saw the conversation.',
75 'Write "Push branch fix/retry and open a pull request against main", never "Push it".',
76 'Leave out: work the report says was skipped on purpose with a reason; decisions or actions only the user can take; headings and list lead-ins such as "What I left alone:"; anything already done; anything you cannot make concrete from the report.',
77 'At most three, one per line, no numbering, no commentary. If there are none, reply with the single word NONE.',
78 'You may also get the loose ends already open, each with an id such as U3. If the request or the report shows one is done, no longer wanted, or moot, end with one line "RESOLVED: U3 U7". Leave that line out when none are.',
79].join(' ')
80// ponytail: a loose end open this many main turns is stale and clears itself; tune by feel.
81const STALE_TURNS = 10
82// How an answer words work it is putting off.
83const SAID = /\b(for now|follow[- ]up|out of scope|not yet|I (?:didn't|did not|haven't|have not|skipped|left)\b|(?:do|handle|add|fix|revisit|address|tackle) (?:that|this|it|them|those) later|in a later (?:pass|step|turn|change|PR)|still needs?|remains? to be|placeholder|stubbed|untested|not (?:verified|tested|implemented|wired up))/i
84// What put-off work looks like once it is written into a file.
85const WROTE = /\b(?:TODO|FIXME|XXX)\b|not implemented|NotImplemented|\bplaceholder\b|\.skip\(|\bx(?:it|describe)\(|@pytest\.mark\.skip/
86
87// The sentences of an answer that put work off, code blocks left out.
88const deferrals = (answer: string): string[] =>
89 answer
90 .replace(/```[\s\S]*?```/g, ' ')
91 .split(/(?<=[.!?])\s+|\n+/)
92 .map(part => part.replace(/^[\s>*#-]+/, '').replace(/\*\*/g, '').trim())
93 // Words Claude is quoting or showing as code are not Claude putting work off.
94 .filter(part => part.length > 12 && SAID.test(part.replace(/"[^"]*"|`[^`]*`/g, ' ')))
95 .map(part => part.slice(0, 200))
96 .slice(0, 3)
97
98const recordUndone = async ($: EngineInterface, found: string[], source: Undone['source']): Promise<void> => {
99 if (found.length === 0) {
100 return
101 }
102
103 const at = await $.clock.now()
104 let added = 0
105
106 await update($, stats, raw => {
107 const s = whole(raw)
108 // Cleared and sent ones count too, so a reworded copy doesn't come back.
109 const seen = s.undone.map(one => one.text)
110 const fresh = found.filter(text => !isDuplicate(text, seen) && (seen.push(text), true))
111 added = fresh.length
112
113 return {
114 ...s,
115 nextUndone: s.nextUndone + fresh.length,
116 undone: [
117 ...s.undone,
118 ...fresh.map((text, i): Undone => ({ id: s.nextUndone + i, text, source, at, turn: s.turns, status: 'open' })),
119 ].slice(-40),
120 }
121 })
122
123 if (added > 0) {
124 $.ui.toast(`Loose end: ${(found[0] ?? '').slice(0, 80)}`)
125 }
126}
127
128// Marks loose ends cleared: by id, or every open one recorded `STALE_TURNS` main turns ago.
129const clearUndone = ($: EngineInterface, ids: number[], now?: number) =>
130 update($, stats, raw => {
131 const s = whole(raw)
132 const isStale = (one: Undone) => now !== undefined && now - (one.turn ?? 0) >= STALE_TURNS
133
134 return {
135 ...s,
136 undone: s.undone.map(one => (one.status === 'open' && (ids.includes(one.id) || isStale(one)) ? { ...one, status: 'cleared' as const } : one)),
137 }
138 })
139
140// A second, small model reads the request against the final report and writes
141// each loose end as a standalone action. It sees the report and not the tool
142// calls, so it finds what the report admits to. `hints` are the report's own
143// put-off sentences, which alone are too thin to act on. It also gets the open
144// loose ends and names the ones the request or report settled, which clear.
145const check = async ($: EngineInterface, asked: string, answer: string, hints: string[], source: Undone['source']): Promise<void> => {
146 const flagged = hints.length ? `\n\nSENTENCES IN THE REPORT THAT PUT WORK OFF:\n${hints.join('\n')}` : ''
147 const open = whole(await read($, stats)).undone.filter(one => one.status === 'open')
148 const listed = open.length ? `\n\nLOOSE ENDS ALREADY OPEN:\n${open.map(one => `U${one.id} ${one.text}`).join('\n')}` : ''
149 const reply = await $.model.complete({
150 model: CHECKER,
151 system: CHECK_SYSTEM,
152 prompt: `REQUEST:\n${asked || '(not captured)'}\n\nFINAL REPORT:\n${answer.slice(-6000)}${flagged}${listed}`,
153 maxTokens: 300,
154 timeoutMs: 30_000,
155 })
156
157 if (!reply.isAnswered) {
158 $.ui.log('tether checker: the model did not answer', { to: 'debug' })
159
160 return
161 }
162
163 const rows = reply.text.split('\n').map(row => row.replace(/^[\s\d.*-]+/, '').trim())
164 const resolved = rows
165 .filter(row => /^resolved:/i.test(row))
166 .flatMap(row => [...row.matchAll(/U(\d+)/gi)].map(m => Number(m[1])))
167 .filter(id => open.some(one => one.id === id))
168 const found = rows
169 .filter(row => row !== '' && !/^none\.?$/i.test(row) && !/^resolved:/i.test(row))
170 .map(row => row.slice(0, 200))
171 .slice(0, 3)
172
173 if (resolved.length > 0) {
174 await clearUndone($, resolved)
175 }
176 await recordUndone($, found, source)
177}
178
179const BLANK_ROW: AgentRow = {
180 id: '',
181 label: '',
182 type: 'subagent',
183 model: null,
184 startedAt: 0,
185 endedAt: null,
186 lastSeenAt: 0,
187 isSpawned: false,
188 tools: 0,
189 tokensRead: 0,
190 tokensOut: 0,
191 hasFailed: false,
192}
193
194const stats = atom({ plugin: 'tether', key: 'stats' } as const, EMPTY)
195// Sections the person folded. Context starts folded (its bar still shows), and so does changes.
196const collapsed = atom({ plugin: 'tether', key: 'collapsed' } as const, ['context', 'changes'])
197// What this session is about, in the person's words, and whether its field is open.
198const focus = atom({ plugin: 'tether', key: 'focus' } as const, '')
199const isEditingFocus = atom({ plugin: 'tether', key: 'isEditingFocus' } as const, false)
200// The note follows the session title until the person saves their own; clearing it hands it back.
201const isFocusCustom = atom({ plugin: 'tether', key: 'isFocusCustom' } as const, false)
202
203// The main pane's sections in their default order, with the names Settings shows.
204const SECTIONS: [id: string, label: string][] = [
205 ['focus', 'working on'], ['context', 'context'], ['assumptions', 'assumptions'], ['undone', 'loose ends'],
206 ['asks', 'action items'], ['agents', 'subagents'], ['cost', 'cost and tokens'], ['changes', 'changes'],
207]
208const SETTINGS = 'tether-settings'
209const DEFAULT_LAYOUT: Layout = { order: SECTIONS.map(([id]) => id), hidden: [] }
210// The person's order and hidden sections: saved in $.store for every session, mirrored here so the pane redraws.
211const layout = atom({ plugin: 'tether', key: 'layout' } as const, DEFAULT_LAYOUT)
212
213/** Every section in the person's order; ids it doesn't know are dropped, new ones join at the end. */
214const ordered = (l: Layout): string[] => {
215 const known = SECTIONS.map(([id]) => id)
216 const kept = l.order.filter(id => known.includes(id))
217 return [...kept, ...known.filter(id => !kept.includes(id))]
218}
219/** The sections to draw, in order. */
220const arrange = (l: Layout): string[] => ordered(l).filter(id => !l.hidden.includes(id))
221const isLayout = (v: unknown): v is Layout =>
222 typeof v === 'object' && v !== null && Array.isArray((v as Layout).order) && Array.isArray((v as Layout).hidden)
223// Open asks: the list, the next id, and the texts of the last 20 answered or resolved asks,
224// so the backstop never re-adds one.
225const asks = atom({ plugin: 'tether', key: 'asks' } as const, [])
226const nextAsk = atom({ plugin: 'tether', key: 'nextAsk' } as const, 1)
227const closedAsks = atom({ plugin: 'tether', key: 'closedAsks' } as const, [])
228// Main turns started; asks added in one turn share it, the backstop's included.
229const askBatch = atom({ plugin: 'tether', key: 'askBatch' } as const, 0)
230
231// The session's stored value may predate a field added since: fill the gaps.
232const whole = (s: Stats): Stats => ({
233 ...EMPTY,
234 ...s,
235 agents: (s.agents ?? []).map(row => ({ ...BLANK_ROW, ...row })),
236})
237
238const compact = (n: number): string => {
239 if (n >= 1_000_000) {
240 return `${(n / 1_000_000).toFixed(2)}M`
241 }
242
243 return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : `${n}`
244}
245
246const elapsed = (ms: number): string => {
247 const seconds = Math.max(0, Math.floor(ms / 1000))
248 const mm = `${Math.floor(seconds / 60)}`.padStart(2, '0')
249
250 return `${mm}:${`${seconds % 60}`.padStart(2, '0')}`
251}
252
253const money = (usd: number | null): string => {
254 if (usd === null) {
255 return '--'
256 }
257
258 return usd > 0 && usd < 0.005 ? '<$0.01' : `$${usd.toFixed(2)}`
259}
260
261// A running subagent's clock in whole minutes, so the timer redraws the pane once a minute
262// rather than every second; a finished one keeps mm:ss.
263const took = (row: AgentRow, runs: boolean, now: number): string =>
264 runs ? `${Math.floor((now - row.startedAt) / MINUTE)}m` : elapsed((row.endedAt ?? row.lastSeenAt) - row.startedAt)
265
266const tokensRead = (s: Stats): number => s.freshTokens + s.cacheReadTokens + s.cacheWriteTokens
267
268// A row nobody spawned in view of this mod (a fork of the engine's, an agent
269// older than this load) may never report an end, so silence ends it.
270const isRunning = (row: AgentRow, now: number): boolean =>
271 row.endedAt === null && (row.isSpawned || now - row.lastSeenAt < 2 * MINUTE)
272
273// What the timer moves on screen: each running subagent's minutes, and whether it still runs.
274const face = (s: Stats): string =>
275 JSON.stringify(s.agents.map(row => (isRunning(row, s.now) ? Math.floor((s.now - row.startedAt) / MINUTE) : -1)))
276
277const withAgent = (agents: AgentRow[], id: string, at: number, change: (row: AgentRow) => AgentRow): AgentRow[] => {
278 const known = agents.find(row => row.id === id) ?? {
279 ...BLANK_ROW,
280 id,
281 label: `agent ${id.slice(0, 6)}`,
282 startedAt: at,
283 }
284 const rows = [...agents.filter(row => row.id !== id), { ...change(known), lastSeenAt: at }]
285 // Over the cap, the oldest finished rows go; a running one never does.
286 const surplus = rows.filter(row => row.endedAt !== null).slice(0, Math.max(0, rows.length - MAX_AGENTS))
287
288 return rows.filter(row => !surplus.includes(row))
289}
290
291const measure = async ($: EngineInterface): Promise<void> => {
292 const { context, cost } = await $.session.usage({ breakdown: 'summary' })
293 const breakdown = context.breakdown
294 const snapshot = breakdown === undefined ? null : toSnapshot(breakdown, await $.session.messages({ as: 'api' }))
295 const deferredTokens = (breakdown?.categories ?? []).filter(row => row.kind === 'deferred').reduce((sum, row) => sum + row.tokens, 0)
296
297 // The breakdown measures against the window compaction works to, which is
298 // what its rows add up to; a window just compacted has no reading of its
299 // own until its next response, so nothing older is carried over it.
300 await update($, stats, raw => ({
301 ...whole(raw),
302 costUsd: cost?.usd ?? raw.costUsd,
303 ctxPercent: breakdown?.percentage ?? context.percent ?? null,
304 ctxTokens: context.tokens ?? breakdown?.totalTokens ?? null,
305 ctxWindow: breakdown?.rawMaxTokens ?? context.window,
306 snapshot,
307 deferredTokens,
308 needsMeasure: false,
309 }))
310}
311
312// The model's own report of an assumption: stored, shown at once, and
313// answered with the id a later note names to replace it.
314const recordAssumption = async ($: EngineInterface, input: Record<string, unknown>, agentId: string | undefined): Promise<string | null> => {
315 const say = (key: string): string => `${input[key] ?? ''}`.replace(/\s+/g, ' ').trim().slice(0, 240)
316 const text = say('assumption')
317
318 if (text === '') {
319 return null
320 }
321
322 const at = await $.clock.now()
323 const replaces = Number(say('replaces').replace(/\D/g, '')) || null
324 let id = 0
325
326 await update($, stats, raw => {
327 const s = whole(raw)
328 id = s.nextNote
329 const added: Assumption = {
330 id,
331 text,
332 basis: say('basis'),
333 affects: say('affects'),
334 at,
335 agent: agentId ?? null,
336 status: 'open',
337 replacedBy: null,
338 }
339
340 return {
341 ...s,
342 openedAt: s.openedAt || at,
343 now: at,
344 nextNote: id + 1,
345 assumptions: [
346 ...s.assumptions.map(one =>
347 one.id === replaces && one.status === 'open' ? { ...one, status: 'replaced' as const, replacedBy: id } : one,
348 ),
349 added,
350 ].slice(-30),
351 }
352 })
353
354 $.ui.toast(`Assumed: ${text.slice(0, 90)}`)
355
356 return `Noted as A${id}. The user can see it.`
357}
358
359const openSettings = ($: EngineInterface) => $.ui.open({ id: SETTINGS, title: 'Tether settings', focus: true, closeOnEscape: true })
360
361const saveLayout = async ($: EngineInterface, next: Layout): Promise<void> => {
362 await update($, layout, () => next)
363 await $.store.set('layout', next)
364}
365
366// Only the settings-hook events carry the session title. It is generated after the first
367// prompt, so it arrives with the second one; a sidebar rename arrives with the next.
368const followTitle = async ($: EngineInterface, title: string | undefined): Promise<void> => {
369 const t = title?.trim().slice(0, 500) ?? ''
370 if (t !== '' && !(await read($, isFocusCustom)) && (await read($, focus)) !== t) {
371 await update($, focus, () => t)
372 }
373}
374
375const rememberAsks = ($: EngineInterface, texts: string[]) =>
376 texts.length ? update($, closedAsks, l => [...l, ...texts].slice(-20)) : Promise.resolve([])
377
378const applyAsks = async ($: EngineInterface, change: Change): Promise<void> => {
379 const batch = await read($, askBatch)
380 const added: Ask[] = []
381 for (const item of change.add) {
382 let id = 0
383 await update($, nextAsk, n => ((id = n), n + 1))
384 added.push({ ...item, id, batch })
385 }
386 const before = await read($, asks)
387 // A new batch replaces every ask from an earlier turn, answered or not: the list never goes stale.
388 const gone = new Set([...change.resolve, ...(added.length ? before.filter(a => (a.batch ?? 0) < batch).map(a => a.id) : [])])
389 await update($, asks, l => [...l.filter(a => !gone.has(a.id)), ...added])
390 await rememberAsks($, before.filter(a => gone.has(a.id)).map(a => a.text))
391 if (added.length) {
392 $.ui.toast(`Waiting on you: ${(added[0]?.text ?? '').slice(0, 80)}`)
393 }
394}
395
396// A sent prompt that holds a button's drafted reply answers that ask: drop it now
397// rather than wait for Claude to resolve it with `track`.
398const settleDrafted = async ($: EngineInterface, sent: string): Promise<void> => {
399 const answered = (await read($, asks)).filter(a => a.drafted !== undefined && sent.includes(a.drafted))
400 if (answered.length === 0) {
401 return
402 }
403 const gone = new Set(answered.map(a => a.id))
404 await update($, asks, l => l.filter(a => !gone.has(a.id)))
405 await rememberAsks($, answered.map(a => a.text))
406}
407
408// The backstop: when Claude's reply looks like it asks something and it never called
409// `track`, Haiku reads the reply and adds what it missed.
410const extractAsks = async ($: EngineInterface, prompt: string, answer: string): Promise<void> => {
411 const list = await read($, asks)
412 const done = await read($, closedAsks)
413 const r = await $.model.complete({
414 model: 'haiku',
415 system: BACKSTOP_SYSTEM,
416 prompt: `Open list:\n${list.map(a => `#${a.id} [${a.kind}] ${a.text}`).join('\n') || '(empty)'}\n\nRecently closed (never re-add):\n${done.join('\n') || '(none)'}\n\nUser's last message:\n${prompt.slice(-4000)}\n\nAssistant's reply:\n${answer.slice(-12000)}`,
417 maxTokens: 800,
418 effort: 'low',
419 })
420 if (!r.isAnswered) {
421 return
422 }
423 // Haiku re-adds reworded copies of open and just-answered asks; drop them here rather than trust the prompt.
424 const change = parseChange(r.text)
425 const seen = [...list.map(a => a.text), ...done]
426 await applyAsks($, { ...change, add: change.add.filter(a => !isDuplicate(a.text, seen) && (seen.push(a.text), true)) })
427}
428
429export const register: Register = on => {
430 // The module's own: a reload forgets it, which costs at most one backstop run.
431 let isTracked = false
432
433 on('session.start', async ($, e, next) => {
434 await $.command.register({
435 name: 'tether',
436 description: 'Open the session dashboard pane',
437 })
438
439 // Opens itself on every session start. Unasked, a terminal places it from 144 columns;
440 // narrower, it waits until the terminal widens or /tether opens it.
441 $.ui.open({ id: PANE, title: 'Tether' }).catch((err: unknown) => $.ui.log(`tether open: ${String(err)}`, { to: 'debug' }))
442
443 const tick = async (): Promise<void> => {
444 const s = whole(await read($, stats))
445 const at = await $.clock.now()
446
447 // A compaction left the context reading to be taken again.
448 if (s.needsMeasure) {
449 await measure($)
450 }
451
452 // Written only when a figure on screen would change, so the band and pane
453 // redraw about once a minute instead of every second.
454 if (s.tickError !== '' || face(s) !== face({ ...s, now: at })) {
455 await update($, stats, one => ({ ...whole(one), tickError: '', now: at }))
456 }
457 }
458 const failed = (where: string) => async (err: unknown): Promise<void> => {
459 $.ui.log(`tether ${where}: ${String(err)}`, { to: 'debug' })
460 await update($, stats, one => ({ ...whole(one), tickError: `${where}: ${String(err)}`.slice(0, 120) }))
461 }
462
463 // Started before anything that could fail, so the clock always runs.
464 $.clock.every(1000, () => {
465 tick().catch(failed('timer'))
466 })
467
468 await $.tool.register({
469 name: 'note_assumption',
470 description:
471 'Record one assumption you are making about what the user wants, before acting on it. The user sees it at once and can reject it. Use it for judgment calls the user did not state; not for verified facts or routine steps.',
472 inputSchema: {
473 type: 'object',
474 properties: {
475 assumption: { type: 'string', description: 'The assumption, as one plain sentence.' },
476 basis: { type: 'string', description: 'What led you to it, in a few words.' },
477 affects: { type: 'string', description: 'What it changes: the files, behaviour or scope that would differ if it is wrong.' },
478 replaces: { type: 'string', description: 'The id of an earlier assumption this one overturns, such as A3. Leave out otherwise.' },
479 },
480 required: ['assumption', 'basis', 'affects'],
481 additionalProperties: false,
482 },
483 })
484
485 await $.tool.register({
486 name: 'track',
487 description: "Add or resolve items in the user's Action items panel: what you are waiting on the user for.",
488 inputSchema: SCHEMA,
489 })
490
491 const saved = await $.store.get('layout').catch(() => undefined)
492 if (isLayout(saved)) {
493 await update($, layout, () => saved)
494 }
495
496 return next(e)
497 })
498
499 on('command.run', { command: 'tether' }, async ($, e) => {
500 const word = `${e.args ?? ''}`.trim().toLowerCase()
501
502 if (word === 'check on' || word === 'check off') {
503 await update($, stats, s => ({ ...whole(s), isChecking: word === 'check on' }))
504
505 return { text: `Second-model check of finished turns is ${word.slice(6)}.` }
506 }
507
508 if (word === 'settings') {
509 await openSettings($)
510
511 return { text: 'Tether settings opened.' }
512 }
513
514 await $.ui.open({ id: PANE, title: 'Tether' })
515 await measure($)
516
517 return { text: 'Session dashboard opened.' }
518 })
519
520 on('agent.spawn', async ($, e, next) => {
521 const spawned = await next(e)
522
523 if (spawned.agentId !== undefined) {
524 const id = spawned.agentId
525 const at = await $.clock.now()
526 await update($, stats, raw => ({
527 ...whole(raw),
528 now: at,
529 agents: withAgent(whole(raw).agents, id, at, row => ({
530 ...row,
531 label: e.description,
532 type: e.subagentType,
533 model: spawned.model,
534 isSpawned: true,
535 })),
536 }))
537 }
538
539 return spawned
540 })
541
542 // What the person asked for, kept for the check at the end of the turn.
543 on('prompt.submit', async ($, e, next) => {
544 const kind: string = e.origin.kind
545
546 if (kind === 'composer' || kind === 'bridge' || kind === 'sdk') {
547 await update($, stats, raw => ({ ...whole(raw), lastPrompt: e.text.slice(0, 4000) }))
548 await settleDrafted($, e.text)
549 }
550
551 return next(e)
552 })
553
554 on('classic.SessionStart', async ($, e, next) => {
555 await followTitle($, e.session_title)
556 return next(e)
557 })
558 on('classic.UserPromptSubmit', async ($, e, next) => {
559 await followTitle($, e.session_title)
560 return next(e)
561 })
562
563 on('turn.start', async ($, e, next) => {
564 isTracked = false
565 await update($, askBatch, n => n + 1)
566
567 return next(e)
568 })
569
570 on('prompt.compose', async ($, e, next) => {
571 const composed = await next(e)
572
573 return {
574 sections: [...composed.sections, { id: 'tether:assumptions', text: GUIDE, scope: 'session' as const }, { id: 'tether:asks', text: GUIDANCE, scope: 'session' as const }],
575 }
576 })
577
578 // Each request of a loop. A subagent's request means it is at work, even
579 // one that had finished an earlier run.
580 on('turn.step', async function* ($, e, next) {
581 const at = await $.clock.now()
582 const agentId = e.agentId
583
584 await update($, stats, raw => {
585 const s = whole(raw)
586
587 return agentId === undefined
588 ? { ...s, now: at }
589 : { ...s, now: at, agents: withAgent(s.agents, agentId, at, row => ({ ...row, endedAt: null })) }
590 })
591
592 return yield* next(e)
593 })
594
595 on('tool.call', async ($, e, next) => {
596 // The mod's own tool is answered here and is not counted as a tool call.
597 const called: string = e.tool
598 const calledBy = e.agentId
599
600 if (called === TRACK) {
601 isTracked = true
602 // The tool's arguments sit on e itself, beside tool and tool_use_id.
603 await applyAsks($, parseChange(JSON.stringify(e)))
604 const open = (await read($, asks)).map(a => `#${a.id} [${a.kind}] ${a.text}`)
605 const text = open.length ? `Action items:\n${open.join('\n')}` : 'No action items.'
606
607 return { result: text, text } as never
608 }
609
610 if (called === NOTE) {
611 const noted = await recordAssumption($, e as unknown as Record<string, unknown>, calledBy)
612
613 return (noted === null
614 ? { deny: 'note_assumption needs an `assumption`: one plain sentence.' }
615 : { result: noted }) as never
616 }
617
618 const ran = await next(e)
619
620 const at = await $.clock.now()
621 const agentId = e.agentId
622 const hasFailed = ran.deny !== undefined || ran.isError === true
623
624 await update($, stats, raw => {
625 const s = whole(raw)
626
627 return {
628 ...s,
629 openedAt: s.openedAt || at,
630 now: at,
631 tools: s.tools + 1,
632 fails: s.fails + (hasFailed ? 1 : 0),
633 turnTools: s.turnTools + (agentId === undefined ? 1 : 0),
634 agents: agentId === undefined
635 ? s.agents
636 : withAgent(s.agents, agentId, at, row => ({ ...row, endedAt: null, tools: row.tools + 1 })),
637 ...footprint(s, called, e as unknown as Record<string, unknown>, at, hasFailed, agentId ?? null),
638 }
639 })
640
641 // Put-off work written into a file: the line that says so, and where.
642 if (!hasFailed && WRITERS.includes(called)) {
643 const input = e as unknown as Record<string, unknown>
644 const wrote = `${input.new_string ?? input.content ?? input.new_source ?? ''}`
645 const row = wrote.split('\n').find(one => WROTE.test(one))
646
647 if (row !== undefined) {
648 const file = `${input.file_path ?? input.notebook_path ?? 'a file'}`.split(/[\\/]/).pop() ?? 'a file'
649 await recordUndone($, [`Finish "${row.trim().slice(0, 90)}" in ${file}`], 'code')
650 }
651 }
652
653 return ran
654 })
655
656 on('turn.complete', async ($, e, next) => {
657 const at = await $.clock.now()
658 const agentId = e.agentId
659 const out = e.usage?.output_tokens ?? 0
660 // `input_tokens` is the uncached part alone; a cached prompt reports
661 // nearly all of its input under the two cache fields.
662 const fresh = e.usage?.input_tokens ?? 0
663 const cacheRead = e.usage?.cache_read_input_tokens ?? 0
664 const cacheWrite = e.usage?.cache_creation_input_tokens ?? 0
665
666 await update($, stats, raw => {
667 const s = whole(raw)
668
669 return {
670 ...s,
671 openedAt: s.openedAt || at,
672 now: at,
673 turns: s.turns + (agentId === undefined ? 1 : 0),
674 freshTokens: s.freshTokens + fresh,
675 cacheReadTokens: s.cacheReadTokens + cacheRead,
676 cacheWriteTokens: s.cacheWriteTokens + cacheWrite,
677 outTokens: s.outTokens + out,
678 agents: agentId === undefined
679 ? s.agents
680 : withAgent(s.agents, agentId, at, row => ({
681 ...row,
682 endedAt: at,
683 model: e.usage?.model ?? row.model,
684 tokensRead: row.tokensRead + fresh + cacheRead + cacheWrite,
685 tokensOut: row.tokensOut + out,
686 hasFailed: e.reason === 'error' || e.reason === 'aborted',
687 })),
688 }
689 })
690
691 if (agentId === undefined) {
692 await measure($)
693
694 await clearUndone($, [], whole(await read($, stats)).turns)
695 const turn = whole(await read($, stats))
696 await update($, stats, raw => ({ ...whole(raw), turnTools: 0 }))
697
698 if (e.reason === 'answer') {
699 const hints = deferrals(e.answer)
700 const hasOpen = turn.undone.some(one => one.status === 'open')
701
702 if (!isTracked && looksLikeAsk(e.answer)) {
703 // Not awaited: off the turn's path.
704 extractAsks($, turn.lastPrompt, e.answer).catch((err: unknown) =>
705 $.ui.log(`tether asks backstop: ${String(err)}`, { to: 'debug' }),
706 )
707 }
708
709 // A put-off sentence alone ("I haven't deployed it.") is too thin to act on,
710 // so it goes through the checker to come back as a concrete action, or not at all.
711 // While any loose end is open the checker runs every turn, to clear the ones now settled.
712 if (turn.isChecking && (hints.length > 0 || hasOpen || (turn.turnTools >= CHECK_MIN_TOOLS && turn.lastPrompt !== ''))) {
713 // Not awaited: the turn ends now and the finding arrives when it does.
714 check($, turn.lastPrompt, e.answer, hints, hints.length > 0 ? 'said' : 'checker').catch((err: unknown) =>
715 $.ui.log(`tether checker: ${String(err)}`, { to: 'debug' }),
716 )
717 }
718 }
719 }
720
721 return next(e)
722 })
723
724 // A compacted main conversation is a different window: measure it again
725 // rather than leave the figures of the one it replaced.
726 on('session.compact', async ($, e, next) => {
727 const done = await next(e)
728
729 if (e.agentId !== undefined || e.trigger === 'precompute' || done.messages === undefined) {
730 return done
731 }
732
733 // The engine swaps the conversation in after this hook returns, so a
734 // reading taken here is still the old one: show the compaction's own
735 // count now and measure again on the next tick.
736 const { tokensBefore, tokensAfter } = done
737
738 await update($, stats, raw => {
739 const s = whole(raw)
740 const sized = tokensAfter !== undefined && s.ctxWindow !== null && s.ctxWindow > 0
741
742 return {
743 ...s,
744 needsMeasure: true,
745 ctxTokens: tokensAfter ?? s.ctxTokens,
746 ctxPercent: sized ? (tokensAfter / (s.ctxWindow ?? 1)) * 100 : s.ctxPercent,
747 snapshot: sized ? null : s.snapshot,
748 }
749 })
750
751 if (tokensBefore !== undefined && tokensAfter !== undefined) {
752 $.ui.toast(`Compacted: ${compact(tokensBefore)} to ${compact(tokensAfter)} tokens`)
753 }
754
755 return done
756 })
757
758
759 // Settings: show or hide each section and move it up or down. Saved for every new session.
760 on('ui.render', { component: 'Pane', requestId: SETTINGS }, async ($, e) => {
761 const { Box, Button, Text } = $.ui.resolve(e)
762 const l = await read($, layout)
763 const ids = ordered(l)
764 const move = (id: string, by: number) => {
765 const next = [...ids]
766 const at = next.indexOf(id)
767 const to = at + by
768 if (to < 0 || to >= next.length) return
769 ;[next[at], next[to]] = [next[to] as string, next[at] as string]
770 return saveLayout($, { ...l, order: next })
771 }
772 const toggle = (id: string) =>
773 saveLayout($, { ...l, hidden: l.hidden.includes(id) ? l.hidden.filter(one => one !== id) : [...l.hidden, id] })
774
775 return (
776 <Box flexDirection="column" paddingX={1}>
777 <Text dimColor>Pick the sections you want and their order. Saved for every new session.</Text>
778 {ids.map((id, i) => {
779 const isHidden = l.hidden.includes(id)
780 return (
781 <Box key={`set-${id}`} flexDirection="row" justifyContent="space-between">
782 <Text dimColor={isHidden} strikethrough={isHidden}>{SECTIONS.find(([one]) => one === id)?.[1] ?? id}</Text>
783 <Box flexDirection="row" columnGap={1}>
784 <Button key={`set-show-${id}`} label={isHidden ? 'Show' : 'Hide'} onPress={() => toggle(id)} />
785 <Button key={`set-up-${id}`} label="↑" onPress={() => move(id, -1)} dimColor={i === 0} />
786 <Button key={`set-down-${id}`} label="↓" onPress={() => move(id, 1)} dimColor={i === ids.length - 1} />
787 </Box>
788 </Box>
789 )
790 })}
791 <Box key="set-footer" flexDirection="row" columnGap={1} marginTop={1}>
792 <Button key="set-reset" label="Reset" onPress={() => saveLayout($, DEFAULT_LAYOUT)} />
793 <Button key="set-done" label="Done" onPress={() => $.ui.close({ id: SETTINGS })} />
794 </Box>
795 </Box>
796 )
797 })
798
799 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
800 const ui = $.ui.resolve(e)
801 const { Box, Button, Markdown, Text } = ui
802 // Mobile draws no text field; there the note shows but can't be edited.
803 const Input = 'Input' in ui ? ui.Input : undefined
804 // The terminal has no Svg; it gets the text bar.
805 const Svg = e.surface === 'desktop' ? $.ui.resolve(e).Svg : undefined
806 const s = whole(await read($, stats))
807 const folded = await read($, collapsed)
808 const waiting = await read($, asks)
809 const inner = Math.max(16, e.props.bodyColumns - 4)
810 const viewedId = e.props.view?.agentId
811
812 const line = (label: string, value: string, color?: string) => (
813 <Box flexDirection="row" justifyContent="space-between">
814 <Text dimColor>{label}</Text>
815 <Text color={color} bold>{value}</Text>
816 </Box>
817 )
818 const fold = (id: string) => update($, collapsed, list => (list.includes(id) ? list.filter(one => one !== id) : [...list, id]))
819 // One card per section, styled like the context card. The header toggles
820 // the body; `always` stays in view while it is folded.
821 const section = (id: string, label: string, summary: JSX.Element | string, body: JSX.Element[], always?: JSX.Element) => {
822 const isOpen = !folded.includes(id)
823
824 return (
825 <Box key={`p-${id}`} flexDirection="column" borderStyle="round" borderColor={STONE} borderDimColor paddingX={1}>
826 <Box flexDirection="row" justifyContent="space-between">
827 <Box flexDirection="row">
828 <Text color={ACCENT}>{'◆ '}</Text>
829 <Button key={`fold-${id}`} plain label={`${label} ${isOpen ? '▾' : '▸'}`} onPress={() => fold(id)} />
830 </Box>
831 {typeof summary === 'string' ? <Text dimColor>{summary}</Text> : summary}
832 </Box>
833 {always}
834 {isOpen && <Box flexDirection="column">{body}</Box>}
835 </Box>
836 )
837 }
838 const draft = (text: string) => $.prompt.fill({ text, mode: 'append' })
839 // Approve clears an assumption from the panel; nothing is sent.
840 const clearNote = (id: number) => update($, stats, raw => ({
841 ...whole(raw),
842 assumptions: whole(raw).assumptions.map(one => (one.id === id ? { ...one, status: 'cleared' as const } : one)),
843 }))
844 // "Reject" puts the correction in the prompt box for the person to finish
845 // and send; nothing reaches the model until they do.
846 const flag = async (id: number, text: string): Promise<void> => {
847 await draft(`Assumption A${id} is wrong ("${text}"). Instead: \n\n`)
848 await update($, stats, raw => ({
849 ...whole(raw),
850 assumptions: whole(raw).assumptions.map(one => (one.id === id ? { ...one, status: 'flagged' as const } : one)),
851 }))
852 }
853 // "Do now" drafts the instruction; the person sends it.
854 const push = async (id: number, text: string): Promise<void> => {
855 await draft(`You left this undone: "${text}". Do it now.\n\n`)
856 await update($, stats, raw => ({
857 ...whole(raw),
858 undone: whole(raw).undone.map(one => (one.id === id ? { ...one, status: 'sent' as const } : one)),
859 }))
860 }
861 const clear = (id?: number) => update($, stats, raw => ({
862 ...whole(raw),
863 undone: whole(raw).undone.map(one => (one.status === 'open' && (id === undefined || one.id === id) ? { ...one, status: 'cleared' as const } : one)),
864 }))
865 const todo = s.undone.filter(one => one.status === 'open').slice(-SHOWN_UNDONE).reverse()
866 const sources = { said: 'Claude said', code: 'in a file', checker: 'second model' }
867 const notes = (list: Assumption[]) => list.filter(one => one.status !== 'cleared').slice(-SHOWN_NOTES).reverse().map((note, i) => {
868 const isOpen = note.status === 'open'
869 const who = note.agent === null ? '' : ` · ${s.agents.find(row => row.id === note.agent)?.label ?? 'subagent'}`
870
871 return (
872 <Box key={`note-${note.id}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
873 <Box flexDirection="row" justifyContent="space-between">
874 <Text color={isOpen ? SLATE : undefined} dimColor={!isOpen} bold>{`A${note.id} · ${elapsed(note.at - s.openedAt)}${who}`.slice(0, Math.max(8, inner - 24))}</Text>
875 <Box flexDirection="row" columnGap={1}>
876 {!isOpen && <Text color={note.status === 'flagged' ? ACCENT : undefined} dimColor={note.status !== 'flagged'}>{note.status === 'flagged' ? 'you flagged it' : `replaced by A${note.replacedBy ?? '?'}`}</Text>}
877 <Button key={`ok-${note.id}`} label={isOpen ? 'Approve' : 'Clear'} onPress={() => clearNote(note.id)} />
878 {isOpen && <Button key={`wrong-${note.id}`} label={`${i + 1} Reject`} hotkey={`${i + 1}`} onPress={() => flag(note.id, note.text)} />}
879 </Box>
880 </Box>
881 <Text dimColor={!isOpen} strikethrough={note.status === 'replaced'} wrap="wrap">{note.text}</Text>
882 {isOpen && note.basis !== '' && <Text dimColor wrap="wrap">{`because ${note.basis}`}</Text>}
883 {isOpen && note.affects !== '' && <Text dimColor wrap="wrap">{`affects ${note.affects}`}</Text>}
884 </Box>
885 )
886 })
887 const agentRows = (limit: number) => [...s.agents].sort((a, b) => b.startedAt - a.startedAt).slice(0, limit).map(row => {
888 const runs = isRunning(row, s.now)
889 const color = runs ? OLIVE : row.hasFailed ? ACCENT : SLATE
890 const pointer = row.id === viewedId ? '▶ ' : ''
891
892 return (
893 <Box flexDirection="column">
894 <Box flexDirection="row" justifyContent="space-between">
895 <Text color={color} bold>{`${pointer}${runs ? '●' : row.hasFailed ? '✗' : '✓'} ${row.label}`.slice(0, Math.max(8, inner - 7))}</Text>
896 <Text color={color}>{took(row, runs, s.now)}</Text>
897 </Box>
898 <Text dimColor>
899 {` ${runs ? 'running' : row.hasFailed ? 'stopped' : 'done'} · ${row.type} · ${row.model ?? 'model unknown'} · ${row.tools} tool calls`.slice(0, inner)}
900 </Text>
901 </Box>
902 )
903 })
904 const shownNotes = (list: Assumption[]) => list.filter(one => one.status === 'open').length
905
906 // A subagent's transcript is on screen: the pane is that agent's.
907 if (viewedId !== undefined) {
908 const row = s.agents.find(one => one.id === viewedId)
909 const runs = row !== undefined && isRunning(row, s.now)
910 const color = row === undefined ? SOFT : runs ? OLIVE : row.hasFailed ? ACCENT : SLATE
911 const own = s.assumptions.filter(note => note.agent === viewedId)
912
913 return (
914 <Box flexDirection="column">
915 {section('agent', 'subagent in view', runs ? 'running' : '', row === undefined
916 ? [<Text color={color} bold>{`agent ${viewedId.slice(0, 6)}`}</Text>, <Text dimColor>No activity seen yet</Text>]
917 : [
918 <Text color={color} bold>{row.label.slice(0, inner)}</Text>,
919 line('Status', runs ? 'running' : row.hasFailed ? 'stopped' : 'done', color),
920 line('Time', took(row, runs, s.now), color),
921 line('Type', row.type),
922 line('Model', row.model ?? 'unknown'),
923 line('Tool calls', `${row.tools}`),
924 line('Tokens read', compact(row.tokensRead)),
925 line('Tokens written', compact(row.tokensOut)),
926 <Text dimColor>Tokens update each time it finishes a run</Text>,
927 ])}
928 {section('assumptions', 'assumptions', `${shownNotes(own)} open`, [
929 ...(own.length === 0 ? [<Text dimColor>None reported yet</Text>] : []),
930 ...notes(own),
931 ])}
932 {section('agents', 'all subagents', `${s.agents.length}`, agentRows(6))}
933 <Text dimColor>Context and cache belong to the main session. Switch back to see them.</Text>
934 </Box>
935 )
936 }
937
938 // Open asks: each button drafts its reply into the prompt box, and the ask clears when
939 // that reply is sent. A finished reply ends in a blank line, so several pressed in a row
940 // stack as paragraphs. Discuss and Answer leave the cursor after their colon for typing,
941 // and start a conversation, so Discuss's ask stays until Claude resolves it with `track`.
942 // Dismiss drops it unsent.
943 const dismissAsk = async (a: Ask): Promise<void> => {
944 await update($, asks, l => l.filter(x => x.id !== a.id))
945 await rememberAsks($, [a.text])
946 }
947 // Clear all drops every ask unsent, for a list gone stale; remembered so the backstop doesn't re-add them.
948 const clearAsks = async (): Promise<void> => {
949 const all = await read($, asks)
950 await update($, asks, () => [])
951 await rememberAsks($, all.map(a => a.text))
952 }
953 const askButtons = (a: Ask) => {
954 const b = (name: string, label: string, text: string) => (
955 <Button
956 key={`${name}${a.id}`}
957 label={label}
958 onPress={async () => {
959 await draft(text.endsWith(': ') ? text : `${text}\n\n`)
960 await update($, asks, l => l.map(x => (x.id === a.id ? { ...x, drafted: text.trim() } : x)))
961 }}
962 />
963 )
964 const discuss = <Button key={`discuss${a.id}`} label="Discuss" onPress={() => draft(SEND.discuss(a))} />
965 switch (a.kind) {
966 case 'decision':
967 return [b('approve', 'Approve', SEND.approve(a)), b('deny', 'Deny', SEND.deny(a)), discuss]
968 case 'choice':
969 return [...(a.options ?? []).map((o, i) => b(`pick${i}-`, o, SEND.pick(a, o))), discuss]
970 case 'question':
971 return [b('answer', 'Answer', SEND.answer(a)), discuss, <Button key={`dismiss${a.id}`} label="Dismiss" onPress={() => dismissAsk(a)} />]
972 case 'review':
973 return [b('good', 'Looks good', SEND.good(a)), discuss]
974 case 'todo':
975 return [b('done', 'Done', SEND.done(a)), b('cancel', 'Cancel', SEND.cancel(a)), discuss]
976 }
977 }
978
979 const snap = s.snapshot
980 const legend = snap === null
981 ? []
982 : [...snap.slices.filter(x => x.label !== 'free').sort((a, b) => b.tokens - a.tokens), ...snap.slices.filter(x => x.label === 'free')]
983 const bar = snap === null
984 ? <Text dimColor>Measured after the next turn</Text>
985 : Svg
986 ? <Svg source={barSvg(snap)} alt={`context ${snap.percent}% full`} height={10} isInteractive />
987 : <Text>{barCells(snap, inner).map((run, i) => <Text key={`b${i}`} color={run.color}>{run.text}</Text>)}</Text>
988 const ctxSummary = snap === null
989 ? ''
990 : (
991 <Text>
992 <Text bold>{fmt(snap.used)}</Text>
993 <Text dimColor>{` of ${fmt(snap.window)} `}</Text>
994 <Text bold color={INK} backgroundColor={pillColor(snap.percent)}>{` ${snap.percent}% `}</Text>
995 </Text>
996 )
997 const check = checkState(s)
998 const checkLine = {
999 none: '',
1000 passed: `Checked after the last edit: passed (${s.lastCheck?.text ?? ''})`,
1001 failed: `Checked after the last edit: failed (${s.lastCheck?.text ?? ''})`,
1002 unchecked: 'Not checked since the last edit: no tests, lint or build ran after it',
1003 }[check]
1004 const changesSummary = [
1005 s.effects.length > 0 ? `${s.effects.length} outside` : '',
1006 s.touched.length > 0 ? `${s.touched.length} files` : '',
1007 check === 'none' ? '' : check,
1008 ].filter(Boolean).join(' · ')
1009 const read_ = tokensRead(s)
1010 const cached = read_ === 0 ? 0 : Math.round((s.cacheReadTokens / read_) * 100)
1011
1012 const note = await read($, focus)
1013 const isEditing = (await read($, isEditingFocus)) || note === ''
1014 const saveFocus = async (value: string): Promise<void> => {
1015 await update($, focus, () => value.trim().slice(0, 500))
1016 await update($, isFocusCustom, () => value.trim() !== '')
1017 await update($, isEditingFocus, () => false)
1018 }
1019
1020 // Every card is built, then shown in the person's saved order, hidden ones left out (Settings).
1021 const cards: Record<string, JSX.Element> = {
1022 // Folded, the note rides on the header line, cut with an ellipsis; open, it gets its own row and Edit.
1023 focus: section('focus', 'working on', folded.includes('focus') && note !== ''
1024 ? <Box key="focus-summary" flexGrow={1} flexShrink={1} marginLeft={1} minWidth={0}><Text wrap="truncate-end">{note}</Text></Box>
1025 : '', [
1026 ...(isEditing && Input
1027 ? [
1028 <Box key="focus-field" width="100%">
1029 <Input key="focus-input" placeholder="What are we working on? Enter to save" value={note} submitLabel="save" onSubmit={(v: string) => saveFocus(v)} />
1030 </Box>,
1031 ]
1032 : [
1033 <Box key="focus-row" flexDirection="row" justifyContent="space-between">
1034 <Text wrap="wrap">{note === '' ? 'Nothing noted.' : note}</Text>
1035 {Input && <Button key="focus-edit" label="Edit" onPress={() => update($, isEditingFocus, () => true)} />}
1036 </Box>,
1037 ]),
1038 ]),
1039 context: section('context', 'context', ctxSummary, [
1040 ...legend.map(sl => (
1041 <Box key={`leg-${sl.label}`} flexDirection="row" justifyContent="space-between">
1042 <Text wrap="truncate">
1043 <Text color={sl.color}>{'■ '}</Text>
1044 <Text>{sl.label}</Text>
1045 {sl.note ? <Text dimColor>{` · ${sl.note}`}</Text> : null}
1046 </Text>
1047 <Text>
1048 <Text bold>{fmt(sl.tokens)}</Text>
1049 {sl.label === 'free' ? null : <Text dimColor>{` ${sl.percent}%`.padStart(5)}</Text>}
1050 </Text>
1051 </Box>
1052 )),
1053 ...(s.deferredTokens > 0 ? [<Text dimColor>{`+ ${compact(s.deferredTokens)} of tools loaded only when needed`}</Text>] : []),
1054 ], bar),
1055 assumptions: section('assumptions', 'assumptions', `${shownNotes(s.assumptions)} open`, [
1056 ...(s.assumptions.every(one => one.status === 'cleared')
1057 ? [<Text dimColor>None open. They appear here as Claude makes them.</Text>]
1058 : []),
1059 ...notes(s.assumptions),
1060 ]),
1061 undone: section('undone', 'loose ends', todo.length === 0 ? '' : `${todo.length} open`, [
1062 ...(todo.length === 0 ? [<Text dimColor>Nothing flagged. Work Claude puts off shows up here.</Text>] : []),
1063 ...todo.map((one, i) => (
1064 <Box key={`undone-${one.id}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
1065 <Box flexDirection="row" justifyContent="space-between">
1066 <Text color={ACCENT} bold>{`U${one.id} · ${elapsed(one.at - s.openedAt)} · ${sources[one.source]}`.slice(0, Math.max(8, inner - 22))}</Text>
1067 <Box flexDirection="row" columnGap={1}>
1068 <Button key={`do-${one.id}`} label={`${UNDONE_KEYS[i] ?? ''} Do now`} hotkey={UNDONE_KEYS[i] ?? 'a'} onPress={() => push(one.id, one.text)} />
1069 <Button key={`clear-${one.id}`} label="Clear" onPress={() => clear(one.id)} />
1070 </Box>
1071 </Box>
1072 <Text wrap="wrap">{one.text}</Text>
1073 </Box>
1074 )),
1075 ...(todo.length > 0 ? [<Button key="undone-clear" label="x Clear all" hotkey="x" onPress={() => clear()} />] : []),
1076 ]),
1077 asks: section('asks', 'action items', waiting.length === 0 ? '' : `${waiting.length} waiting`, waiting.length === 0
1078 ? [<Text dimColor>Nothing waiting on you.</Text>]
1079 : [
1080 ...waiting.map((a, i) => (
1081 <Box key={`ask-${a.id}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
1082 <Text wrap="wrap">{a.text}</Text>
1083 {a.drafted !== undefined && <Text dimColor>In your prompt box. Clears when you send it.</Text>}
1084 <Box columnGap={1} rowGap={1} flexWrap="wrap">{askButtons(a)}</Box>
1085 </Box>
1086 )),
1087 <Box key="asks-clear-row" marginTop={1}><Button key="asks-clear" label="Clear all" onPress={clearAsks} /></Box>,
1088 ]),
1089 agents: section('agents', 'subagents', `${s.agents.filter(row => isRunning(row, s.now)).length} running`, [
1090 ...(s.agents.length === 0 ? [<Text dimColor>None started yet</Text>] : []),
1091 ...agentRows(6),
1092 ]),
1093 cost: section('cost', 'cost and tokens', money(s.costUsd), [
1094 line('Cost, whole session', money(s.costUsd)),
1095 line('Tokens read', compact(read_)),
1096 line(' served from cache', `${cached}%`, SLATE),
1097 line('Tokens written', compact(s.outTokens)),
1098 line('Turns', `${s.turns}`),
1099 <Text dimColor>Tokens and turns count from when this loaded</Text>,
1100 ...(s.tickError !== '' ? [<Text key="p-timer" color={ACCENT}>{`Timer error, ${s.tickError}`}</Text>] : []),
1101 ]),
1102 changes: section('changes', 'changes', changesSummary, [
1103 ...(s.effects.length === 0 && s.touched.length === 0
1104 ? [<Text key="ch-empty" dimColor>Nothing yet. Pushes, merges, deletes, installs and edited files show up here.</Text>]
1105 : []),
1106 ...(s.effects.length > 0 ? [<Text key="ch-out" dimColor>Outside this machine</Text>] : []),
1107 ...[...s.effects].reverse().slice(0, 10).map((one, i) => (
1108 <Box key={`ch-e${i}`} flexDirection="row" justifyContent="space-between">
1109 <Text color={one.isFailed ? ACCENT : undefined} wrap="truncate-end">{`${one.isFailed ? '✗' : '✓'} ${one.text}`}</Text>
1110 <Text dimColor>{elapsed(one.at - s.openedAt)}</Text>
1111 </Box>
1112 )),
1113 ...(s.touched.length > 0 ? [<Text key="ch-files" dimColor>{`Files edited (${s.touched.length})`}</Text>] : []),
1114 ...[...s.touched].reverse().slice(0, 8).map((one, i) => (
1115 <Box key={`ch-f${i}`} flexDirection="row" justifyContent="space-between">
1116 {/* A file: link opens the file as the app opens file links. */}
1117 <Markdown text={`[${one.path.split(/[\\/]/).slice(-2).join('/').replace(/[[\]]/g, '')}](file://${encodeURI(one.path)})`} />
1118 <Text dimColor>{one.edits === 1 ? '1 edit' : `${one.edits} edits`}</Text>
1119 </Box>
1120 )),
1121 ...(check === 'none' ? [] : [<Text key="ch-check" color={check === 'passed' ? OLIVE : ACCENT} wrap="wrap">{checkLine}</Text>]),
1122 ]),
1123 }
1124
1125 return (
1126 <Box flexDirection="column">
1127 {arrange(await read($, layout)).filter(id => cards[id] !== undefined).map(id => cards[id])}
1128 <Box key="settings-row" flexDirection="row" justifyContent="flex-end">
1129 <Button key="open-settings" plain label="Settings" onPress={() => openSettings($)} />
1130 </Box>
1131 </Box>
1132 )
1133 })
1134}
1135hooks/asks.ts 90 lines1// Action items: what Claude is waiting on the person for. Brought in from the open-asks plugin,
2// which the desk replaces; run one or the other, since both register a `track` tool.
3import type { Ask, AskKind } from '../types'
4
5export const TRACK = 'mcp__tether__track'
6export const KINDS: AskKind[] = ['decision', 'choice', 'question', 'review', 'todo']
7
8export const GUIDANCE = `Action items: the user keeps a panel of everything you are waiting on them for. Whenever your reply asks the user to decide, choose, answer, check something, or do something themselves, call ${TRACK} in the same turn with "add" (kinds: decision = yes/no before you act, naming the exact action; choice = 2-4 short "options"; question = information only they have; review = look at something and report back; todo = they act outside the chat). One standalone line per item that makes sense without the transcript. When the user's message answers an open item, or your work makes one moot, pass its id in "resolve". The tool returns the open list with ids.`
9
10export const SCHEMA = {
11 type: 'object',
12 properties: {
13 add: {
14 type: 'array',
15 items: {
16 type: 'object',
17 properties: {
18 kind: { type: 'string', enum: KINDS },
19 text: { type: 'string', description: 'One standalone line.' },
20 options: { type: 'array', items: { type: 'string' }, description: 'choice only: 2-4 short labels.' },
21 },
22 required: ['kind', 'text'],
23 },
24 },
25 resolve: { type: 'array', items: { type: 'number' }, description: 'Ids of answered or moot items.' },
26 },
27}
28
29export const BACKSTOP_SYSTEM = `You track what an AI coding assistant is waiting on its user for. You get the open list, the user's last message, and the assistant's reply. Answer with JSON only: {"add":[{"kind":"...","text":"...","options":["..."]}],"resolve":[ids]}.
30Kinds:
31- decision: a yes/no the assistant wants before acting ("Delete the remote branch?"). Text names the exact action.
32- choice: pick one of 2-4 named options; put them in "options", short labels.
33- question: needs information only the user has, answered in words.
34- review: the user should look at something (a page, a pane, a PR) and report whether it is right.
35- todo: something the user must do themselves outside the chat (run a command, change an admin setting, message someone).
36add: each such thing in the reply. One standalone line each that makes sense without the transcript. Skip anything already on the open list, anything the user's message already answered, rhetorical questions, and things the assistant will do itself.
37resolve: ids of open items the user's message answered or the reply settled or made moot.
38Nothing to change: {"add":[],"resolve":[]}.`
39
40// What each button drafts into the prompt box.
41// rekall/graph-memory/recall_hook.py BUTTON_PROMPT matches these shapes to skip recall; change both together.
42const quote = (a: Ask) => `"${a.text}"`
43export const SEND = {
44 approve: (a: Ask) => `Approved: ${a.text}`,
45 deny: (a: Ask) => `Denied: ${a.text}`,
46 pick: (a: Ask, opt: string) => `For ${quote(a)}, I pick: ${opt}`,
47 good: (a: Ask) => `Checked, looks good: ${a.text}`,
48 done: (a: Ask) => `Done: ${a.text}`,
49 cancel: (a: Ask) => `Not doing this, plan around it: ${a.text}`,
50 answer: (a: Ask) => `Answer to ${quote(a)}: `,
51 discuss: (a: Ask) => `Let's discuss ${quote(a)}: `,
52}
53
54export type Change = { add: Omit<Ask, 'id'>[]; resolve: number[] }
55
56// ponytail: tolerant parse of the model's JSON; a bad reply changes nothing
57export function parseChange(reply: string): Change {
58 try {
59 const raw = JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1))
60 const add: Omit<Ask, 'id'>[] = []
61 for (const a of Array.isArray(raw.add) ? raw.add : []) {
62 if (!KINDS.includes(a?.kind) || typeof a.text !== 'string' || !a.text.trim()) continue
63 const options = Array.isArray(a.options) ? a.options.filter((o: unknown) => typeof o === 'string' && o.trim()).slice(0, 4) : []
64 if (a.kind === 'choice' && options.length < 2) add.push({ kind: 'question', text: a.text.trim() })
65 else add.push({ kind: a.kind, text: a.text.trim(), ...(a.kind === 'choice' ? { options } : {}) })
66 }
67 const resolve = (Array.isArray(raw.resolve) ? raw.resolve : []).filter((n: unknown) => typeof n === 'number')
68 return { add, resolve }
69 } catch {
70 return { add: [], resolve: [] }
71 }
72}
73
74// ponytail: word overlap, not meaning. 0.8 calibrated 2026-10-07 on real asks: rewordings of one ask scored 1.0,
75// two different "delete the remote branch X?" asks scored 0.67. Upgrade path: an embedding check if rewordings slip through.
76const STOP = new Set(['the', 'and', 'for', 'from', 'with', 'this', 'that', 'instead', 'should', 'into', 'your', 'you', 'its', 'are', 'was', 'now', 'run', 'runs'])
77const words = (t: string) => new Set(t.toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 3 && !STOP.has(w)))
78export function isDuplicate(text: string, others: string[]) {
79 const a = words(text)
80 return others.some(o => {
81 const b = words(o)
82 const shared = [...a].filter(w => b.has(w)).length
83 return shared / Math.max(1, Math.min(a.size, b.size)) >= 0.8
84 })
85}
86
87// ponytail: cheap gate for the Haiku backstop; errs toward running it. Misses an ask with no "?" and none of these verbs.
88export const looksLikeAsk = (answer: string) =>
89 /\?|\b(you need to|you'll need to|please|run|press|type|approve|let me know|tell me)\b/i.test(answer)
90hooks/changes.ts 64 lines1// The changes section: what this session did outside the machine, which files it edited,
2// and whether a check ran after the last edit. Read from each tool call's input and result.
3import type { Effect, Stats } from '../types'
4
5// ponytail: a list of known shapes, not a judgment of every command. A side effect it doesn't
6// name is missed; add its shape here when one slips through.
7const OUTSIDE = new RegExp(
8 [
9 String.raw`\bgit\s+(push|reset\s+--hard|branch\s+-D|worktree\s+remove|stash\s+(drop|clear)|clean\s+-\w*f)\b`,
10 String.raw`\bgh\s+(pr\s+(create|merge|close|edit|comment|review)|issue\s+(create|close|edit|comment)|release\s+create|repo\s+(create|delete)|api\b.*-X\s*(POST|PUT|PATCH|DELETE))`,
11 String.raw`(^|[;&|]\s*)rm\s`,
12 String.raw`\bcurl\b.*(-X\s*(POST|PUT|PATCH|DELETE)|--data\b|\s-d\s)`,
13 String.raw`\bclaude\s+plugin\s+(install|uninstall|enable|disable|update|marketplace\s+(add|remove|update))\b`,
14 String.raw`\b(npm|pnpm|yarn)\s+publish\b|\bdocker\s+push\b|\bkubectl\s+(apply|delete)\b|\bterraform\s+(apply|destroy)\b|\bastro\s+deploy\b`,
15 ].join('|'),
16)
17// An MCP tool whose name says it writes: mcp__<server>__<create_item>.
18const MCP_WRITE = /^mcp__(.+?)__((create|update|delete|send|post|merge|publish|add|edit|set|change|move|transition|remove|write|upload)\w*)$/i
19// A command that checks work: tests, lint, type checks, builds, plugin validation.
20const CHECK = /\b(pytest|jest|vitest|mocha|ruff|eslint|tsc|mypy|pylint|flake8|(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|build|check|typecheck)|cargo\s+(test|check|clippy)|go\s+(test|vet)|make\s+(test|check|lint)|claude\s+plugin\s+(test|validate)|dbt\s+(test|build))\b/
21export const WRITERS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
22// Prose and notes: listed as edited, but no test or lint is expected after them.
23const PROSE = /\.(md|mdx|markdown|txt|rst|adoc|org)$/i
24const isCode = (path: string) => !PROSE.test(path)
25const MAX_EFFECTS = 50
26const MAX_FILES = 40
27
28const firstLine = (t: string) => t.trim().split('\n')[0]?.slice(0, 160) ?? ''
29
30/** What one finished tool call adds to the changes section; nothing when it adds nothing. */
31export function footprint(s: Stats, tool: string, input: Record<string, unknown>, at: number, hasFailed: boolean, agent: string | null): Partial<Stats> {
32 const change: Partial<Stats> = {}
33 const effect = (text: string) => {
34 const row: Effect = { at, text, isFailed: hasFailed, agent }
35 change.effects = [...s.effects, row].slice(-MAX_EFFECTS)
36 }
37
38 if (tool === 'Bash') {
39 const command = `${input.command ?? ''}`
40 if (OUTSIDE.test(command)) effect(firstLine(command))
41 if (CHECK.test(command)) change.lastCheck = { at, isPassed: !hasFailed, text: firstLine(command) }
42 }
43 const mcp = MCP_WRITE.exec(tool)
44 if (mcp !== null && !tool.startsWith('mcp__tether__')) effect(`${mcp[1]} ${mcp[2]}`)
45
46 if (!hasFailed && WRITERS.includes(tool)) {
47 const path = `${input.file_path ?? input.notebook_path ?? ''}`
48 if (path !== '') {
49 const before = s.touched.find(one => one.path === path)
50 change.touched = [...s.touched.filter(one => one.path !== path), { path, edits: (before?.edits ?? 0) + 1, at }].slice(-MAX_FILES)
51 if (isCode(path)) change.lastEditAt = at
52 }
53 }
54
55 return change
56}
57
58/** Whether code was checked after its last edit: no code edits yet, passed, failed, or not run. */
59export function checkState(s: Pick<Stats, 'touched' | 'lastEditAt' | 'lastCheck'>): 'none' | 'passed' | 'failed' | 'unchecked' {
60 if (!s.touched.some(one => isCode(one.path))) return 'none'
61 if (s.lastCheck === null || s.lastCheck.at < s.lastEditAt) return 'unchecked'
62 return s.lastCheck.isPassed ? 'passed' : 'failed'
63}
64hooks/split.ts 231 lines1// Copied from plugins/context-band/hooks/split.ts so the desk draws the same bar and legend.
2// ponytail: two copies; change both together, or move it to a shared plugin if a third mod needs it.
3
4import type { ApiMessage, SessionContextBreakdown } from 'claude-code'
5
6import type { Slice, Snapshot } from '../types'
7
8// Anthropic palette (brand-guidelines skill): blue = system, green = setup you control,
9// orange = the conversation. Tints and shades are by eye; tune them in the app.
10const ENGINE_ROWS: Record<string, { label: string; color: string }> = {
11 'System prompt': { label: 'system prompt', color: '#6a9bcc' },
12 'System tools': { label: 'tools', color: '#9dbfe0' },
13 'MCP tools': { label: 'mcp tools', color: '#4a7aa8' },
14 'Custom agents': { label: 'agents', color: '#788c5d' },
15 'Memory files': { label: 'memory files', color: '#a3b48a' },
16 Skills: { label: 'skills', color: '#5a6b45' },
17}
18const MESSAGES_ROW = 'Messages'
19const UNKNOWN_ROW = '#b0aea5'
20
21export const BUCKETS = {
22 prompts: { label: 'your prompts', color: '#d97757' },
23 injected: { label: 'injected', color: '#e8a58c' },
24 replies: { label: 'replies', color: '#b85c3f' },
25 thinking: { label: 'thinking', color: '#f0c4b0' },
26 toolCalls: { label: 'tool calls', color: '#c98a6e' },
27 toolResults: { label: 'tool results', color: '#9c4a30' },
28 images: { label: 'images', color: '#e8e6dc' },
29} as const
30export type Bucket = keyof typeof BUCKETS
31
32export const FREE = '#b0aea5'
33export const TICK = '#e8a58c'
34// ponytail: base64 length says nothing about image tokens; ~1.6k tokens each is the usual
35const IMAGE_CHARS = 6400
36// Reminders the engine already counts in its own rows. Only the first copy is skipped:
37// a re-sent copy is context spent again, and that belongs in injected.
38// ponytail: matched on the engine's wording; if it changes, the block falls back to injected
39const ENGINE_COUNTED: [kind: string, marker: string][] = [
40 ['memory', 'Codebase and user instructions are shown below'],
41 ['skills', 'skills are available for use with the Skill tool'],
42 ['agents', 'Available agent types for the Agent tool'],
43]
44
45// What each slice holds, for the hover detail under the legend.
46export const ABOUT: Record<string, string> = {
47 'system prompt': "Claude Code's own instructions and your output style.",
48 tools: 'Schemas of the built-in tools (Bash, Read, Edit, and the rest).',
49 'mcp tools': 'Tool schemas from connected MCP servers.',
50 agents: 'Descriptions of the subagent types the Agent tool can start.',
51 'memory files': 'CLAUDE.md files, rules, and auto-memory loaded at session start.',
52 skills: "The skill listing: each skill's name and description.",
53 'your prompts': 'What you typed.',
54 injected: 'Text added to your turns: hook output, reminders, and re-sent listings.',
55 replies: "Claude's visible replies.",
56 thinking: "Claude's reasoning blocks.",
57 'tool calls': 'The input of every tool call Claude made.',
58 'tool results': 'What the tools returned.',
59 images: 'Images and PDFs, estimated at about 1.6k tokens each.',
60 'mcp server instructions': 'Usage notes MCP servers send with their tools, read on every turn.',
61 free: 'Room left in the window. Claude Code compacts at the tick on the bar.',
62}
63
64/** The biggest few of a name-to-tokens map, as `a 12k, b 3k, c 900`. */
65export function top(entries: Iterable<[string, number]>, n = 3): string | undefined {
66 const sums = new Map<string, number>()
67 for (const [k, v] of entries) sums.set(k, (sums.get(k) ?? 0) + v)
68 const best = [...sums].filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).slice(0, n)
69 return best.length ? best.map(([k, v]) => `${k} ${fmt(v)}`).join(', ') : undefined
70}
71
72// ponytail: matched on the engine's wording for hook output ("SessionStart:startup hook success",
73// "UserPromptSubmit hook additional context"); it names the hook event, never the plugin or script
74const HOOK_EVENT = /\b([A-Z][A-Za-z]+)(?::[a-z_]+)? hook (?:success|additional context|blocking error)/
75/** Who put an injected block there: a hook event, or the engine itself. */
76export const injectedSource = (t: string) => HOOK_EVENT.exec(t)?.[1] ?? 'engine'
77
78const textOf = (v: unknown): string =>
79 typeof v === 'string' ? v
80 : Array.isArray(v) ? v.map(b => (b && typeof b === 'object' && 'text' in b ? String(b.text) : '')).join('')
81 : ''
82
83/** Character counts per bucket, plus tool-result characters per tool name. */
84export function measure(messages: readonly ApiMessage[]) {
85 const chars: Record<Bucket, number> = { prompts: 0, injected: 0, replies: 0, thinking: 0, toolCalls: 0, toolResults: 0, images: 0 }
86 const toolName = new Map<string, string>()
87 const byTool = new Map<string, number>()
88 const bySource = new Map<string, number>()
89 const seen = new Set<string>()
90 for (const m of messages) {
91 for (const b of m.content) {
92 if (b.type === 'text') {
93 const t = String(b.text ?? '')
94 const bucket: Bucket = m.role === 'assistant' ? 'replies' : t.includes('<system-reminder>') ? 'injected' : 'prompts'
95 const kind = bucket === 'injected' ? ENGINE_COUNTED.find(([, marker]) => t.includes(marker))?.[0] : undefined
96 if (kind && !seen.has(kind)) {
97 seen.add(kind)
98 continue
99 }
100 chars[bucket] += t.length
101 if (bucket === 'injected') bySource.set(injectedSource(t), (bySource.get(injectedSource(t)) ?? 0) + t.length)
102 } else if (b.type === 'thinking') {
103 chars.thinking += String(b.thinking ?? '').length
104 } else if (b.type === 'tool_use') {
105 toolName.set(String(b.id), String(b.name))
106 chars.toolCalls += String(b.name).length + JSON.stringify(b.input ?? {}).length
107 } else if (b.type === 'tool_result') {
108 const n = textOf(b.content).length
109 chars.toolResults += n
110 const name = toolName.get(String(b.tool_use_id)) ?? 'other'
111 byTool.set(name, (byTool.get(name) ?? 0) + n)
112 } else if (b.type === 'image' || b.type === 'document') {
113 chars.images += IMAGE_CHARS
114 }
115 }
116 }
117 return { chars, byTool, bySource }
118}
119
120/** Scale character counts to the engine's Messages tokens, so the split always sums to it. */
121export function splitMessages(messages: readonly ApiMessage[], messagesTokens: number) {
122 const { chars, byTool, bySource } = measure(messages)
123 const keys = Object.keys(chars) as Bucket[]
124 const total = keys.reduce((s, k) => s + chars[k], 0)
125 const tokens = {} as Record<Bucket, number>
126 for (const k of keys) tokens[k] = total ? Math.round((messagesTokens * chars[k]) / total) : 0
127 if (total) {
128 // rounding drift goes to the largest bucket
129 const biggest = keys.reduce((a, b) => (chars[b] > chars[a] ? b : a))
130 tokens[biggest] += messagesTokens - keys.reduce((s, k) => s + tokens[k], 0)
131 }
132 const topTool = [...byTool].sort((a, b) => b[1] - a[1])[0]?.[0]
133 const scale = (m: Map<string, number>): [string, number][] => [...m].map(([k, v]) => [k, total ? Math.round((messagesTokens * v) / total) : 0])
134 return { tokens, topTool, byTool: scale(byTool), bySource: scale(bySource) }
135}
136
137export const pct = (tokens: number, window: number) => (tokens > 0 && window > 0 ? Math.max(1, Math.round((tokens / window) * 100)) : 0)
138
139export function fmt(n: number): string {
140 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
141 if (n >= 10_000) return `${Math.round(n / 1000)}k`
142 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
143 return String(n)
144}
145
146const detail = (label: string, biggest?: string) => {
147 const d = [ABOUT[label] ?? 'A row /context reports; run /context for its detail.', biggest && `Biggest: ${biggest}.`].filter(Boolean).join(' ')
148 return d ? { detail: d } : {}
149}
150
151export function toSnapshot(b: SessionContextBreakdown, messages: readonly ApiMessage[]): Snapshot {
152 const window = b.rawMaxTokens
153 const slices: Slice[] = []
154 const add = (label: string, tokens: number, color: string, note?: string, biggest?: string) =>
155 slices.push({ label, tokens, percent: pct(tokens, window), color, ...(note ? { note } : {}), ...detail(label, biggest) })
156 const bigByRow: Record<string, string | undefined> = {
157 'mcp tools': top((b.mcpTools ?? []).filter(t => t.isLoaded).map(t => [t.serverName, t.tokens])),
158 agents: top((b.agents ?? []).map(a => [a.agentType, a.tokens])),
159 'memory files': top((b.memoryFiles ?? []).map(f => [f.path.split('/').slice(-2).join('/'), f.tokens])),
160 skills: top((b.skills?.skillFrontmatter ?? []).map(k => [k.pluginName ?? k.source, k.tokens])),
161 }
162 let free = 0
163 for (const row of b.categories) {
164 if (row.kind === 'free') free += row.tokens
165 if (row.kind !== 'used' || row.tokens <= 0) continue // buffer sits past the tick; deferred is outside the window
166 if (row.name === MESSAGES_ROW) {
167 const { tokens, topTool, byTool, bySource } = splitMessages(messages, row.tokens)
168 for (const k of Object.keys(BUCKETS) as Bucket[]) {
169 const biggest = k === 'toolResults' ? top(byTool) : k === 'injected' ? top(bySource) : undefined
170 if (tokens[k] > 0) add(BUCKETS[k].label, tokens[k], BUCKETS[k].color, k === 'toolResults' ? topTool : undefined, biggest)
171 }
172 } else {
173 const known = ENGINE_ROWS[row.name]
174 const label = known?.label ?? row.name.toLowerCase()
175 add(label, row.tokens, known?.color ?? UNKNOWN_ROW, undefined, bigByRow[label])
176 }
177 }
178 slices.push({ label: 'free', tokens: free, percent: pct(free, window), color: FREE, ...detail('free') })
179 return {
180 slices,
181 used: b.totalTokens,
182 window,
183 ...(b.autoCompactThreshold ? { threshold: b.autoCompactThreshold } : {}),
184 percent: Math.round(b.percentage),
185 }
186}
187
188export const pillColor = (percent: number) => (percent >= 80 ? '#d97757' : percent >= 50 ? '#e8a58c' : '#788c5d')
189
190/** The bar: a track for the whole window, one rect per used slice, a tick at the compaction point. */
191export function barSvg(s: Snapshot): string {
192 const W = 1000
193 const H = 12
194 let x = 0
195 const rects = s.slices
196 .filter(sl => sl.label !== 'free' && sl.tokens > 0)
197 .map(sl => {
198 const w = Math.max(2, (sl.tokens / s.window) * W)
199 const tip = `${sl.label} ${fmt(sl.tokens)} (${sl.percent}%)${sl.note ? ` · ${sl.note}` : ''}${sl.detail ? `\n${sl.detail}` : ''}`.replace(/[<&]/g, '')
200 const r = `<rect x="${x.toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${H}" fill="${sl.color}"><title>${tip}</title></rect>`
201 x += w
202 return r
203 })
204 const tick = s.threshold ? `<rect x="${Math.min(W - 3, (s.threshold / s.window) * W).toFixed(1)}" y="0" width="3" height="${H}" fill="${TICK}"/>` : ''
205 return (
206 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none">` +
207 `<defs><clipPath id="t"><rect width="${W}" height="${H}" rx="4"/></clipPath></defs>` +
208 `<g clip-path="url(#t)"><rect width="${W}" height="${H}" fill="${FREE}" fill-opacity="0.25"/>${rects.join('')}${tick}</g></svg>`
209 )
210}
211
212/** The bar for surfaces without Svg: `width` cells of █ per used slice, ░ for free, │ at the compaction point, as color runs. */
213export function barCells(s: Snapshot, width: number): { color: string; text: string }[] {
214 const n = Math.max(10, width)
215 const cells: { color: string; ch: string }[] = []
216 for (const sl of s.slices) {
217 if (sl.label === 'free' || sl.tokens <= 0) continue
218 const k = Math.max(1, Math.round((sl.tokens / s.window) * n))
219 for (let i = 0; i < k && cells.length < n; i++) cells.push({ color: sl.color, ch: '█' })
220 }
221 while (cells.length < n) cells.push({ color: FREE, ch: '░' })
222 if (s.threshold) cells[Math.min(n - 1, Math.round((s.threshold / s.window) * n))] = { color: TICK, ch: '│' }
223 const runs: { color: string; text: string }[] = []
224 for (const c of cells) {
225 const last = runs[runs.length - 1]
226 if (last && last.color === c.color) last.text += c.ch
227 else runs.push({ color: c.color, text: c.ch })
228 }
229 return runs
230}
231types/index.d.ts 99 lines1export type Assumption = {
2 id: number
3 text: string
4 basis: string
5 affects: string
6 at: number
7 agent: string | null
8 status: 'open' | 'replaced' | 'flagged' | 'cleared'
9 replacedBy: number | null
10}
11
12export type AskKind = 'decision' | 'choice' | 'todo' | 'question' | 'review'
13// `drafted`: the reply a button put in the prompt box; sending it clears the ask.
14// `batch` is the main turn it was added in; absent on ones stored before 0.10.0, which count as turn 0.
15export type Ask = { id: number; kind: AskKind; text: string; options?: string[]; drafted?: string; batch?: number }
16
17export type Undone = {
18 id: number
19 text: string
20 source: 'said' | 'code' | 'checker'
21 at: number
22 // The main turn count when it was recorded; absent on ones stored before 0.7.0, which count as turn 0.
23 turn?: number
24 status: 'open' | 'sent' | 'cleared'
25}
26
27// A call that reached outside the machine or is hard to undo: a push, a merge, a delete, an install, an MCP write.
28export type Effect = { at: number; text: string; isFailed: boolean; agent: string | null }
29// A file Claude edited this session, and how many times.
30export type Touched = { path: string; edits: number; at: number }
31
32// The main pane's section order and the sections the person hid.
33export type Layout = { order: string[]; hidden: string[] }
34
35// The context bar's model, the same as context-band's.
36export type Slice = { label: string; tokens: number; percent: number; color: string; note?: string; detail?: string }
37
38export type Snapshot = {
39 slices: Slice[] // used slices in legend order, free last
40 used: number
41 window: number
42 threshold?: number
43 percent: number
44 cost?: number
45}
46
47export type AgentRow = {
48 id: string
49 label: string
50 type: string
51 model: string | null
52 startedAt: number
53 endedAt: number | null
54 lastSeenAt: number
55 isSpawned: boolean
56 tools: number
57 tokensRead: number
58 tokensOut: number
59 hasFailed: boolean
60}
61
62export type Stats = {
63 openedAt: number
64 now: number
65 turns: number
66 tools: number
67 fails: number
68 freshTokens: number
69 cacheReadTokens: number
70 cacheWriteTokens: number
71 outTokens: number
72 costUsd: number | null
73 ctxPercent: number | null
74 ctxTokens: number | null
75 ctxWindow: number | null
76 snapshot: Snapshot | null
77 deferredTokens: number
78 assumptions: Assumption[]
79 nextNote: number
80 agents: AgentRow[]
81 needsMeasure: boolean
82 undone: Undone[]
83 nextUndone: number
84 lastPrompt: string
85 turnTools: number
86 isChecking: boolean
87 tickError: string
88 effects: Effect[]
89 touched: Touched[]
90 lastEditAt: number
91 lastCheck: { at: number; isPassed: boolean; text: string } | null
92}
93
94declare module 'claude-code' {
95 interface PluginState {
96 tether: { stats: Stats; collapsed: string[]; focus: string; isEditingFocus: boolean; isFocusCustom: boolean; asks: Ask[]; nextAsk: number; closedAsks: string[]; askBatch: number; layout: Layout }
97 }
98}
99