SLOPSHOPPER

Tether

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…

newpaneguardcommandtoastprompt
v0.10.0MITupdated 2026-10-09mgstegmaier/claude-plugins/plugins/tether
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tether
│ ┃ Tether ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╭─────────────────────────────────────────── │ tether │ │ ┃ │ ◆ working on ▾ ⏺ Read(src/auth.ts) │ Loose end: Finish "// TODO: implement │ │ ┃ │ What are we working on? Enter to save ⏎ sa ⎿ Read 6 lines │ eviction later" in cache.ts │ │ ┃ ╰─────────────────────────────────────────── ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line ╭───────────────╮ │ ┃ │ ◆ context ▸ │ ⏺ Bash(bun test) │ tether │ │ ┃ │ Measured after the next turn │ ⎿ 3 pass, 1 fail │ Loose end: OK │ │ ┃ ╰──────────────────────────────────────────╯ ╰───────────────╯ │ ┃ ╭──────────────────────────────────────────╮ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ◆ assumptions ▾ 0 open │ │ ┃ │ None open. They appear here as Claude │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ makes them. │ │ ┃ ╰──────────────────────────────────────────╯ › /tether │ ┃ ╭─────────────────────────────────────────── ⎿ tether: Session dashboard opened. │ ┃ │ ◆ loose ends ▾ 3 open │ ┃ │ U3 · 00:00 · second model[ a Do now ] [ Cl │ ┃ │ OK │ ┃ │ │ ┃ │ U2 · 00:00 · in a file[ b Do now ] [ Clear │ ┃ │ Finish "// TODO: implement eviction │ ┃ │ later" in cache.ts │ ┃ │ │ ┃ │ U1 · 00:00 · in a file[ c Do now ] [ Clear │ ┃ │ Finish "// TODO: implement persistence" │ ┃ │ in audit.ts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tether
╭──────────────────────────────────────────────────────────╮ │ ◆ working on ▾ │ │ What are we working on? Enter to save ⏎ save │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ context ▸ │ │ Measured after the next turn │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ assumptions ▾ 0 open │ │ None open. They appear here as Claude makes them. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ loose ends ▾ 3 open │ │ U3 · 00:00 · second model [ a Do now ] [ Clear ] │ │ OK │ │ │ │ U2 · 00:00 · in a file [ b Do now ] [ Clear ] │ │ Finish "// TODO: implement eviction later" in cache.ts │ │ │ │ U1 · 00:00 · in a file [ c Do now ] [ Clear ] │ │ Finish "// TODO: implement persistence" in audit.ts │ │ [ x Clear all ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ action items ▾ │ │ Nothing waiting on you. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ subagents ▾ 0 running │ │ None started yet │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ cost and tokens ▾ $0.42 │ │ Cost, whole session $0.42 │ │ Tokens read 97.4K │ │ served from cache 93% │ │ Tokens written 1.5K │ │ Turns 1 │ │ Tokens and turns count from when this loaded │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ changes ▸ 1 outside · 3 files · failed │ ╰──────────────────────────────────────────────────────────╯ Settings
Pane · tether-settings
Pick the sections you want and their order. Saved for every new session. working on [ Hide ] [ ↑ ] [ ↓ ] context [ Hide ] [ ↑ ] [ ↓ ] assumptions [ Hide ] [ ↑ ] [ ↓ ] loose ends [ Hide ] [ ↑ ] [ ↓ ] action items [ Hide ] [ ↑ ] [ ↓ ] subagents [ Hide ] [ ↑ ] [ ↓ ] cost and tokens [ Hide ] [ ↑ ] [ ↓ ] changes [ Hide ] [ ↑ ] [ ↓ ] [ Reset ] [ Done ]
README

Tether

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

Getting started

  1. In the Claude desktop app's Code tab or in a terminal session, run /plugin marketplace add mgstegmaier/claude-plugins. You only need this once.
  2. Run /plugin install tether@mgstegmaier.
  3. Run /reload-plugins, or start a new session.
  4. Start a session. The pane opens by itself. In a terminal narrower than 144 columns it waits until the terminal widens; run /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.

The pane

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

Commands

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

What it adds to your sessions

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.

What to know about the numbers

  • The session cost comes from Claude Code and is an estimate at API list prices. Your plan may bill differently.
  • Tokens and turns count from when the mod loaded, which is the start of the session unless you installed it partway through.
  • On a Team or Enterprise plan, or a machine with managed settings, Claude Code stops a mod you install yourself from changing the system prompt, so the assumptions panel may stay empty.
  • Loose ends is a prompt to look, not a verdict. The second model reads Claude's report and not its tool calls, so it finds what the report admits to.
  • Context figures describe the main conversation. Subagents get their own status but not their own context breakdown.

Before you install

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.

License

MIT

Source 5 files
hooks/register.tsx 1135 lines
1import { 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}
1135
hooks/asks.ts 90 lines
1// 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)
90
hooks/changes.ts 64 lines
1// 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}
64
hooks/split.ts 231 lines
1// 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}
231
types/index.d.ts 99 lines
1export 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