Restyles the chat: your messages, Claude's replies, and one line per turn for the work. Off in every session until you turn it on there: the band's feed key…

<img src="https://raw.githubusercontent.com/hex/claude-sessions/main/assets/banner.svg" width="100%" alt="cs: a session manager for Claude Code">
cs creates persistent workspaces for Claude Code, with session notes, conversation resume, and local git history.
Requires macOS or Linux, Bash 3.2+, git, jq, perl, Claude Code, and a UTF-8 locale. Remote control needs Claude Code 2.1.299+. Non-UTF-8 locales such as ja_JP.eucJP, ko_KR.eucKR, and zh_CN.eucCN cause syntax errors in cs and two of its hooks.
curl -fsSL https://github.com/hex/claude-sessions/releases/latest/download/install.sh | bash
From a checkout:
git clone https://github.com/hex/claude-sessions.git
cd claude-sessions
./install.sh
Review install.sh before running it. The piped command always installs the release, even inside a checkout. The installer puts the commands in ~/.local/bin/, registers hooks, and installs skills, mods, and Bash and Zsh completions. Updates and prebuilt picker downloads require ssh-keygen with -Y verify support to check the signed release manifest.
cs debug-api
One command, from any terminal: cs <name> creates the session the first time and resumes it every time after. It needs no project or git repository first.
cs debug-api. cs creates a workspace and opens Claude Code.Objective section and keeps notes under .cs/.cs debug-api again and accept the prompt to resume the recorded conversation.When you forget a name, a bare cs opens a picker of your sessions, and cs . reopens the one whose root you are in.
The statusline guide covers the quiet default layout, the band and the capsules, colors, thresholds, font setup, and agent rows.
| Feature | What it does | Details |
|---|---|---|
| Session records | Keeps objectives, per-actor narratives, memory, plans, checkpoints, and a timeline in .cs/. | Session layout |
| Crash recovery | Saves edits to shadow git refs without adding normal branch commits. | Autosave hook |
| Conversation rotation | Hands work to a fresh conversation through /rotate and /clear. | Rotation and queue hooks |
| Existing projects | cs -adopt <name> registers your project without moving it. | Adoption |
| Parallel worktrees | cs <base>@<feature> gives each feature its own checkout and session records. | Worktrees |
| Remote control | cs <name> --remote serves a session for the Claude app or web. | Remote control |
| Task queue | Runs confirmed tasks at turn boundaries, with failure, context, and quota breakers. | Queue behavior |
| Cross-session mail | Delivers local messages and wakes idle sessions for unread arrivals. | Mail wakes |
| Secrets | Uses the macOS keychain or encrypted files, with signed age exports for sync. | Secrets guide |
| Encrypted sessions | Keeps notes, transcripts, plans, and private state in a vault on macOS. | Encrypted sessions |
| Statusline | Shows session, git, model, context, queue, mail, and rate limits. | Statusline guide |
| Prompt grounding | Adds matching files, recent commits, changes, and clarification guidance to code-work prompts. | Scope hook |
| Prompt rewriting | Ctrl+G rewrites a draft in the composer for you to review before sending. | Prompt rewriter |
| Writing skills | Judges prose or drafts from a writing profile. | Skills |
| Session wrap | /wrap saves memory, summarizes the narrative, and archives completed tasks without committing. | Memory checks |
| Inline Mermaid | Draws supported flowcharts in replies, and a show-me skill answers "show me" with one. | Mermaid mod<br><img src="https://raw.githubusercontent.com/hex/claude-sessions/main/assets/mermaid.svg" width="260" alt="Reply prose, then the same prose with a drawn Mermaid chart"> |
| Chat feed | Folds each turn's tool calls into one line beside your messages and Claude's replies, off until you turn it on per session with the band's ctrl+x 3 or /feed on. | Feed mod |
| Release notes | Shows pending release notes in-session through /cs-update. | Update mod |
| Other front ends | Session hooks also work in desktop and IDE conversations opened in a .cs/ workspace. | Session resolution |
cs -uninstall
| Guide | Contents |
|---|---|
| Commands | Command reference, adoption, worktrees, queues, remote control, and completion. |
| Configuration | Every environment variable cs reads and the values it exports. |
| Session layout | Shared and local records, encryption, and merge policy. |
| Hooks | Hook behavior, prompt rewriting, and Claude Code mods. |
| Secrets | Storage backends, backups, and signed sync files. |
| Statusline | Segments, the quiet, band and capsule layouts, colors, thresholds, and agent rows. |
MIT. Contributions can start with an issue or pull request.
hooks/register.tsx 567 lines1// ABOUTME: cs-feed mod: restyles the chat transcript ("the feed"). Always loaded, every hook always registered; a session whose .cs/local/feed is not exactly "on\n", or that cs -vet-session refuses, gets Claude Code's own drawing.
2// ABOUTME: On/off comes from cs's band key (a ui.press hook reads the file after next) or /feed (writes it where cs -vet-session passes). PAINTERS is the one table of render sites; site() is the one caller of next().
3import type { EngineInterface, On, RenderComponent, RenderElement, RenderInput, SessionMessage } from 'claude-code'
4
5import { active, appendText, applyFeed, dirty, feedRecord, newLoad, noteStepsDrawn, noteWorking, observeAppend, observeModel, observeSpawn, observePrompt, observeTurnComplete, observeTurnStart, openTurn, readLive, readPulse, readTick, replaceResponseText, startClocks, toggle, transcript, writeFeed } from './session'
6import type { Load } from './session'
7import { modeOf, parseFeedArgs, transition } from './settings'
8import type { FeedCommand } from './settings'
9import { decideBand, decideCall, decideCommandOutput, decideGroup, decideHelpersPane, decideReply, decideSpinner, decideToolResult, decideUser, sessionModes } from './verdict'
10import { callId } from './transcript'
11import type { CallView, Clock, Context } from './verdict'
12import { NOTHING, drawBand, drawCall, drawCommandOutput, drawGroup, drawHelpersPane, drawReply, drawSettingsPane, drawUser, inks } from './draw'
13import type { Pen } from './draw'
14import { commands, logs, replies } from './words'
15
16type $ = EngineInterface
17
18export const PANE = 'cs-feed'
19export const HELPERS_PANE = 'cs-feed-helpers'
20
21// One Load per module evaluation. A reload (a change of these files, such as a
22// cs update) evaluates the module again and register() makes a new one; the
23// old module keeps its own until its in-flight dispatches settle (spike Q1).
24// A per-session toggle is not a reload: it is a file this load reads.
25let load: Load = newLoad()
26
27/**
28 * No install switch: register() always
29 * registers the one shape, and each hook returns the engine's drawing while
30 * this session's feed is off. The off cost beside cs's three mods is spike4's
31 * inert mod: +32 worker hops per three-turn scenario (24 ui.render, 8
32 * session.append), about 1.6 ms p50 per new render hop, probably +8-10 ms at
33 * each turn's end (not significant per run, n=6), +5 MB RSS. Accepted.
34 */
35export function register(on: On) {
36 load = newLoad()
37 registerFeed(on)
38}
39
40/** What /feed answers, and whether it changed nothing because the directory or cs refused (the settings pane logs those). */
41type Answer = { readonly text: string; readonly refused: boolean }
42
43/**
44 * Carries out a typed /feed command or a settings-pane press for THIS session.
45 * It never reloads and runs no command.
46 *
47 * await feedRecord(load, $) // the mirror exists from here on (vetted at the load's on-read)
48 * if (cmd.kind === 'status') await applyFeed(load, $, 'status') // status reports the file as vetted; a vet only for an outside flip to on
49 * const tr = transition(cmd, load.feed!)
50 * if (tr.session) { // every write: on, off, a preset, a setting, hide/show, a chip, a pane row
51 * const done = await writeFeed(load, $, tr.session) // asks cs -vet-session first (ruling §20.22)
52 * if (done === 'no-session') return { text: 'this directory is not a cs session (no .cs/local), so nothing changed.', refused: true }
53 * if (done !== 'written') return { text: `no feed change in this session: ${done.refused}`, refused: true }
54 * }
55 * if (tr.pane === 'open') await $.ui.open({ id: PANE, title: 'Chat view', focus: true })
56 * if (tr.pane === 'close') await $.ui.close({ id: PANE })
57 * return { text: tr.reply, refused: false }
58 *
59 * The texts carry no plugin name: the engine leads a command's output and a
60 * `$.ui.log` line with it (`cs-feed: no feed change ...` on screen).
61 *
62 * A refusal writes nothing, leaves the mirror and the drawing as they were,
63 * and says why in the reply: the refusal text is cs's own (session.vetRefusal),
64 * in the pattern cs's band toasts it with ("cs: no rotation in this session:
65 * <refusal>"). The
66 * band's press asks the same cs rule with the same argv, so a session cs
67 * refuses is refused by both writers. Bare /feed, /feed close and /feed status
68 * write nothing and ask no vet of their own; the one cs process any of them
69 * can cause is a read's (the load's feedRecord when it is the first asker, or
70 * status's re-read finding an outside flip to on; ruling §20.22).
71 *
72 * /feed status re-reads FEED_FILE first because the mirror changes only at a
73 * press, a /feed write or a load: after a hand edit, or a second Claude process
74 * in this directory, it is stale. When the file differs, that read also redraws
75 * this session to match (applyFeed's dirty() and invalidate); a flip to on that
76 * no writer of ours vetted is vetted first, and a refusal reads off (logged
77 * once, session.vetOnRead). The reply's first word is the mirror's after that
78 * read: `off` in a session cs refuses, whatever the file says. The view half is
79 * still the mirror's: applyFeed reads FEED_FILE only.
80 *
81 * writeFeed's read-back, not tr.session, sets the mirror, and its invalidate
82 * redraws cs's band too (the label follows on its next redraw: inferred, see
83 * session.applyFeed). /feed is `immediate`, so a typed /feed runs mid-turn at
84 * once (spike3 M12: immediate lets a typed command run mid-turn).
85 */
86function perform($: $, cmd: FeedCommand): Promise<Answer> {
87 if (cmd.kind === 'status' || cmd.kind === 'open' || cmd.kind === 'close' || cmd.kind === 'usage') return performNow($, cmd)
88 const result = (load.writes ?? Promise.resolve()).then(() => performNow($, cmd))
89 // The caller keeps result's rejection; the queue must still admit the next command.
90 load.writes = result.then(() => undefined, () => undefined)
91 return result
92}
93
94async function performNow($: $, cmd: FeedCommand): Promise<Answer> {
95 await feedRecord(load, $)
96 if (cmd.kind === 'status') await applyFeed(load, $, 'status')
97 const current = load.feed
98 if (!current) throw new Error('feed record did not settle')
99 const changed = transition(cmd, current)
100 if (changed.session) {
101 const result = await writeFeed(load, $, changed.session)
102 if (result === 'no-session') return { text: replies.notASession, refused: true }
103 if (result !== 'written') return { text: replies.refused(result.refused), refused: true }
104 }
105 if (changed.pane === 'open') await $.ui.open({ id: PANE, title: 'Chat view', focus: true })
106 if (changed.pane === 'close') await $.ui.close({ id: PANE })
107 return { text: changed.reply, refused: false }
108}
109
110/**
111 * The one shape. Nothing registered here awaits across a tool call or a
112 * stream. Every hook is registered whatever this session's toggle says; the
113 * toggle decides what they draw, never what is registered.
114 *
115 * Every hook body is a block (`=> { … }`), never an expression body: the cost
116 * oracle's probe build inserts one counting statement at the top of each body
117 * of a copy (Deploy touchpoints, finding 5). The probe script checks that it
118 * inserted exactly as many as there are on() calls.
119 */
120function registerFeed(on: On) {
121 // Commands do not survive a reload (spike Q2, measured): every load registers them.
122 // cs's band shows the feed chord only while $.command.list() lists /feed from
123 // plugin cs-feed, reads it when the band is evaluated (4-5 ms after a reloaded
124 // plugin's session.start, spike3 M14), and nothing redraws the band when the
125 // command list changes later. So both registrations are the FIRST statements,
126 // issued before any await, and session.start settles (next) only after both
127 // resolved. Nothing else may come before them. Not a manifest markdown command:
128 // the band filters `source === 'plugin' && plugin === 'cs-feed'`, and /feed
129 // must be answered by the command.run hook below; neither is measured for a
130 // markdown command. /clear (cs's rotation) reloads nothing (spike3 M13: same
131 // load, no session.start, /feed stays registered), so the gap exists only on a
132 // true reload. Whether the band's post-reload evaluation waits for this hook to
133 // settle is not measured (open-measurements M24).
134 on('session.start', async ($, e, next) => {
135 const feed = $.command.register(commands.feed)
136 const helpers = $.command.register(commands.helpers)
137 await Promise.all([feed, helpers])
138 return next(e)
139 })
140
141 on('command.run', { command: 'feed' }, async ($, e) => {
142 return { text: (await perform($, parseFeedArgs(e.args))).text }
143 })
144
145 on('command.run', { command: 'helpers' }, async ($) => {
146 await $.ui.open({ id: HELPERS_PANE, title: 'Helpers', focus: true })
147 return { text: replies.helpersOpen }
148 })
149
150 // cs's band key (`ctrl+x 3 feed`, Button key `cs-feed`, action strip:jump3).
151 // next(e) resolves only after cs's onPress (pressFeed) promise settles
152 // (spike5 M16: 64 of 64, idle and mid-turn, both --plugin-dir orders; with a
153 // 400 ms sleep before the write, next took 403-415 ms). So the file read
154 // straight after it is the post-press value, with no timer. Band-side
155 // precondition: pressFeed awaits its $.fs.write before returning and the
156 // Button's onPress returns pressFeed's promise (mods/cs/hooks/register.tsx
157 // pressFeed and buildBandEntries, today). Work cs started without awaiting
158 // would not be covered (inferred from the handler, spike5 "Not measured").
159 // A press while one is pending gets that press's in-flight promise
160 // (pressFeed is single-flight: `if (feedPressPending !== undefined) return
161 // feedPressPending`) and writes nothing, so its next resolves after the first
162 // press's write and both hooks read the same value. A press pressFeed refuses
163 // (session not vetted, a write error) leaves the file as it is or torn; the
164 // read says what both sides now show. This read is not vetted again
165 // (ruling §20.22): pressFeed vetted before it wrote, so a flip to on read
166 // here is the press's; an on that did not change keeps the load's cached
167 // answer, so a session cs refused stays off (session.applyFeed 'press').
168 // Measured alternative, not taken: an `fs.write` op hook sees the write
169 // with e.text (M16), but it runs before the write lands and for every
170 // fs.write of every plugin, where this runs for one Button.
171 // Matcher literals: KEEP IN SYNC with settings.BAND_PLUGIN / BAND_ELEMENT (a kit test compares them).
172 on('ui.press', { plugin: 'cs', element: 'cs-feed' }, async ($, e, next) => {
173 const result = await next(e)
174 await applyFeed(load, $, 'press')
175 return result
176 })
177
178 // The two Pane bodies run ungated, whatever this session's toggle says: a
179 // person opened them (/feed, /helpers). Their off cost: beside cs they join the
180 // hop cs-update's bare { component: 'Pane' } hook already causes on every Pane
181 // render; standalone they are new hops (host-side requestId selection is
182 // open-measurements #4). spike4's inert probe had neither.
183 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
184 const session = load.feed ?? await feedRecord(load, $)
185 await next(e)
186 return drawSettingsPane(modeOf(session), session, { el: $.ui.resolve(e), ink: inks(load.light), room: e.props.bodyColumns }, async cmd => {
187 const answer = await perform($, cmd)
188 if (answer.refused) await $.ui.log(answer.text)
189 })
190 })
191
192 // The Helpers pane (§7.4): the agent list, the main snapshot (for each
193 // helper's Agent call: its spawn time and durationMs) and, for the newest 6,
194 // each helper's own transcript. One that cannot be read leaves its head alone
195 // (§7.4 (c)); its deny is logged. While a helper works the pane reads TICK
196 // for its elapsed time and step.
197 on('ui.render', { component: 'Pane', requestId: HELPERS_PANE }, async ($, e, next) => {
198 await next(e)
199 const agents = await $.agent.list()
200 const t = await transcript(load, $)
201 const shown = agents.filter(agent => !agent.parentId && !agent.teammateId).slice(-6)
202 const own = new Map<string, readonly SessionMessage[] | undefined>()
203 for (const agent of shown) {
204 const rows = await $.session.messages({ agentId: agent.id })
205 own.set(agent.id, Array.isArray(rows) ? rows : undefined)
206 if (!Array.isArray(rows)) await $.ui.log(logs.helperUnread(agent.id, rows.deny))
207 }
208 const blocks = decideHelpersPane(shown, t, own, load.obs, Date.now())
209 if (blocks.some(block => block.status === 'working')) await readTick($)
210 return drawHelpersPane(blocks, { el: $.ui.resolve(e), ink: inks(load.light), room: e.props.bodyColumns })
211 })
212
213 // The transcript grew. One hook per door, never a bare session.append: sec-default
214 // forwards attachment, hook-context, compaction, tool-message and org-plugin notes
215 // past user mods (validate/verdicts.md G), and on a seat without it a bare hook would
216 // be a worker hop for each of those rows too. These five are the doors whose rows
217 // change what plain messages() holds or carry a fact the feed observes. A matcher
218 // cannot say "no agentId", so each hook skips a helper's rows.
219 //
220 // Doors measured on 2.1.309 (spike Q4, spike2 M1, spike5 M19): a typed prompt, an
221 // idle peer message (isMeta) and an idle task notification come on `prompt`; a
222 // prompt, peer message or task notification delivered into a running turn comes on
223 // `delivery` (type attachment, name queued_command). So the observers key on
224 // e.origin.kind, not on the door. Teammate envelopes are inferred to share these
225 // paths, not measured.
226 //
227 // Each observer records whatever this session's toggle says: the hop is paid
228 // either way, and a flip on then finds phase and observations current.
229 // dirty() schedules a refresh only while active(). No append hook calls
230 // $.ui.invalidate (spike5 M18: it re-evaluates every selected instance).
231 on('session.append', { door: 'prompt' }, async ($, e, next) => {
232 const result = await next(e)
233 if (e.agentId !== undefined) return result
234 // Synchronous from here to the return: no await. spike5 M19 measured this
235 // continuation running before turn.start, 0-1 ms later, which consumes it.
236 observePrompt(load, { door: 'prompt', peer: e.origin.kind === 'peer', isMeta: e.message.isMeta === true })
237 observeAppend(load, e)
238 dirty(load, $)
239 return result
240 })
241 on('session.append', { door: 'command' }, async ($, e, next) => {
242 // A skill or slash invocation lands in plain messages() as a user entry and opens a turn (spike Q4). Dirty only.
243 const result = await next(e)
244 if (e.agentId === undefined) dirty(load, $)
245 return result
246 })
247 on('session.append', { door: 'response' }, async ($, e, next) => {
248 // BEFORE next, synchronously (uuid and tool_use blocks are pinned), so the
249 // reply's first evaluation, +6-9 ms after this append (spike5 M18), finds it:
250 if (e.agentId === undefined) observeModel(load, e.uuid, e.message.content)
251 const result = await next(e)
252 if (e.agentId !== undefined) return result
253 if (result.message) replaceResponseText(load, e.uuid, appendText(result.message.content))
254 dirty(load, $)
255 return result
256 })
257 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
258 const result = await next(e)
259 if (e.agentId === undefined) dirty(load, $)
260 return result
261 })
262 on('session.append', { door: 'delivery' }, async ($, e, next) => {
263 // Deliveries into a running turn (measured, spike2 M1). They never reach plain messages(),
264 // so these observations are their only trace: the same observe(load.obs, e) by origin as
265 // the prompt door (composer -> promptAt, peer -> bursts and frames, task-notification -> bgEnded).
266 // A peer delivered mid-turn opens no turn, so peerPending is not touched here.
267 const result = await next(e)
268 if (e.agentId !== undefined) return result
269 observeAppend(load, e)
270 dirty(load, $)
271 return result
272 })
273
274 // Consumes the prompt hook's peerPending (spike5 M19 order) into this turn's
275 // record, and only then clears it, so the next response keeps its peer opener.
276 on('turn.start', async ($, e, next) => {
277 if (e.agentId === undefined) {
278 observeTurnStart(load)
279 dirty(load, $)
280 }
281 return next(e)
282 })
283
284 // Main loop only. A helper's end needs no event: its row's own props, ToolUseSummary.agentId
285 // and durationMs, and $.agent.list() say it (verdict.helperState). Set at entry, before next.
286 // Whether the final reply's first evaluation sees phase idle depends on the Stop hook: without one
287 // turn.complete comes first (spike5 M18); with one it comes after both evaluations (spike4's rig,
288 // 36 of 36 turns, M27), and the reply's claim is pending until this phase change's LIVE bump
289 // re-evaluates it (ruling §20.23 (a)). verdict.stepsOwner is built for both orders.
290 on('turn.complete', async ($, e, next) => {
291 if (e.agentId === undefined) {
292 observeTurnComplete(load)
293 dirty(load, $)
294 }
295 return next(e)
296 })
297
298 // The one fact about a helper the transcript lacks: when a running one started.
299 // It is also how the band learns of a helper without polling: the spawn asks
300 // the next refresh to read $.agent.list(), and dirty() schedules it; a
301 // changed Seen.helpers bumps LIVE, which the band reads while a turn is open
302 // or a helper is known. A denied spawn started nothing.
303 // Off cost: a new hop per helper spawn in every session, off included (no cs
304 // mod hooks agent.spawn, and spike4's inert probe had none).
305 on('agent.spawn', async ($, e, next) => {
306 const result = await next(e)
307 if (e.parentAgentId === undefined && result.deny === undefined) {
308 observeSpawn(load, e.tool_use_id)
309 dirty(load, $)
310 }
311 return result
312 })
313
314 sites(on, PAINTERS)
315}
316
317// ---------------------------------------------------------------------------
318// The render sites: one table, one wrapper.
319// ---------------------------------------------------------------------------
320
321/** The sites the feed draws. AskUserQuestion, InfoNotice and PromptHint stay the engine's (ruling §20.13); Pane is hooked by requestId in both shapes. */
322type Hooked = Exclude<RenderComponent, 'AskUserQuestion' | 'InfoNotice' | 'PromptHint' | 'Pane'>
323type Props<C extends RenderComponent> = Partial<RenderInput<C>['props']>
324
325/**
326 * What a painter decides for one draw. Only site() turns it into calls of next():
327 * engine next(e), props untouched
328 * rewrite next with these props; the engine draws the row (its clock, tokens, cs's tip)
329 * frame the engine's drawing (props rewritten when given) placed inside a tree of ours
330 * tree ours; next(e) has still run, so observers beneath (cs's rail) saw the row
331 * nothing no row; next(e) has still run
332 */
333export type Drawn<C extends RenderComponent> =
334 | { readonly kind: 'engine' }
335 | { readonly kind: 'rewrite'; readonly props: Props<C> }
336 | { readonly kind: 'frame'; readonly props?: Props<C>; readonly frame: (drawn: RenderElement) => RenderElement }
337 | { readonly kind: 'tree'; readonly tree: RenderElement }
338 | { readonly kind: 'nothing' }
339
340/** A decision, whether it was taken from the open turn's state (site() then reads LIVE), and the clock tokens it moves with (verdict.Clock). */
341export type Decided<D> = { readonly drawn: D; readonly live: boolean; readonly claim?: import('./verdict').StepsClaim; readonly clock?: Clock }
342
343/** A painter sees its row, the context and a pen. It has no `$` and no `next`: it cannot write, and it cannot skip the engine. */
344export type Painter<C extends RenderComponent, D extends Drawn<C> = Drawn<C>> = (e: RenderInput<C>, cx: Context, pen: Pen) => Promise<Decided<D>>
345
346/** The engine draws the row; we may only change its props. A replacing Spinner drops the cs tip (explore-cs 1.3, 0 of 60). */
347export type RewriteOnly<C extends RenderComponent> = Extract<Drawn<C>, { kind: 'engine' | 'rewrite' }>
348/** Ours goes around what next(e) drew (cs's band), never in its place. */
349export type AroundOnly<C extends RenderComponent> = Extract<Drawn<C>, { kind: 'engine' | 'frame' }>
350
351type Limits = {
352 Spinner: RewriteOnly<'Spinner'>
353 ToolProgress: RewriteOnly<'ToolProgress'>
354 SessionMode: RewriteOnly<'SessionMode'>
355 AbovePrompt: AroundOnly<'AbovePrompt'>
356}
357/** Every hooked site exactly once, each limited to what it may draw. A missing site, an extra one, or a Spinner painter that returns a tree is a compile error. */
358export type PainterTable = { readonly [C in Hooked]: Painter<C, C extends keyof Limits ? Limits[C] : Drawn<C>> }
359
360export const PAINTERS = {
361 UserMessage: async (e, cx, pen) => {
362 const { verdict, live } = decideUser({ ...e.props, uuid: e.requestId }, cx)
363 return { drawn: verdict.draw === 'engine' ? { kind: 'engine' } : { kind: 'tree', tree: drawUser(verdict, pen) }, live }
364 },
365 AssistantMessage: async (e, cx, pen) => {
366 // decideReply: error -> tree(error line); said hidden -> nothing;
367 // else frame: props { isFirstOfReply: false } (no engine bullet), frame = drawn => drawReply(verdict, drawn, pen).
368 // The markdown is the engine's (or cs-mermaid's) drawing in either load order.
369 // Live only while the steps-line claim is pending (verdict.ReplyDecision,
370 // ruling §20.23 (a)): the instance does not exist while the reply streams
371 // and is evaluated twice once stored (spike5 M18), before turn.complete
372 // when a Stop hook runs (M27); a pending reply reads LIVE, so the
373 // turn.complete bump re-evaluates it and it settles the claim then. This
374 // entry hands site() the claim with its Decided (the sketch leaves that
375 // one field untyped on the table); site() records a settled claim
376 // (session.noteStepsDrawn) and later evaluations reuse it (verdict.stepsOwner).
377 const { verdict, live, claim } = decideReply({ ...e.props, uuid: e.requestId }, cx)
378 return { drawn: { kind: 'frame', props: { isFirstOfReply: false }, frame: drawn => drawReply(verdict, drawn, pen) }, live, claim }
379 },
380 ToolUse: async (e, cx, pen) => {
381 // decideCall(toCallView(e.props), cx) -> nothing | tree(drawCall). Under the default renderer a lone call
382 // arrives here, under fullscreen as a one-call ToolGroup (spike2 M3): both go through decideCall.
383 const { verdict, live, clock } = decideCall(toCallView(e.props), cx)
384 return { drawn: { kind: 'tree', tree: drawCall(verdict, pen) }, live, clock }
385 },
386 ToolGroup: async (e, cx, pen) => {
387 const { verdict, live, clock } = decideGroup({ ...e.props, calls: e.props.calls.map(toCallView) }, cx)
388 return { drawn: verdict.draw === 'engine' ? { kind: 'engine' } : { kind: 'tree', tree: drawGroup(verdict, pen) }, live, clock }
389 },
390 ToolResult: async (e, cx, pen) => {
391 return { drawn: { kind: decideToolResult(callId(e.props.tool_use_id), e.props.isExpanded, cx) }, live: false }
392 },
393 ToolProgress: async () => ({ drawn: { kind: 'rewrite', props: { hint: '' } }, live: false }),
394 TurnDuration: async () => ({ drawn: { kind: 'nothing' }, live: false }),
395 CommandOutput: async (e, cx, pen) => {
396 const verdict = decideCommandOutput(e.props, cx)
397 return { drawn: typeof verdict === 'string' ? { kind: verdict } : { kind: 'tree', tree: drawCommandOutput(verdict, pen) }, live: false }
398 },
399 Spinner: async (e, cx) => {
400 // The working line: the engine's own row with our words as its message, never a tree of ours.
401 // "Writing" comes from the row's own mode ('responding'), not from the transcript: the reply's
402 // row is stored only once it has streamed (spike5 M18), too late for the line it names.
403 const { verdict, live, clock } = decideSpinner(e.props, cx)
404 return { drawn: verdict.draw === 'rewrite' ? { kind: 'rewrite', props: { message: verdict.message, suffix: '' } } : { kind: 'engine' }, live, clock }
405 },
406 SessionMode: async (e, cx) => {
407 return { drawn: { kind: 'rewrite', props: { modes: sessionModes(e.props.modes, cx.view) } }, live: false }
408 },
409 AbovePrompt: async (e, cx, pen) => {
410 // Ours ABOVE what next(e) drew, cs's band included; the tokens it reads are the decision's (decideBand).
411 const { verdict, live, clock } = decideBand(e.props, cx.usage ?? {}, cx)
412 return { drawn: verdict.draw === 'engine' ? { kind: 'engine' } : { kind: 'frame', frame: beneath => drawBand(verdict, beneath, Math.floor(cx.now / 70), pen) }, live, clock }
413 },
414} satisfies PainterTable
415
416/**
417 * The only way this mod hooks a render site, so the composition rules hold by
418 * construction rather than by each painter remembering them. sites() declares
419 * literal matchers for the host's static scan and delegates every evaluation
420 * here. The engine's matcher union does not accept a generic component value;
421 * literal declarations also give validate an exact component inventory.
422 *
423 * - next(e) runs exactly once per draw, whatever the decision. `engine(p?)`
424 * calls it (with props rewritten when given) and memoises; the `finally`
425 * calls it if nothing else did. An inner hook (cs's UserMessage rail
426 * observer, cs's band, cs-mermaid) therefore always runs, whichever order
427 * the user-tier plugins load in (CS:464-475, T:13457).
428 * - this session's feed off, or the row's own isExpanded (ctrl+o, --verbose)
429 * returns the engine's drawing with props untouched (§10, per row: no module
430 * flag). The off check reads the per-load mirror (session.feedRecord), not
431 * the file, so off costs the hop and next(e), no op; applyFeed() invalidates
432 * on every flip. The mirror says off in a session cs -vet-session refuses,
433 * whatever the file says (ruling §20.22). Until the load's record settles,
434 * every evaluation awaits it here, before next(e) and before context():
435 * in an on-session that wait holds the load's vet (one cs process,
436 * 0.15-0.44 s), so the first painted draw after a load waits for it before
437 * the refresh's two messages() reads (Q-idle-delivery option B).
438 * - a decision that is `live` reads LIVE, subscribing this instance alone to
439 * the next refresh; one with a `clock` reads TICK or PULSE (or both), only
440 * while what it draws moves; every other row is never redrawn by the
441 * feed's own updates.
442 * - a throw draws the engine's component (IDX:4102); next has already run.
443 * - for AssistantMessage, the reply's steps-line claim is recorded once,
444 * when it settles (noteStepsDrawn after the painter returns, claim
445 * 'drawn' or 'declined'; a 'pending' claim records nothing and its
446 * decision is live): module memory, plus at most one $.clock.after(0)
447 * LIVE bump. Nothing else a render does writes.
448 *
449 * on('ui.render', { component }, async ($, e, next) => {
450 * let drawn: Promise<RenderElement> | undefined
451 * const engine = (p?) => (drawn ??= next(p ? { ...e, props: { ...e.props, ...p } } : e))
452 * try {
453 * if (e.component === 'AbovePrompt') noteWorking(load, $, e.props.isWorking) // the phase stays settled while off
454 * if (ownExpanded(e)) return await engine()
455 * const s = load.feed ?? await feedRecord(load, $) // in an on-session the load's vet (one cs process) is awaited here
456 * if (!s.on) return await engine()
457 * const cx = await context($, e, s)
458 * const { drawn: d, live, clock } = await paint(e, cx, pen($, e, cx))
459 * if (live) await read($, LIVE)
460 * if (clock is 'tick' or 'both') await read($, TICK) // an elapsed time or a polled count it shows
461 * if (clock is 'pulse' or 'both') await read($, PULSE) // an animation it shows
462 * switch (d.kind) {
463 * case 'engine': return await engine()
464 * case 'rewrite': return await engine(d.props)
465 * case 'frame': return d.frame(await engine(d.props))
466 * case 'tree': await engine(); return d.tree
467 * case 'nothing': await engine(); return NOTHING(pen)
468 * }
469 * } finally {
470 * await engine()
471 * }
472 * })
473 */
474async function site<C extends Hooked>($: $, e: RenderInput<C>, next: (e: RenderInput<C>) => Promise<RenderElement>, paint: Painter<C>): Promise<RenderElement> {
475 let drawn: Promise<RenderElement> | undefined
476 const engine = (props?: Props<C>) => drawn ??= next(props ? { ...e, props: { ...e.props, ...props } } : e)
477 try {
478 if ('isWorking' in e.props) noteWorking(load, $, e.props.isWorking)
479 if ('isExpanded' in e.props && e.props.isExpanded) return await engine()
480 const session = load.feed ?? await feedRecord(load, $)
481 if (!session.on) return await engine()
482 const cx = await context($, e, session)
483 const pen: Pen = {
484 el: $.ui.resolve(e), ink: inks(load.light), room: cx.room,
485 pad: e.surface === 'terminal' ? 7 : 0, clickable: cx.clickable,
486 press: key => toggle(load, $, key),
487 helpers: async () => { await $.ui.open({ id: HELPERS_PANE, title: 'Helpers', focus: true }) }, cx,
488 }
489 const decision = await paint(e as RenderInput<C>, cx, pen)
490 if (e.component === 'AssistantMessage' && decision.claim !== undefined && decision.claim !== 'pending') noteStepsDrawn(load, $, e.requestId, decision.claim === 'drawn')
491 if (decision.live) await readLive($)
492 if (decision.clock === 'tick' || decision.clock === 'both') await readTick($)
493 if (decision.clock === 'pulse' || decision.clock === 'both') await readPulse($)
494 switch (decision.drawn.kind) {
495 case 'engine': return await engine()
496 case 'rewrite': return await engine(decision.drawn.props)
497 case 'frame': return decision.drawn.frame(await engine(decision.drawn.props))
498 case 'tree': await engine(); return decision.drawn.tree
499 case 'nothing': await engine(); return NOTHING(pen)
500 }
501 } finally {
502 await engine()
503 }
504}
505
506/** Installs every typed table entry through site(), with literal matchers for the engine's scan. */
507function sites(on: On, table: PainterTable): void {
508 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => { return site<'UserMessage'>($, e, next, table.UserMessage) })
509 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => { return site<'AssistantMessage'>($, e, next, table.AssistantMessage) })
510 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { return site<'ToolUse'>($, e, next, table.ToolUse) })
511 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => { return site<'ToolGroup'>($, e, next, table.ToolGroup) })
512 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => { return site<'ToolResult'>($, e, next, table.ToolResult) })
513 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => { return site<'ToolProgress'>($, e, next, table.ToolProgress) })
514 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => { return site<'TurnDuration'>($, e, next, table.TurnDuration) })
515 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => { return site<'CommandOutput'>($, e, next, table.CommandOutput) })
516 on('ui.render', { component: 'Spinner' }, async ($, e, next) => { return site<'Spinner'>($, e, next, table.Spinner) })
517 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => { return site<'SessionMode'>($, e, next, table.SessionMode) })
518 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { return site<'AbovePrompt'>($, e, next, table.AbovePrompt) })
519}
520
521/**
522 * What every painter reads, built once per painted draw: this session's view
523 * (from the record site() already holds) + transcript snapshot + open turn +
524 * observations. site() has noted the band's `isWorking` before this (the one
525 * place a render prop feeds the load); this starts the clocks on a painted draw.
526 * It awaits transcript(), which starts the refresh itself when one is only
527 * scheduled (session.Snap), so a first draw never takes a snapshot older than
528 * the dirty() that preceded it.
529 *
530 * startClocks(load, $)
531 * const t = await transcript(load, $)
532 * { t, view: s.view, obs: load.obs, opened: load.opened, open: openTurn(load, t), phase: load.obs.phase,
533 * agents: load.agents, now: Date.now(), surface: e.surface,
534 * clickable: e.surface !== 'terminal' || !!e.viewport?.isFullscreen, room: (e.viewport?.columns ?? 80) - pad,
535 * usage: the desktop band's rate limits, one $.session.usage() per desktop AbovePrompt draw }
536 */
537async function context($: $, e: RenderInput, s: import('./settings').Session): Promise<Context> {
538 const desktopBand = e.component === 'AbovePrompt' && e.surface === 'desktop'
539 startClocks(load, $, desktopBand)
540 const t = await transcript(load, $)
541 return {
542 t, view: s.view, obs: load.obs, opened: load.opened, open: openTurn(load, t),
543 phase: load.obs.phase, agents: load.agents, now: Date.now(), surface: e.surface,
544 clickable: e.surface !== 'terminal' || e.viewport?.isFullscreen === true,
545 room: Math.max(1, (e.viewport?.columns ?? 80) - (e.surface === 'terminal' ? 7 : 0)),
546 usage: desktopBand ? bandUsage(await $.session.usage()) : undefined,
547 }
548}
549
550/** §9.2 (b): the desktop band's two rate-limit windows, from session.usage (five_hour, seven_day); a window with no reading is left out. */
551function bandUsage(usage: import('claude-code').SessionUsage): { fiveHour?: number; week?: number } {
552 const percent = (kind: string) => usage.rateLimits.find(limit => limit.kind === kind)?.percentUsed
553 return { fiveHour: percent('five_hour'), week: percent('seven_day') }
554}
555
556/** ToolUse props or one ToolGroupCall, parsed at the boundary. */
557function toCallView(call: unknown): CallView {
558 if (!call || typeof call !== 'object' || Array.isArray(call) || !('tool' in call) || typeof call.tool !== 'string') throw new Error('tool row has no tool name')
559 const row = call as Record<string, unknown>
560 return {
561 id: typeof row.tool_use_id === 'string' && row.tool_use_id ? callId(row.tool_use_id) : undefined,
562 tool: call.tool,
563 input: row.input && typeof row.input === 'object' && !Array.isArray(row.input) ? row.input as Record<string, unknown> : {},
564 isRunning: row.isRunning === true, isErrored: row.isErrored === true, isInterrupted: row.isInterrupted === true, output: row.output,
565 }
566}
567hooks/session.ts 1022 lines1// ABOUTME: cs-feed's per-load core: this session's record (mirror of .cs/local/feed and feed-view), the transcript snapshot and its refresh, the observations and the clocks.
2// ABOUTME: A new Load is made by every register(); $.state holds only three redraw tokens, written only from $.clock callbacks; the record is written only from event hooks.
3import type { Args, EngineInterface } from 'claude-code'
4import { atom, read } from 'claude-code'
5
6import type { Session, View } from './settings'
7import { CS_LOCAL, DEFAULT_VIEW, FEED_FILE, OFF_LINE, ON_LINE, VIEW_FILE, VET_TIMEOUT_MS, isOn, parseStoredView, viewText } from './settings'
8import type { CallId, Transcript, Turn } from './transcript'
9import { buildTranscript, callId, EMPTY } from './transcript'
10import { logs, parseEnvelopes, senderOf } from './words'
11import { helperBusy } from './verdict'
12
13type $ = EngineInterface
14
15/**
16 * Redraw tokens. None holds a fact: the value is a counter whose only use is
17 * that a write redraws the drawings that read it while drawing (IDX:3469).
18 * A write that lands after this session's feed went off therefore changes
19 * nothing anyone draws from (the readers pass through); and the writers are
20 * $.clock callbacks, which recheck `active()` after every await.
21 *
22 * - LIVE: what an open turn's rows, the Spinner or the band draw from changed:
23 * the snapshot, the phase or the band's `working` (Seen, below). Read only by
24 * drawings that depend on the open turn. A reply row reads it only while its
25 * steps-line claim is pending (verdict.ReplyDecision, ruling §20.23 (a)), so
26 * turn.complete's bump re-evaluates that one instance; a settled reply never
27 * reads it (AssistantMessage is not evaluated while it streams, spike5 M18).
28 * - TICK: one second passed (elapsed times, the helper band, theme).
29 * - PULSE: an animation frame (70 ms), only while something drawn animates.
30 */
31export const LIVE = atom({ plugin: 'cs-feed', key: 'live' } as const, 0)
32export const TICK = atom({ plugin: 'cs-feed', key: 'tick' } as const, 0)
33export const PULSE = atom({ plugin: 'cs-feed', key: 'pulse' } as const, 0)
34
35/** Keeps the state source beside its literal declaration for the engine's scan. */
36export function readLive($: $): Promise<number> {
37 return read($, LIVE)
38}
39
40export function readTick($: $): Promise<number> { return read($, TICK) }
41export function readPulse($: $): Promise<number> { return read($, PULSE) }
42
43/**
44 * Where the main loop stands, from main-loop turn.start / turn.complete (observed,
45 * never awaited across: they set a field and call next). The observers run
46 * whatever this session's toggle says, so a flip finds the phase current.
47 * `unknown` only after a module load mid-turn (a change of these files, such as
48 * a cs update): no turn.start reaches a new load for a turn already running
49 * (spike Q5); the first band draw settles it from AbovePrompt.isWorking.
50 */
51export type Phase = 'unknown' | 'idle' | 'live'
52
53/**
54 * What a live event told this load about one stored row or call, keyed by its
55 * immutable id. These are observations, not a transcript copy: nothing is
56 * derived from them by merging with the snapshot, and losing them (a module
57 * reload) degrades to the documented fallback named on each.
58 */
59export type Observations = {
60 /**
61 * typed prompt uuid -> when it was stored (origin `composer`, door `prompt` or,
62 * typed mid-turn, `delivery`). A stored row's UserMessage.requestId is this
63 * uuid (spike5 M17, 17 of 17). Two instances match no row and draw no HH:MM:
64 * the pre-store `placeholder` instance, and a queued prompt's instances (their
65 * ids are in no row; the stored row's instance then uses the uuid).
66 * Lost: the row shows no HH:MM (§3.1 parity).
67 */
68 readonly promptAt: Map<string, number>
69 /** user-row uuid -> teammate frames parsed from the raw envelope (§8.3 source 1). Lost: parse the row's text. */
70 readonly frames: Map<string, readonly import('./words').Frame[]>
71 /** peer-row uuid -> its burst's head uuid; `open` is the burst a new peer row joins (§8.2). Lost: one line per message. */
72 readonly bursts: { readonly of: Map<string, string>; readonly members: Map<string, readonly { uuid: string; sender: string; at?: number }[]>; open?: string }
73 /** The helper batch keeps vanished agents' kinds until no member is busy. */
74 readonly batch: { readonly kinds: Map<string, string>; startedAt?: number }
75 /**
76 * reply uuid -> the newest call id stored before it in the same turn, the
77 * reply's whole text as stored, and an order stamp (§4.4). The key is the
78 * `response` append's e.uuid, which IS AssistantMessage.requestId (spike5 M17,
79 * 18 of 18; the full uuid, not its first groups, not the API's msg_ id).
80 *
81 * Recorded synchronously at the top of the response hook, BEFORE await
82 * next(e) (uuid and tool_use blocks are pinned; spike5 M18: the reply's two
83 * evaluations come +6-9 ms and +13-26 ms after its append, with no
84 * invalidate, and never while it streams), so the first evaluation finds it.
85 * The text is replaced by the stored text from next(e)'s result, synchronously,
86 * in case a hook beneath rewrote a text block (rewritable); an evaluation
87 * between the two sees a mismatch and reads as a fragment for that draw.
88 * While the turn is live that leaves the claim pending (§20.23), so
89 * the stored text is in place by the turn.complete re-evaluation. Only when
90 * the evaluation in that window also finds the turn spent (no Stop hook)
91 * does site() record stepsDrawn false, and the write-once record keeps it
92 * false for that reply: the line stays on the first call's row (rare,
93 * cosmetic; rationale gap 6).
94 *
95 * Whether the reply may carry the steps line is `words.repliesOwnSteps(text)`,
96 * a function of the text alone, so the tool row and the reply row agree in
97 * either plugin order (finding 6). The reply row draws it only when the text
98 * it is handed equals `text` (a fragment never draws it). Lost: the line
99 * stays on the first call's row.
100 */
101 readonly anchors: Map<string, { readonly call: CallId; readonly text: string; readonly seq: number }>
102 /** call id -> uuid of the newest anchored reply whose `call` is it: "the last such block wins" (§4.4a). An index over `anchors`, rewritten per reply. */
103 readonly latestByCall: Map<CallId, string>
104 /** The stamp the next anchor takes. */
105 anchorSeq: number
106 /**
107 * reply uuid -> whether that reply's row drew its turn's steps line, as the
108 * evaluation that SETTLED its claim decided (verdict.stepsOwner,
109 * StepsClaim): the first evaluation that is not 'pending'. Written once per
110 * uuid, by site() after the AssistantMessage painter (module memory, never
111 * $.state, so a render may write it); a pending evaluation writes nothing.
112 * The tool row yields only to `true`. A settling evaluation that read as a
113 * fragment (see `anchors`) records false for good; while the turn is live a
114 * fragment only leaves the claim pending. Lost: the line stays on the first
115 * call's row.
116 */
117 readonly stepsDrawn: Map<string, boolean>
118 /** The newest tool_use id stored since the last prompt row: the anchor a reply gets. */
119 callSincePrompt?: CallId
120 /**
121 * Live provenance for segmentation (finding 1): the first tool_use id of the
122 * first model row of a turn opened by an idle peer row. buildTranscript opens
123 * a turn at that call. Keyed by a call id because plain messages() holds call
124 * ids and nothing else stable. Origin `peer` covers teammate envelopes too,
125 * which the history rule (transcript.opensPeerTurn) does not match.
126 * A peer turn whose reply has no tool_use gives the response hook no id, so
127 * it takes no opener: only the history rule (joinApi) can open it
128 * (open-measurements M30). Lost: history rule only.
129 */
130 readonly turnOpeners: Set<CallId>
131 /**
132 * The last main-loop prompt-door row stored was an idle peer message (origin
133 * `peer`, isMeta). Set by the prompt hook SYNCHRONOUSLY after its await
134 * next(e) resolves, with no further await, from `door === 'prompt'`:
135 * `peerPending = origin.kind === 'peer' && isMeta` (so a typed prompt or an
136 * idle task notification sets it false). Consumed by turn.start, which
137 * copies it into `mainTurn.peer` and clears it.
138 *
139 * Measured order at the receiver (spike5 M19, 5 of 5): session.receive ->
140 * prompt.submit -> session.append door prompt (peer, isMeta) -> that hook's
141 * next(e) resolves -> turn.start 0-1 ms later. Both hooks run in this load's
142 * environment and the append continuation's seq was below turn.start's every
143 * time, so turn.start reads the flag the prompt hook just set.
144 */
145 peerPending: boolean
146 /**
147 * The main-loop turn running now, from turn.start to turn.complete: whether
148 * an idle peer row opened it, and whether its opener call was taken. The
149 * response hook adds the first tool_use id of a peer turn to turnOpeners.
150 */
151 mainTurn?: { readonly peer: boolean; openerTaken: boolean }
152 /**
153 * Agent call id -> when agent.spawn said it started: a RUNNING helper's elapsed
154 * time, the one helper fact no source carries. Everything else about a helper
155 * is derived (verdict.helperState). Lost: "running" with no time (§7.1 pin).
156 */
157 readonly helperStartedAt: Map<CallId, number>
158 /** background call id -> first time this load saw it (§9.1 elapsed; the transcript has no timestamps). */
159 readonly bgSeenAt: Map<CallId, number>
160 /**
161 * background call ids a task notification named as ended (its <tool-use-id>),
162 * seen at session.append on door `delivery` (mid-turn) or `prompt` (idle),
163 * origin `task-notification` (spike2 M1c). After reload the API snapshot also supplies recorded closes (option B).
164 */
165 readonly bgEnded: Set<CallId>
166 phase: Phase
167 /** Last AbovePrompt.isWorking (noteWorking). */
168 working?: boolean
169}
170
171/** The chevrons a person opened: run ids, call ids, `turn-<first call>`, `batch-<first helper>`, `burst-<uuid>`. */
172export type Opened = Set<string>
173
174/**
175 * What the drawings that read LIVE were last drawn from (finding 2; rationale
176 * decision 8, "Targeted redraws"). A refresh bumps LIVE when any member
177 * differs from the last bump's, and only then, so a LIVE reader is redrawn
178 * exactly when something it draws from changed and never by a stored row
179 * that changed none of it. The members, and why each is here:
180 *
181 * - `transcript`: the snapshot's calls, turns, runs and background runs. The
182 * open turn's rows, the Spinner and a not-yet-filed call draw from these.
183 * - `phase`: turn.start / turn.complete. turn.complete with an identical
184 * snapshot must still redraw the turn's rows spent (they then stop reading
185 * LIVE) and re-evaluate a reply whose steps-line claim is pending
186 * (§20.23 (a)). Trace: the final reply is stored, the refresh bumps (phase
187 * live); turn.complete sets phase idle and calls dirty(); the next refresh
188 * finds the same `transcript` but `phase` changed and bumps.
189 * - `working`: the band's AbovePrompt.isWorking, which settles phase after a
190 * module load mid-turn (no turn.start reaches it, spike Q5).
191 * - `burst`: the open peer burst's head and its members (§8.2). A peer row
192 * arriving joins the open burst, so its head, already drawn as one peer
193 * line, must redraw as "N messages"; the head reads LIVE while its burst is
194 * open and stops once Claude's text or a message of the person's closes it,
195 * so a closed head is drawn once and never again by the feed.
196 * - `helpers`: the top-level helpers the refresh's agent.list() reported,
197 * with their status. A spawn calls dirty() and asks the next refresh to read
198 * the list (Snap.askAgents), so the band (a LIVE reader while a turn is open
199 * or a helper is known) learns of a helper from the agent.spawn hook, not by
200 * polling; the load's first refresh reads it too, so a helper from before
201 * this load is found at the first painted draw.
202 */
203export type Seen = { readonly transcript: string; readonly phase: Phase; readonly working: boolean | undefined; readonly burst: string; readonly helpers: string }
204
205/**
206 * The snapshot and its one refresh (validate2 gap 2):
207 *
208 * clean `cached` is current; a render reads it with no $ call
209 * scheduled dirty() set a $.clock.after(0); a render that asks first starts the refresh itself
210 * refreshing `inFlight` is the one read in progress; every caller awaits it
211 *
212 * `again`: a dirty() that lands while `refreshing` sets it (it schedules
213 * nothing itself). When the in-flight refresh settles it clears `again` and
214 * starts exactly one more refresh, whose messages() read begins after every
215 * dirty() of the window, so the rows those dirty() calls announced are in it.
216 * N dirty() calls in one window cost one more read, not N.
217 */
218export type Snap = {
219 cached: Transcript
220 state: 'clean' | 'scheduled' | 'refreshing'
221 inFlight?: Promise<Transcript>
222 again: boolean
223 /** The next refresh also reads $.agent.list(): set for the load's first refresh and by every main-loop agent.spawn. */
224 askAgents: boolean
225 /** The Seen of the last LIVE bump. */
226 bumped?: Seen
227}
228
229export type Load = {
230 /**
231 * Mirror of FEED_FILE and VIEW_FILE; undefined until the per-load read
232 * settles (feedRecord). `on` is true only when the file says on AND cs
233 * trusts this session's control files (`vet`, below): a session cs refuses
234 * reads as off. Written by feedRecord() (the load's read) and applyFeed()
235 * (every later read), the view half also by writeFeed(); never by a render
236 * except through feedRecord's one per-load promise.
237 */
238 feed?: Session
239 record?: Promise<Session>
240 /**
241 * VIEW_FILE could not be used at the load's read (refused by
242 * parseStoredView, or a read error other than ENOENT): the mirror holds
243 * DEFAULT_VIEW, and the next successful writeFeed writes the view even when
244 * it equals the mirror's, so `/feed clean` repairs the file. Cleared by that
245 * write.
246 */
247 viewUnusable?: true
248 /** Writing commands wait before deriving their next view; readers keep their independent tickets. */
249 writes?: Promise<void>
250 /**
251 * cs's answer on this session's control files for the `on\n` the mirror
252 * last read (§20.22: the writes and the on-read are vetted).
253 * Undefined: not asked for the file's current on,
254 * or the file reads off. Set from vetOnRead's answer (by feedRecord at the
255 * load's on-read, by applyFeed('status') at an outside flip to on), by
256 * applyFeed to 'passed' when a press's write turned the file on (cs's
257 * pressFeed vetted it on the way in), and at a successful write's ticketed
258 * read-back. A read that finds the file off clears it, so the next on
259 * that no writer of ours vetted is vetted again. A refused writeFeed leaves
260 * it as it was (the mirror and the drawing stay as they were). Cached for
261 * the load otherwise: a session cs stops trusting after a pass keeps the
262 * pass until the file reads off or the next load (the band labels its key
263 * off at cs's next vet).
264 */
265 vet?: Vetted
266 /** The last read-side refusal logged ($.ui.log), so each distinct refusal is logged once per load, as cs's band de-duplicates its feed complaint (readFeed's feedComplaint). Cleared by a pass. */
267 vetLogged?: string
268 /**
269 * Read tickets for FEED_FILE (applyFeed): `started` is taken when a read
270 * begins, `applied` is the ticket of the read the mirror holds. A read
271 * whose ticket is below `applied` is dropped when it resolves, so the
272 * latest-started read wins even when two reads resolve out of order (a
273 * typed /feed's read-back beside a band press's read). Two quick band
274 * presses do not need it: cs's pressFeed is single-flight and hands the
275 * second press the first one's in-flight promise, so it writes once and both
276 * hooks' reads start after that write.
277 */
278 reads: { started: number; applied: number }
279 snap: Snap
280 readonly obs: Observations
281 readonly opened: Opened
282 /** The polled agent list and theme, refreshed by the 1 s tick. */
283 agents: readonly import('claude-code').AgentInfo[]
284 light: boolean
285 themeAt?: number
286 desktopBand?: boolean
287 /** Whether the tick and pulse are scheduled; cleared by a callback that stops. startClocks is idempotent on it, so an off-on-off within one load never doubles them. */
288 clocks: { tick: boolean; pulse: boolean }
289}
290
291export function newLoad(): Load {
292 return {
293 reads: { started: 0, applied: 0 }, snap: { cached: EMPTY, state: 'scheduled', again: false, askAgents: true },
294 obs: { promptAt: new Map(), frames: new Map(), bursts: { of: new Map(), members: new Map() }, batch: { kinds: new Map() }, anchors: new Map(), latestByCall: new Map(), anchorSeq: 0, stepsDrawn: new Map(), turnOpeners: new Set(), peerPending: false, helperStartedAt: new Map(), bgSeenAt: new Map(), bgEnded: new Set(), phase: 'unknown' },
295 opened: new Set(), agents: [], light: false, clocks: { tick: false, pulse: false },
296 }
297}
298
299/** Called after the prompt-door append settles, before turn.start. */
300export function observePrompt(load: Load, row: { door: 'prompt' | 'delivery'; peer: boolean; isMeta: boolean }): void {
301 if (row.door === 'prompt') {
302 load.obs.peerPending = row.peer && row.isMeta
303 load.obs.callSincePrompt = undefined
304 }
305}
306
307export function appendText(content: readonly { readonly type: string; readonly [field: string]: unknown }[]): string {
308 return content.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n')
309}
310
311export function observeAppend(load: Load, row: Args<'session.append'>): void {
312 const text = appendText(row.message.content)
313 const kind = row.origin.kind
314 const now = Date.now()
315 if (kind === 'composer' || kind === 'bridge') {
316 load.obs.bursts.open = undefined
317 if (kind === 'composer' && !row.message.isMeta) {
318 load.obs.promptAt.set(row.uuid, now)
319 boundMap(load.obs.promptAt, 2000)
320 }
321 }
322 const frames = parseEnvelopes(text)
323 if (frames.length) {
324 load.obs.frames.set(row.uuid, frames)
325 boundMap(load.obs.frames, 200)
326 }
327 if (kind === 'peer' || kind === 'peer-send-message') {
328 const head = load.obs.bursts.open ?? row.uuid
329 load.obs.bursts.open = head
330 load.obs.bursts.of.set(row.uuid, head)
331 const members = load.obs.bursts.members.get(head) ?? []
332 if (!members.some(member => member.uuid === row.uuid)) load.obs.bursts.members.set(head, [...members, { uuid: row.uuid, sender: senderOf(undefined, text), at: now }])
333 }
334 if (kind === 'task-notification') for (const match of text.matchAll(/<tool-use-id>\s*([^<]+?)\s*<\/tool-use-id>/g)) {
335 const id = match[1]?.trim()
336 if (id) load.obs.bgEnded.add(callId(id))
337 }
338}
339
340function boundMap<K extends string, V>(map: Map<K, V>, limit: number): void {
341 while (map.size > limit) {
342 const key = map.keys().next().value
343 if (key === undefined) return
344 map.delete(key)
345 }
346}
347
348export function observeModel(load: Load, uuid: string, content: readonly { readonly type: string; readonly [field: string]: unknown }[]): void {
349 const text = appendText(content)
350 const calls = content.flatMap(block => block.type === 'tool_use' && typeof block.id === 'string' && block.id ? [block.id] : [])
351 observeResponse(load, { uuid, text, calls })
352 for (const block of content) {
353 if (block.type !== 'tool_use' || block.name !== 'Bash' || typeof block.id !== 'string' || !block.id || !block.input || typeof block.input !== 'object' || !('run_in_background' in block.input) || block.input.run_in_background !== true) continue
354 const id = callId(block.id)
355 if (!load.obs.bgSeenAt.has(id)) load.obs.bgSeenAt.set(id, Date.now())
356 }
357}
358
359/**
360 * A main-loop helper started (agent.spawn, after its next). Records its start
361 * time (helperStartedAt) and asks the next refresh to read the agent list, so
362 * the band's LIVE redraw shows it (Seen.helpers); the hook calls dirty().
363 */
364export function observeSpawn(load: Load, id: string): void {
365 load.obs.helperStartedAt.set(callId(id), Date.now())
366 boundMap(load.obs.helperStartedAt, 200)
367 load.snap.askAgents = true
368}
369
370/**
371 * The polled agent list (the 1 s tick, and a refresh that Snap.askAgents
372 * marks) and the band's batch (§7.3 (b)): the helpers launched since none was
373 * busy, each kind remembered, its start taken when the first joins, all reset
374 * when none is busy.
375 */
376export function noteAgents(load: Load, agents: readonly import('claude-code').AgentInfo[]): void {
377 const previous = new Set(load.agents.map(agent => agent.id))
378 load.agents = agents
379 const crew = agents.filter(agent => !agent.parentId && !agent.teammateId)
380 if (crew.some(agent => helperBusy(agent.status))) {
381 load.obs.batch.startedAt ??= Date.now()
382 for (const agent of crew) if (helperBusy(agent.status) || load.obs.batch.kinds.has(agent.id) || !previous.has(agent.id)) load.obs.batch.kinds.set(agent.id, agent.type)
383 } else {
384 load.obs.batch.kinds.clear()
385 load.obs.batch.startedAt = undefined
386 }
387}
388
389export function observeTurnStart(load: Load): void {
390 load.obs.mainTurn = { peer: load.obs.peerPending, openerTaken: false }
391 load.obs.peerPending = false
392 load.obs.phase = 'live'
393 load.obs.callSincePrompt = undefined
394}
395
396export function observeTurnComplete(load: Load): void {
397 load.obs.phase = 'idle'
398 load.obs.mainTurn = undefined
399}
400
401/** Returns whether the shell must schedule the claim's LIVE bump. */
402export function recordStepsClaim(load: Load, reply: string, claim: import('./verdict').StepsClaim): boolean {
403 if (claim === 'pending' || load.obs.stepsDrawn.has(reply)) return false
404 const drew = claim === 'drawn'
405 load.obs.stepsDrawn.set(reply, drew)
406 return drew
407}
408
409/** Called before next(response); the returned stored text may then replace the anchor text. */
410export function observeResponse(load: Load, row: { uuid: string; text: string; calls: readonly string[] }): void {
411 if (row.text) load.obs.bursts.open = undefined
412 const first = row.calls[0]
413 if (first && load.obs.mainTurn?.peer && !load.obs.mainTurn.openerTaken) {
414 load.obs.turnOpeners.add(callId(first))
415 load.obs.mainTurn.openerTaken = true
416 }
417 if (row.text && load.obs.callSincePrompt) {
418 const call = load.obs.callSincePrompt
419 load.obs.anchors.set(row.uuid, { call, text: row.text, seq: ++load.obs.anchorSeq })
420 load.obs.latestByCall.set(call, row.uuid)
421 boundMap(load.obs.anchors, 400)
422 for (const [id, reply] of load.obs.latestByCall) if (!load.obs.anchors.has(reply)) load.obs.latestByCall.delete(id)
423 }
424 const last = row.calls.at(-1)
425 if (last) load.obs.callSincePrompt = callId(last)
426}
427
428export function replaceResponseText(load: Load, uuid: string, text: string): void {
429 const anchor = load.obs.anchors.get(uuid)
430 if (anchor) load.obs.anchors.set(uuid, { ...anchor, text })
431}
432
433/**
434 * The feed draws and its clocks run: this session's record says on.
435 * Rechecked (finding 4) in every clock callback and in the LIVE bump after
436 * every await in them, and immediately before every $.state write and every
437 * $.clock.after/every call: a typed /feed off (immediate, mid-turn included)
438 * or a band press can land during any of those awaits. Synchronous, so nothing
439 * can interleave between the check and the call it guards. Observers do not
440 * check it: they record whatever the toggle says. toggle() does not check it
441 * either, on purpose: a chevron press after off costs one invalidate, harmless.
442 */
443export function active(load: Load): boolean {
444 return load.feed?.on === true
445}
446
447/**
448 * This session's record, read once per load and mirrored in `load.feed`: one
449 * per-load promise, started by whichever caller asks first (a render, a
450 * command, a press) and awaited by every render, so a render that lands
451 * before anything else sees the file, not a default.
452 *
453 * const cwd = await $.session.cwd()
454 * fileOn = isOn(await $.fs.read(`${cwd}/${FEED_FILE}`) or undefined on ENOENT) // a ticketed read (applyFeed)
455 * load.vet = fileOn ? await vetOnRead(load, $, cwd) : undefined // off or absent: no cs process
456 * on = fileOn && load.vet === 'passed'
457 * view = parseStoredView(await $.fs.read(`${cwd}/${VIEW_FILE}`) or undefined)
458 * or, refused, DEFAULT_VIEW with one log line (storedView)
459 *
460 * A FEED_FILE read error other than ENOENT is logged ($.ui.log, with the
461 * error) and reads as off, as the band's readFeed does (it catches, logs a new
462 * complaint once and returns false), so both sides then show off, never on.
463 * VIEW_FILE never decides on or off: a torn, malformed or unreadable view is
464 * logged once (the load reads it once), the mirror takes DEFAULT_VIEW, and the
465 * next write rewrites the file (Load.viewUnusable).
466 *
467 * Vetted once per load when the file says on (§20.22: the on-read asks
468 * the same cs -vet-session as writeFeed). A
469 * refused session reads as off: the mirror says off, every hook passes the
470 * engine's drawing through, nothing is written, and the refusal is logged once
471 * (vetOnRead). cs's band labels the key off there too (`!feedVetRefused &&
472 * readFeed`: its last vet refused), so both sides agree and both refuse writes
473 * (rationale (e) trace 7). A session whose file says off or is absent spawns
474 * no cs process.
475 *
476 * Where it waits: site() awaits this promise at `load.feed ?? await
477 * feedRecord(load, $)`, before context() and so before the refresh's two
478 * messages() reads. In an on-session the first painted draw after a load
479 * therefore waits for the vet (one cs process, 0.15-0.44 s, cs's
480 * measurement; up to VET_TIMEOUT_MS, then refused, if cs hangs), and so does
481 * every hooked evaluation that arrives before the promise settles, the
482 * pass-through ones included, since off or on is not known until cs answers
483 * (a row passing through on its own isExpanded excepted: site() returns it
484 * before the await):
485 * their next(e), cs's band beneath AbovePrompt among them, runs after it. The
486 * hook's budget does not count the wait (HookBudget: the clock stops while a
487 * $ call is in flight). noteWorking runs before the await, so the phase still
488 * settles. After it settles, the off path costs no op again.
489 *
490 * A mirror, not a per-draw read: the off path costs the hop and next(e), no op.
491 * The file can change behind the mirror only through a writer other than cs's
492 * band press and /feed (a hand edit, a second Claude session in this
493 * directory); that is seen at the next press, /feed (/feed status re-reads
494 * it first, through applyFeed, and vets a flip to on), or load (Tradeoffs).
495 */
496export function feedRecord(load: Load, $: $): Promise<Session> {
497 return load.record ??= (async () => {
498 const cwd = await $.session.cwd()
499 const feed = await readLocal($, `${cwd}/${FEED_FILE}`)
500 const view = await storedView(load, $, `${cwd}/${VIEW_FILE}`)
501 load.vet = isOn(feed.text) ? await vetOnRead(load, $, cwd) : undefined
502 load.feed = { on: !feed.failed && isOn(feed.text) && load.vet === 'passed', view }
503 return load.feed
504 })()
505}
506
507/** VIEW_FILE's view, or DEFAULT_VIEW when it cannot be used (parseStoredView's refusal is logged here, a read error by readLocal). */
508async function storedView(load: Load, $: $, path: string): Promise<View> {
509 const stored = await readLocal($, path)
510 if (stored.failed) {
511 load.viewUnusable = true
512 return DEFAULT_VIEW
513 }
514 try {
515 return parseStoredView(stored.text)
516 } catch (error) {
517 load.viewUnusable = true
518 await $.ui.log(logs.viewUnusable(path, error instanceof Error ? error.message : String(error)))
519 return DEFAULT_VIEW
520 }
521}
522
523async function readLocal($: $, path: string): Promise<{ text?: string; failed: boolean }> {
524 try {
525 return { text: await $.fs.read(path), failed: false }
526 } catch (error) {
527 const message = error instanceof Error ? error.message : String(error)
528 if (/\bENOENT\b/.test(message)) return { failed: false }
529 await $.ui.log(logs.unread(path, message))
530 return { failed: true }
531 }
532}
533
534/**
535 * Which read applyFeed makes, and so whether cs already vetted the file it
536 * reads. `press`: after cs's pressFeed, which vets before it writes. `write`:
537 * writeFeed's read-back, after its own vet passed. `status`: /feed status's
538 * re-read, after no writer of ours: a flip to on found there arrived from
539 * outside both writers (a hand edit, a second Claude process in this
540 * directory, a pull), so it is vetted before it turns the feed on.
541 */
542export type FeedRead = 'press' | 'write' | 'status'
543
544/**
545 * Reads FEED_FILE and applies it to the mirror (ticketed, see Load.reads).
546 * Called after `await next(e)` in the ui.press hook on cs's `cs-feed` Button
547 * ('press'), after cs-feed's own write (writeFeed, 'write'), and by perform()
548 * before transition for /feed status ('status'), so status reports the file
549 * as vetted (off where cs refuses), not a mirror a hand edit or a second
550 * Claude process in this directory left stale. It reads FEED_FILE only: the
551 * view stays the mirror's.
552 *
553 * await feedRecord(load, $) // the load's read first, the same per-load promise: a press
554 * // during the load's vet neither finds load.feed unset nor outranks its ticket
555 * const ticket = ++load.reads.started
556 * const fileOn = isOn(the read)
557 * let vet = load.vet
558 * if (!fileOn) vet = undefined // the next on is vetted again unless a writer of ours vetted it
559 * else if (after === 'write') vet = 'passed' // commit this writer's fresh answer with its read ticket
560 * else if (vet === undefined && after !== 'status') vet = 'passed' // the writer vetted it on the way in: no cs process
561 * else if (vet === undefined) vet = await vetOnRead(load, $, cwd) // an outside flip to on: one cs process
562 * if (ticket < load.reads.applied) return // a read started earlier that resolves later changes nothing, load.vet included
563 * load.reads.applied = ticket; load.vet = vet
564 * const on = fileOn && vet === 'passed' // a cached refusal keeps a refused press's unchanged on, off
565 *
566 * and when `on` changed:
567 *
568 * load.feed = { on, view: load.feed.view }
569 * if (on) dirty(load, $) // a flip on rebuilds once, whole
570 * $.ui.invalidate('ui.render') // every hooked instance reads the mirror again
571 *
572 * The press path trusts the file it reads after next(e): cs's refusal is cs's
573 * module memory, so an outside on that lands after cs-feed's last read and
574 * before a press cs refuses (the file read off before, on after) reads as the
575 * press's write and turns on.
576 * Accepted (ruling §20.22: a press's read is not vetted again); the band
577 * labels the key off there, and the next load's vet reads it off.
578 *
579 * The invalidate is cs-feed's own and comes after the mirror changed. cs's
580 * pressFeed already invalidated before cs-feed's next(e) resolved (cs's
581 * instances, as the contract states it; the scope is unmeasured,
582 * open-measurements M28: if it is global, that wave drew every feed instance
583 * with the old mirror, a cost only), so the shared AbovePrompt instance may
584 * have been drawn once with cs-feed's old mirror; this later invalidate supersedes any evaluation still
585 * in flight (spike3 M15: drawn again at once with the new value, never blank).
586 * It is also what redraws cs's band after a typed /feed: cs-feed hooks
587 * AbovePrompt, so its invalidate re-evaluates that instance, whose chain runs
588 * cs's band hook, which re-reads the file (inferred from the shared hop
589 * labels, spike3 M14 debug and spike4 3b; not measured for this pair).
590 * Never called from a render, never from a session.append hook.
591 */
592export async function applyFeed(load: Load, $: $, after: FeedRead): Promise<void> {
593 await feedRecord(load, $)
594 const ticket = ++load.reads.started
595 const cwd = await $.session.cwd()
596 const file = await readLocal($, `${cwd}/${FEED_FILE}`)
597 if (ticket < load.reads.applied) return
598 const fileOn = isOn(file.text)
599 let vet = load.vet
600 let asked = false
601 if (!fileOn) vet = undefined
602 else if (after === 'write') vet = 'passed'
603 else if (vet === undefined && after === 'status') {
604 const refused = await vetRefusal($, cwd)
605 vet = refused === undefined ? 'passed' : { refused }
606 asked = true
607 } else if (vet === undefined) vet = 'passed'
608 if (ticket < load.reads.applied) return
609 load.reads.applied = ticket
610 load.vet = vet
611 if (vet === 'passed') load.vetLogged = undefined
612 const current = load.feed
613 if (!current) throw new Error('feed record did not settle')
614 const on = fileOn && vet === 'passed'
615 if (current.on !== on) {
616 load.feed = { on, view: current.view }
617 if (on) dirty(load, $)
618 }
619 if (asked && vet !== undefined) await reportVet(load, $, vet)
620 if (current.on !== on) await $.ui.invalidate('ui.render')
621}
622
623/** What a /feed write did: written, nothing (no .cs/local), or nothing because cs refused this session's control files (the reason, for the reply). */
624export type WriteOutcome = 'written' | 'no-session' | { readonly refused: string }
625
626/**
627 * Writes this session's record, from an event hook only (perform: /feed typed,
628 * a settings-pane press), and only where cs trusts this session's control
629 * files, as cs's band press does (§20.22):
630 *
631 * const cwd = await $.session.cwd()
632 * if (!(await $.fs.exists(`${cwd}/${CS_LOCAL}`))) return 'no-session' // never create .cs/local; no vet spawned
633 * const refusal = await vetRefusal($, cwd)
634 * if (refusal !== undefined) return { refused: refusal } // nothing written, the mirror and load.vet untouched
635 * if (view changed or load.viewUnusable) await $.fs.write(`${cwd}/${VIEW_FILE}`, viewText(next.view)) // view first: a reader never sees on with the old view
636 * load.feed = { on: load.feed.on, view: next.view }
637 * await $.fs.write(`${cwd}/${FEED_FILE}`, next.on ? ON_LINE : OFF_LINE)
638 * await applyFeed(load, $, 'write') // read back and commit this write's pass with its ticket; no second vet
639 * return 'written'
640 *
641 * The vet comes after the directory test, so a directory with no .cs/local
642 * spawns no cs process, and before the first write, so a refused session gets
643 * neither file. Its cost is one cs process per writing /feed (0.15-0.44 s,
644 * measured by cs), awaited inside the immediate command, mid-turn included.
645 * Non-atomic writes, as the band's: a write torn by a crash leaves a file that
646 * reads as off on both sides.
647 */
648export async function writeFeed(load: Load, $: $, next: Session): Promise<WriteOutcome> {
649 const cwd = await $.session.cwd()
650 if (!(await $.fs.exists(`${cwd}/${CS_LOCAL}`))) return 'no-session'
651 const refused = await vetRefusal($, cwd)
652 if (refused !== undefined) return { refused }
653 const current = load.feed ?? await feedRecord(load, $)
654 const viewChanged = viewText(current.view) !== viewText(next.view)
655 if (viewChanged || load.viewUnusable) {
656 await $.fs.write(`${cwd}/${VIEW_FILE}`, viewText(next.view))
657 load.viewUnusable = undefined
658 }
659 load.feed = { on: current.on, view: next.view }
660 await $.fs.write(`${cwd}/${FEED_FILE}`, next.on ? ON_LINE : OFF_LINE)
661 await applyFeed(load, $, 'write')
662 if (viewChanged && load.feed.on === current.on) await $.ui.invalidate('ui.render')
663 return 'written'
664}
665
666/**
667 * cs's answer on this session's control files, by cs's own rule, asked the
668 * way cs's band asks it (mods/cs/hooks/register.tsx sessionVetted, on main):
669 *
670 * const bin = await $.env.get("CS_BIN") // a literal: validate lists the env names a module reads
671 * no bin -> 'the launch did not say where cs is (CS_BIN)'
672 * await $.process.run([bin, '-vet-session', cwd], { timeoutMs: VET_TIMEOUT_MS })
673 * exit 0 -> undefined (trusted)
674 * exit != 0 -> the last non-empty stderr line, else `cs -vet-session exited ${code}`
675 * throws (a timeout too) -> `cs -vet-session did not run: ${message}`
676 *
677 * Returns the refusal or undefined. It reports nothing itself: each caller
678 * says the refusal its own way. writeFeed's caller (perform) puts it in the
679 * command's reply, once, to the person who typed it; vetOnRead logs it once
680 * per distinct text. No toast and no `lastRefusal`: cs's own lastRefusal is
681 * cs's module memory; this shares nothing with it, and cs's band asks again
682 * at its next acting step, so the two answer the same rule, not the same call.
683 */
684export async function vetRefusal($: $, cwd: string): Promise<string | undefined> {
685 const bin = await $.env.get('CS_BIN')
686 if (!bin) return 'the launch did not say where cs is (CS_BIN)'
687 try {
688 const result = await $.process.run([bin, '-vet-session', cwd], { timeoutMs: VET_TIMEOUT_MS })
689 if (result.exitCode === 0) return undefined
690 return result.stderr.split(/\r?\n/).filter(line => line.trim() !== '').at(-1) ?? `cs -vet-session exited ${result.exitCode}`
691 } catch (error) {
692 return `cs -vet-session did not run: ${error instanceof Error ? error.message : String(error)}`
693 }
694}
695
696/** cs's answer on this session's control files: trusted, or refused with cs's own text (vetRefusal's). */
697export type Vetted = 'passed' | { readonly refused: string }
698
699/**
700 * The read-side vet (§20.22: a file that
701 * says on is vetted before the feed turns on). Asked only when FEED_FILE reads
702 * exactly `on\n` and no answer is cached for that on (Load.vet): by
703 * feedRecord at the load's read, and by applyFeed('status') for a flip to on
704 * no writer of ours vetted. The same vetRefusal as writeFeed's, so the same
705 * argv, VET_TIMEOUT_MS and refusal texts as cs's sessionVetted.
706 *
707 * const refusal = await vetRefusal($, cwd)
708 * if (refusal === undefined) { load.vetLogged = undefined; return 'passed' }
709 * if (refusal !== load.vetLogged) $.ui.log(`the feed is off in this session: ${refusal}`) // the engine leads the line with the plugin name
710 * load.vetLogged = refusal
711 * return { refused: refusal }
712 *
713 * The log de-duplicates per distinct text, as cs's readFeed does with its
714 * feed complaint: a cleared answer vetted again with the same refusal logs
715 * nothing new. The caller stores the answer in Load.vet (applyFeed only when
716 * its read still wins its ticket). Cost: one cs process (0.15-0.44 s), in
717 * on-sessions only.
718 */
719export async function vetOnRead(load: Load, $: $, cwd: string): Promise<Vetted> {
720 const refused = await vetRefusal($, cwd)
721 const vet = refused === undefined ? 'passed' : { refused }
722 await reportVet(load, $, vet)
723 return vet
724}
725
726async function reportVet(load: Load, $: $, vet: Vetted): Promise<void> {
727 if (vet === 'passed') {
728 load.vetLogged = undefined
729 return
730 }
731 if (vet.refused !== load.vetLogged) {
732 load.vetLogged = vet.refused
733 await $.ui.log(logs.feedOff(vet.refused))
734 }
735}
736
737/**
738 * The current snapshot (Snap). Clean: the cached one, no $ call. Scheduled:
739 * start the refresh now (the pending after(0) then finds `refreshing` and does
740 * nothing). Refreshing: the in-flight promise, shared by every render that
741 * asks. A refresh is
742 *
743 * snap.state = 'refreshing'
744 * const plain = await $.session.messages()
745 * const api = await $.session.messages({ as: 'api' }) // Q-idle-delivery option B
746 * if (snap.askAgents) noteAgents(load, await $.agent.list()) // the load's first refresh, and after a spawn
747 * snapshot = buildTranscript(plain, api, load.obs.turnOpeners) // whole, never merged
748 * snap.cached = snapshot
749 * const seen = { transcript: signature(snapshot), phase, working, burst, helpers } // Seen
750 * startPulse(load, $) // a helper the spawn revealed animates at its first redraw
751 * if (seen differs from snap.bumped in any member) $.clock.after(0, bump)
752 * bump: if (!active(load)) return; snap.bumped = seen; $.state.set(LIVE, n + 1)
753 * if (snap.again && active(load)) { snap.again = false; start one more refresh } // gap 2
754 * else { snap.state = snap.again ? 'scheduled' : 'clean'; snap.again = false } // off: marked, no timer; the next painted draw refreshes
755 *
756 * and is the only $.session.messages() call the mod makes for the main loop.
757 * A render that finds `scheduled` runs the refresh inside itself, and one that
758 * finds `refreshing` awaits it: either way it awaits two messages() reads
759 * under option B (one otherwise), whose latency at 4096 entries is
760 * open-measurements #6. The first painted draw after a flip on is such a
761 * render.
762 */
763export function transcript(load: Load, $: $): Promise<Transcript> {
764 const snap = load.snap
765 if (snap.state === 'clean') return Promise.resolve(snap.cached)
766 if (snap.inFlight) return snap.inFlight
767 snap.state = 'refreshing'
768 snap.inFlight = (async () => {
769 try {
770 const plain = await $.session.messages()
771 const api = await $.session.messages({ as: 'api' })
772 if (snap.askAgents) {
773 snap.askAgents = false
774 noteAgents(load, await $.agent.list())
775 }
776 const snapshot = buildTranscript(plain, api, load.obs.turnOpeners)
777 snap.cached = snapshot
778 const seen: Seen = {
779 transcript: JSON.stringify([[...snapshot.calls.values()], snapshot.turns, [...snapshot.runs.values()], snapshot.background]),
780 phase: load.obs.phase, working: load.obs.working,
781 burst: JSON.stringify([load.obs.bursts.open, load.obs.bursts.open ? load.obs.bursts.members.get(load.obs.bursts.open) : undefined]),
782 helpers: JSON.stringify(load.agents.filter(agent => !agent.parentId && !agent.teammateId).map(agent => [agent.id, agent.status])),
783 }
784 startPulse(load, $)
785 if (active(load) && JSON.stringify(seen) !== JSON.stringify(snap.bumped)) {
786 $.clock.after(0, async () => {
787 if (!active(load)) return
788 const value = await read($, LIVE)
789 if (!active(load)) return
790 snap.bumped = seen
791 await $.state.set({ plugin: 'cs-feed', key: 'live' }, value + 1)
792 })
793 }
794 const again = snap.again
795 snap.again = false
796 snap.inFlight = undefined
797 snap.state = again ? 'scheduled' : 'clean'
798 if (again && active(load)) return transcript(load, $)
799 return snapshot
800 } catch (error) {
801 snap.inFlight = undefined
802 snap.state = 'scheduled'
803 throw error
804 }
805 })()
806 return snap.inFlight
807}
808
809/**
810 * The transcript grew (session.append on the main loop, after next(e)), a turn
811 * began or ended, AbovePrompt.isWorking flipped, or this session turned on.
812 *
813 * refreshing -> snap.again = true (one more refresh after this one; nothing scheduled here)
814 * scheduled -> nothing (the scheduled refresh has not read yet)
815 * clean -> state = 'scheduled'; if (active(load)) $.clock.after(0, run the refresh)
816 *
817 * Off, the snapshot is only marked: nothing reads messages(). dirty()
818 * itself never reads messages() and never awaits: it marks or schedules. The
819 * refresh runs in the after(0) callback or, when a render asks first, inside
820 * that render (transcript()). So the first painted draw after a flip on
821 * awaits the refresh's reads inside the render: two messages() calls under
822 * Q-idle-delivery option B (plain, then as: 'api'), one otherwise
823 * (open-measurements #6 is on this path). dirty() never invalidates
824 * (an invalidate from an append hook re-evaluates every instance the
825 * plugin's matchers select, 7-9 AssistantMessage and 7-9 UserMessage per
826 * reply in spike5 M18).
827 */
828export function dirty(load: Load, $: $): void {
829 if (load.snap.state === 'refreshing') {
830 load.snap.again = true
831 return
832 }
833 if (load.snap.state === 'scheduled') return
834 load.snap.state = 'scheduled'
835 if (active(load)) $.clock.after(0, async () => {
836 if (active(load) && load.snap.state === 'scheduled') await transcript(load, $)
837 })
838}
839
840/**
841 * The open turn: the transcript's last turn while the main loop is live. With
842 * phase `unknown`, the engine's AbovePrompt.isWorking decides, and before the
843 * band has drawn, whether the last turn holds a call with no result.
844 *
845 * Returns the Turn itself, not its id: an open turn with no call yet has no id
846 * (TurnId is its first call's), and the Spinner still needs it ("Thinking").
847 */
848export function openTurn(load: Load, t: Transcript): Turn | undefined {
849 const last = t.turns.at(-1)
850 if (load.obs.phase === 'idle') return undefined
851 if (load.obs.phase === 'live') return last
852 const working = load.obs.working ?? [...t.calls.values()].some(call => call.turn === last?.id && call.outcome.state === 'running')
853 return working ? last : undefined
854}
855
856/**
857 * The engine's word on whether the main loop works, from an AbovePrompt draw
858 * (context() calls this, and site() calls it on the pass-through path too, so
859 * the phase stays settled while this session's feed is off). Sets `working`;
860 * settles phase `unknown` to `live` or `idle`. A flip calls dirty(). Starts the
861 * clocks only on a painted draw.
862 */
863export function noteWorking(load: Load, $: $, isWorking: boolean): void {
864 const changed = load.obs.working !== isWorking || load.obs.phase === 'unknown'
865 load.obs.working = isWorking
866 if (load.obs.phase === 'unknown') load.obs.phase = isWorking ? 'live' : 'idle'
867 if (changed) dirty(load, $)
868}
869
870/**
871 * The clocks, started at a painted draw and gated on need: the 1 s tick
872 * (agent list while pollsAgents, theme every 30 s, a TICK write) while active; the 70 ms pulse
873 * only while a helper runs or a background run is listed (ruling §20.14).
874 * Idempotent on load.clocks. Each callback (finding 4):
875 *
876 * tick = async () => {
877 * if (!active(load)) return stop('tick')
878 * if (pollsAgents(load)) { const agents = await $.agent.list(); if (!active(load)) return stop('tick') }
879 * const light = await themeIsLight($, load); if (!active(load)) return stop('tick')
880 * load.agents = agents; load.light = light
881 * await $.state.set(TICK, n + 1) // an in-flight write that settles after off redraws only readers, which now pass through
882 * if (!active(load)) return stop('tick')
883 * $.clock.after(1000, tick)
884 * }
885 * stop(k): load.clocks[k] = false // the next painted draw starts it again
886 *
887 * What races here is a per-session off landing during an await (a typed
888 * /feed off runs mid-turn; a band press while idle). A module reload cancels
889 * pending waits (IDX:3512) and the engine never calls a superseded module for a
890 * render (spike2 M2); a callback already past its wait writes a token no
891 * reader of the new load reads, so no reload-specific check is kept.
892 *
893 * Validation (kit, `claude plugin test`): mock.clock; the test's own hook on the
894 * `agent.list` op answers from a promise the test holds (ops are events,
895 * IDX:7008-7016; the kit has no agent mock). Advance to the first tick, run
896 * `/feed off` while agent.list is held, release it, advance 5 s: zero
897 * `state.set` of TICK or PULSE after the flip and no held wait left.
898 */
899export function startClocks(load: Load, $: $, desktopBand = false): void {
900 if (!active(load)) return
901 if (desktopBand) load.desktopBand = true
902 if (!load.clocks.tick) {
903 load.clocks.tick = true
904 $.clock.after(1000, async function tick() {
905 if (!active(load)) { load.clocks.tick = false; return }
906 try {
907 if (pollsAgents(load)) {
908 const agents = await $.agent.list()
909 if (!active(load)) { load.clocks.tick = false; return }
910 noteAgents(load, agents)
911 }
912 if (load.themeAt === undefined || Date.now() - load.themeAt >= 30000) {
913 const rows = await $.config.list()
914 if (!active(load)) { load.clocks.tick = false; return }
915 const theme = rows.find(row => row.key === 'theme')?.value
916 load.light = typeof theme === 'string' && theme.startsWith('light')
917 if (load.desktopBand) {
918 const appearance = await osAppearance($)
919 if (!active(load)) { load.clocks.tick = false; return }
920 if (appearance !== undefined) load.light = appearance === 'light'
921 }
922 load.themeAt = Date.now()
923 }
924 const value = await read($, TICK)
925 if (!active(load)) { load.clocks.tick = false; return }
926 await $.state.set({ plugin: 'cs-feed', key: 'tick' }, value + 1)
927 if (!active(load)) { load.clocks.tick = false; return }
928 startPulse(load, $)
929 if (active(load)) $.clock.after(1000, tick)
930 } catch (error) {
931 load.clocks.tick = false
932 await $.ui.log(logs.clockStopped(error instanceof Error ? error.message : String(error)))
933 }
934 })
935 }
936 startPulse(load, $)
937}
938
939/**
940 * Whether the tick asks $.agent.list(): while a main-loop turn is open (a
941 * helper may start or end inside it) or a top-level helper the last list
942 * reported is busy (a background helper's end is found by this poll). Idle
943 * with no busy helper it asks nothing; a later helper becomes known through
944 * the agent.spawn hook, whose refresh reads the list (Snap.askAgents). The
945 * list keeps a finished agent for a while (the contract: "until the engine
946 * drops its task"), so a listed but finished helper does not keep the poll.
947 */
948function pollsAgents(load: Load): boolean {
949 // phase live counts before the snapshot holds the turn's first row; unknown asks openTurn (the band's isWorking, a running call).
950 return load.obs.phase === 'live' || openTurn(load, load.snap.cached) !== undefined || load.agents.some(agent => !agent.parentId && !agent.teammateId && helperBusy(agent.status))
951}
952
953/**
954 * The desktop band follows the operating system's appearance (§13 (a), ruling
955 * §20.7: probed from the desktop band's path only). "Dark" is dark; a run that
956 * answers without it (the key is absent) is light; a probe that does not run
957 * is unknown, and the /config theme stands (§13 (b)).
958 */
959async function osAppearance($: $): Promise<'dark' | 'light' | undefined> {
960 try {
961 const probe = await $.process.run(['/usr/bin/defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 1000 })
962 return probe.exitCode === 0 && probe.stdout.trim() === 'Dark' ? 'dark' : 'light'
963 } catch {
964 return undefined
965 }
966}
967
968function animating(load: Load): boolean {
969 return load.agents.some(agent => helperBusy(agent.status)) || load.snap.cached.background.some(run => !load.obs.bgEnded.has(run.id))
970}
971
972function startPulse(load: Load, $: $): void {
973 if (!active(load) || !animating(load) || load.clocks.pulse) return
974 load.clocks.pulse = true
975 $.clock.after(70, async function pulse() {
976 if (!active(load) || !animating(load)) { load.clocks.pulse = false; return }
977 try {
978 const value = await read($, PULSE)
979 if (!active(load) || !animating(load)) { load.clocks.pulse = false; return }
980 await $.state.set({ plugin: 'cs-feed', key: 'pulse' }, value + 1)
981 if (!active(load) || !animating(load)) { load.clocks.pulse = false; return }
982 $.clock.after(70, pulse)
983 } catch (error) {
984 // Stopped, not stuck: the flag clears so the next painted draw starts the pulse again.
985 load.clocks.pulse = false
986 await $.ui.log(logs.pulseStopped(error instanceof Error ? error.message : String(error)))
987 }
988 })
989}
990
991/**
992 * Records a reply's settled steps-line claim once (Observations.stepsDrawn):
993 * `drew` is claim === 'drawn'; site() does not call it for a 'pending' claim.
994 * A new `true` schedules one LIVE bump through $.clock.after(0) (a clock
995 * callback, as every token write; it rechecks active()), so the first call's
996 * row, live while the claim was pending, redraws and yields. Called by
997 * site(), the one place a render feeds the load besides noteWorking.
998 */
999export function noteStepsDrawn(load: Load, $: $, reply: string, drew: boolean): void {
1000 if (!recordStepsClaim(load, reply, drew ? 'drawn' : 'declined') || !active(load)) return
1001 $.clock.after(0, async () => {
1002 if (!active(load)) return
1003 const value = await read($, LIVE)
1004 if (!active(load)) return
1005 await $.state.set({ plugin: 'cs-feed', key: 'live' }, value + 1)
1006 })
1007}
1008
1009/**
1010 * A chevron press: flip the key, then one $.ui.invalidate from the press
1011 * handler (never from a render). Not gated on active(): a press that lands
1012 * after this session went off (a stale handle: a drawing still on screen, or
1013 * a press in flight across the flip) flips its key and invalidates once.
1014 * Every instance then passes through, so it costs one evaluation wave and
1015 * changes nothing drawn.
1016 */
1017export async function toggle(load: Load, $: $, key: string): Promise<void> {
1018 if (load.opened.has(key)) load.opened.delete(key)
1019 else load.opened.add(key)
1020 await $.ui.invalidate('ui.render')
1021}
1022hooks/settings.ts 263 lines1// ABOUTME: cs-feed settings: the per-session record (on/off and the view), the two presets, the mode name, and the /feed grammar, all derived from one FIELDS table.
2// ABOUTME: Pure. On/off is the file .cs/local/feed (cs's band writes it too); the view is the sibling .cs/local/feed-view, which only cs-feed writes.
3
4export const PLUGIN = 'cs-feed'
5
6/**
7 * The per-session truth, shared with cs's band.
8 * Relative to `$.session.cwd()`, which both sides read the same
9 * way. Exactly `on\n` is on; anything else is off: absent, torn by a
10 * non-atomic write, `off\n`, or any other text. cs's band reads it with
11 * `=== 'on\n'` (mods/cs/hooks/register.tsx readFeed) and writes it from the
12 * chord `ctrl+x 3 feed` (pressFeed). A contract between two plugins: KEEP IN
13 * SYNC with readFeed and pressFeed; a test in each mod pins these literals.
14 */
15export const FEED_FILE = '.cs/local/feed'
16export const ON_LINE = 'on\n'
17export const OFF_LINE = 'off\n'
18/** The directory whose presence says "this may be a cs session". /feed never creates it ($.fs.write would), and asks cs -vet-session only where it exists. */
19export const CS_LOCAL = '.cs/local'
20/**
21 * How long `cs -vet-session` may take before its answer counts as a refusal.
22 * Every /feed write asks it first, as cs's band press does, and so does a
23 * read that finds FEED_FILE on with no answer cached for it (§20.22,
24 * session.vetRefusal, session.vetOnRead). KEEP
25 * IN SYNC with VET_TIMEOUT_MS in mods/cs/hooks/register.tsx. The env name
26 * `CS_BIN` stays a literal at its one read, as cs's is: validate lists the
27 * names a module reads.
28 */
29export const VET_TIMEOUT_MS = 10000
30/**
31 * The view (clean/normal fields and the hide list), JSON plus a closing
32 * newline. A sibling of FEED_FILE, not inside it: the band's readFeed is
33 * `=== 'on\n'`, so any second field in `feed` would read as off there.
34 * Written only by cs-feed (/feed and the settings pane).
35 */
36export const VIEW_FILE = '.cs/local/feed-view'
37/** The Button cs's band draws for the chord: `ui.press` reaches cs-feed's hook addressed `{ plugin, element }`. KEEP IN SYNC with cs's buildBandEntries key `cs-feed`. */
38export const BAND_PLUGIN = 'cs'
39export const BAND_ELEMENT = 'cs-feed'
40
41/** What the feed file says. Pure: the whole line or nothing (a torn write without its newline is off, as the band reads it). */
42export function isOn(text: string | undefined): boolean {
43 return text === ON_LINE
44}
45
46export const KINDS = ['you', 'said', 'tool', 'agent', 'question', 'peer', 'helper', 'search'] as const
47export type Kind = (typeof KINDS)[number]
48
49export type Style = 'expanded' | 'subtle' | 'line'
50export type Commands = 'grouped' | 'each'
51export type After = 'clear' | 'keep'
52
53/**
54 * What the view draws while this session's feed is on. There is no `raw`
55 * field: raw is `Session.on === false`, and site() returns the engine's
56 * drawing before any painter runs, so no painter can read a view while raw
57 * (the fork's C1 class: per-hook raw checks).
58 */
59export type View = {
60 readonly group: boolean
61 readonly clear: boolean
62 readonly keep: boolean
63 readonly style: Style
64 readonly commands: Commands
65 readonly after: After
66 readonly hide: readonly Kind[]
67}
68
69/**
70 * This session's record as cs-feed mirrors it per load (session.feedRecord):
71 * `on` from FEED_FILE where cs -vet-session passes (a file that says on in a
72 * session cs refuses reads as off, ruling §20.22), `view` from VIEW_FILE.
73 * Not $.state: lost at /clear, --resume, --continue and a relaunch (spike3
74 * M13). `on` false: every hook
75 * passes the engine's drawing through (spike4: +32 worker hops per three-turn
76 * scenario beside cs's three mods, about +8-10 ms at each turn's end, accepted);
77 * the observers keep observing.
78 */
79export type Session = { readonly on: boolean; readonly view: View }
80
81export type Preset = 'clean' | 'normal'
82export type Mode = 'raw' | Preset | 'custom'
83
84/** Everything a View holds but `hide`: what a preset sets. */
85export type Fields = Omit<View, 'hide'>
86
87export const PRESETS: { readonly [P in Preset]: { readonly fields: Fields; readonly help: string } } = {
88 clean: { fields: { group: true, clear: true, keep: false, style: 'subtle', commands: 'grouped', after: 'clear' }, help: "your messages, Claude's replies, and one line per turn for the work" },
89 normal: { fields: { group: false, clear: false, keep: true, style: 'subtle', commands: 'grouped', after: 'keep' }, help: 'each run of tool calls is one line with counts' },
90}
91export const RAW_HELP = "Claude Code's own drawing"
92
93/**
94 * The view an absent VIEW_FILE yields: clean, no kind hidden.
95 * The fork merged stored records over the raw preset's fields because raw was
96 * a field set; here raw is `on: false`, and the first switch-on (the band key,
97 * /feed on, a setting change from raw) needs a view.
98 */
99export const DEFAULT_VIEW: View = { ...PRESETS.clean.fields, hide: [] }
100
101/**
102 * One row per setting. The /feed grammar, its replies, the usage line, the
103 * settings pane's rows and pictures, and `modeOf` all read this table: adding a
104 * setting is one entry (design red flag: hand-synced list).
105 */
106export type FieldSpec<F extends keyof Fields = keyof Fields> = { [K in F]: {
107 readonly field: K
108 readonly word: string // the /feed word: `group`, `style`, ...
109 readonly title: string // the pane's pressable title
110 readonly values: readonly Fields[K][] // in cycle order; booleans as [true, false]
111 readonly spoken: (v: Fields[K]) => string // `on`, `off`, `subtle`, ...
112 readonly picture: (v: Fields[K]) => readonly string[] // the pane's dim example lines
113} }[F]
114const spoken = (value: Fields[keyof Fields]): string => typeof value === 'boolean' ? (value ? 'on' : 'off') : value
115export const FIELDS: readonly FieldSpec[] = [
116 { field: 'group', word: 'group', title: 'Group a turn into one line', values: [true, false], spoken,
117 picture: value => value ? ['you fix the login bug', '6 steps this turn ›', 'claude Fixed: the token now refreshes.'] : ['you fix the login bug', '○ Ran 3 shell commands · read 2 files ›', '○ Edited auth.ts', 'claude Fixed: the token now refreshes.'] },
118 { field: 'clear', word: 'clear', title: 'Clear after the turn', values: [true, false], spoken,
119 picture: value => [value ? 'a finished turn keeps only its one line (grouping on) or nothing (off)' : 'a finished turn keeps its rows'] },
120 { field: 'keep', word: 'keep', title: 'Keep every card', values: [true, false], spoken,
121 picture: value => [value ? 'finished turns stay drawn as they ran, whatever "clear" says' : '"clear after the turn" decides'] },
122 { field: 'style', word: 'style', title: 'Card style', values: ['expanded', 'subtle', 'line'], spoken,
123 picture: value => value === 'expanded' ? ['○ Ran 2 shell commands', ' ○ Ran the tests +12 lines', ' ○ Listed the files +4 lines'] : [value === 'subtle' ? '○ Ran 2 shell commands +16 lines ›' : '○ Ran 2 shell commands'] },
124 { field: 'commands', word: 'commands', title: 'Commands in the chat', values: ['grouped', 'each'], spoken,
125 picture: value => value === 'grouped' ? ['○ Ran 3 shell commands · read 1 file'] : ['○ Ran the tests', '○ Listed the files', '○ Read app.py'] },
126 { field: 'after', word: 'after', title: 'After a command finishes', values: ['clear', 'keep'], spoken,
127 picture: value => [value === 'clear' ? 'a finished turn keeps the run line, not each command' : 'each finished command keeps its own line'] },
128]
129
130export function modeOf(session: Session): Mode {
131 // raw when this session's feed is off; a preset only when all six fields match
132 // it and nothing is hidden (§1.2, `/feed hide peer` then `/feed clean` reads custom).
133 if (!session.on) return 'raw'
134 if (session.view.hide.length) return 'custom'
135 for (const preset of ['clean', 'normal'] as const) {
136 if (FIELDS.every(({ field }) => session.view[field] === PRESETS[preset].fields[field])) return preset
137 }
138 return 'custom'
139}
140
141/** VIEW_FILE's text is refused when torn, malformed, incomplete or invalid. */
142export function parseStoredView(text: string | undefined): View {
143 if (text === undefined) return DEFAULT_VIEW
144 if (!text.endsWith('\n')) throw new Error('feed-view is torn: missing closing newline')
145 let value: unknown
146 try { value = JSON.parse(text) } catch { throw new Error('feed-view contains invalid JSON') }
147 if (!isRecord(value)) throw new Error('feed-view must be an object')
148 const fields: Record<string, unknown> = value
149 for (const spec of FIELDS) {
150 if (!(spec.field in fields)) throw new Error(`feed-view is missing field ${spec.field}`)
151 if (!spec.values.some(allowed => allowed === fields[spec.field])) throw new Error(`feed-view field ${spec.field} has invalid value ${JSON.stringify(fields[spec.field])}`)
152 }
153 if (!('hide' in fields)) throw new Error('feed-view is missing field hide')
154 if (!Array.isArray(fields.hide) || !fields.hide.every(isKind)) throw new Error(`feed-view field hide has invalid value ${JSON.stringify(fields.hide)}`)
155 if (typeof fields.group !== 'boolean' || typeof fields.clear !== 'boolean' || typeof fields.keep !== 'boolean'
156 || (fields.style !== 'expanded' && fields.style !== 'subtle' && fields.style !== 'line')
157 || (fields.commands !== 'grouped' && fields.commands !== 'each') || (fields.after !== 'clear' && fields.after !== 'keep')) {
158 throw new Error('feed-view field registry disagrees with the view contract')
159 }
160 return { group: fields.group, clear: fields.clear, keep: fields.keep, style: fields.style, commands: fields.commands, after: fields.after, hide: fields.hide }
161}
162
163function isKind(value: unknown): value is Kind {
164 return KINDS.some(kind => kind === value)
165}
166
167function isRecord(value: unknown): value is Record<string, unknown> {
168 return typeof value === 'object' && value !== null && !Array.isArray(value)
169}
170
171/** VIEW_FILE's text for a view: JSON and a closing newline, so a torn write is told apart. */
172export function viewText(view: View): string {
173 return `${JSON.stringify(view)}\n`
174}
175
176/**
177 * §2.2: lowercased, split on whitespace; `close`, `on`, `off`, `status` and the
178 * presets only as the sole word; a setting takes its first two words; booleans accept on/off, yes/no,
179 * true/false; anything else is `usage`.
180 */
181export function parseFeedArgs(args: string): FeedCommand {
182 const words = args.trim().toLowerCase().split(/\s+/)
183 const [word, value] = words
184 if (!word) return { kind: 'open' }
185 if (words.length === 1) {
186 if (word === 'close' || word === 'status') return { kind: word }
187 if (word === 'on' || word === 'off') return { kind: 'switch', on: word === 'on' }
188 if (word === 'clean' || word === 'normal' || word === 'raw') return { kind: 'preset', to: word }
189 }
190 if ((word === 'hide' || word === 'show') && isKind(value)) return { kind: word, what: value }
191 const spec = FIELDS.find(spec => spec.word === word)
192 if (!spec) return { kind: 'usage' }
193 const candidate = { kind: 'set' as const, field: spec.field, value: fieldValue<Fields[keyof Fields]>(spec.values, value) }
194 return isSetting(candidate) ? candidate : { kind: 'usage' }
195}
196
197function isSetting(candidate: { readonly kind: 'set'; readonly field: keyof Fields; readonly value: unknown }): candidate is Setting {
198 return FIELDS.some(spec => spec.field === candidate.field && spec.values.some(value => value === candidate.value))
199}
200
201function fieldValue<T extends Fields[keyof Fields]>(values: readonly T[], word: string | undefined): T | undefined {
202 const normalized = word === 'yes' || word === 'true' ? 'on' : word === 'no' || word === 'false' ? 'off' : word
203 return values.find(value => spoken(value) === normalized)
204}
205
206/**
207 * What a typed /feed command or a settings-pane press asks for. Every change is
208 * this session's (this directory's: FEED_FILE and VIEW_FILE). The shell asks
209 * cs -vet-session first and, refused, writes nothing and replies why (ruling
210 * §20.22); passed, it writes VIEW_FILE when `view` changed, then FEED_FILE,
211 * then reads FEED_FILE back for the mirror and invalidates. cs's band key does not come through here: it
212 * writes FEED_FILE itself, and cs-feed's ui.press hook reads it after next(e).
213 * Nothing reloads and no command runs.
214 *
215 * /feed clean | normal on, the preset's fields, `hide` kept
216 * /feed raw off (the view is kept for the next on)
217 * /feed on | off `on` set, the view kept (the pane's on/off row builds the same)
218 * /feed status nothing written; re-reads FEED_FILE (a vet only for a flip to on no writer of ours vetted); the reply's first word is `on` or `off` as vetted, then the mode
219 * set, hide, show, chip on, the change applied to this session's view
220 * (ruling §20.4/§20.5, now per session: a change from raw turns on)
221 */
222export type Setting = { [F in keyof Fields]: { readonly kind: 'set'; readonly field: F; readonly value: Fields[F] } }[keyof Fields]
223
224export type FeedCommand =
225 | { readonly kind: 'open' } // bare /feed
226 | { readonly kind: 'close' }
227 | { readonly kind: 'switch'; readonly on: boolean } // /feed on | off
228 | { readonly kind: 'status' }
229 | { readonly kind: 'preset'; readonly to: Preset | 'raw' }
230 | Setting
231 | { readonly kind: 'hide' | 'show'; readonly what: Kind }
232 | { readonly kind: 'usage' }
233
234export type Transition = {
235 /** The record to write: VIEW_FILE when its view differs from the current one, then FEED_FILE. Absent: nothing to write (status, open, close, usage). */
236 readonly session?: Session
237 readonly pane?: 'open' | 'close'
238 readonly reply: string // §2.2 reply texts, from FIELDS and PRESETS
239}
240export function transition(cmd: FeedCommand, current: Session): Transition {
241 switch (cmd.kind) {
242 case 'open': return { pane: 'open', reply: `Chat view: ${modeOf(current)}. Settings are open above the prompt; /feed close shuts them.` }
243 case 'close': return { pane: 'close', reply: 'Chat view settings closed.' }
244 case 'status': return { reply: `${current.on ? 'on' : 'off'} · ${modeOf(current)}` }
245 case 'usage': return { reply: usageLine() }
246 case 'switch': return { session: { ...current, on: cmd.on }, reply: `Chat view: ${cmd.on ? 'on' : 'off'}.` }
247 case 'preset': return { session: cmd.to === 'raw' ? { ...current, on: false } : { on: true, view: { ...PRESETS[cmd.to].fields, hide: current.view.hide } }, reply: `Chat view: ${cmd.to}. ${cmd.to === 'raw' ? RAW_HELP : PRESETS[cmd.to].help}.` }
248 case 'hide': case 'show': {
249 const hide = cmd.kind === 'hide' ? [...new Set([...current.view.hide, cmd.what])] : current.view.hide.filter(kind => kind !== cmd.what)
250 return { session: { on: true, view: { ...current.view, hide } }, reply: `Chat view: ${cmd.what} ${cmd.kind === 'hide' ? 'hidden' : 'shown'}.` }
251 }
252 case 'set': {
253 const view: View = { ...current.view, [cmd.field]: cmd.value }
254 return { session: { on: true, view }, reply: `Chat view: ${cmd.field} ${spoken(cmd.value)}.` }
255 }
256 }
257}
258
259/** "/feed opens the settings · /feed clean | normal | raw · ..." built from FIELDS. */
260export function usageLine(): string {
261 return ['/feed opens the settings', '/feed on | off | status', '/feed clean | normal | raw', ...FIELDS.map(spec => `/feed ${spec.word} ${spec.values.map(spoken).join('|')}`), '/feed hide|show <kind>', '/feed close'].join(' · ')
262}
263hooks/verdict.ts 572 lines1// ABOUTME: cs-feed's decisions: for each render site, what the row draws, from its own props, the snapshot, the view and this load's observations.
2// ABOUTME: Pure functions with no $ and no clock; draw.tsx turns a verdict into a tree. Every decision also says whether it depends on the open turn (then the row reads LIVE).
3import type { AgentInfo, PromptOrigin, SessionMessage, UserMessageFrom, UserMessageTask } from 'claude-code'
4
5import type { Observations, Opened, Phase } from './session'
6import type { Kind, View } from './settings'
7import { modeOf } from './settings'
8import type { CallId, FiledCall, Run, Transcript, Turn, TurnId } from './transcript'
9import { callId } from './transcript'
10import type { ErrorLine, Frame } from './words'
11import { repliesOwnSteps, isRefusal, errorLine, callWords, clean, framesFromText, hookTextLine, senderOf, peerHeadline, fit, elapsed, marks } from './words'
12
13/**
14 * A call as a row's props show it, from ToolUse or one ToolGroupCall, parsed at
15 * the site boundary. Liveness and the call's own outcome come from here, the
16 * engine's word on every draw (K3), never from the snapshot: a call cannot be
17 * stuck "running" past the moment its row's props say it ended.
18 */
19export type CallView = {
20 readonly id?: CallId // ToolGroupCall.tool_use_id is optional (older desktop): no id, the call draws its own line
21 readonly tool: string
22 readonly input: Readonly<Record<string, unknown>>
23 readonly isRunning: boolean
24 readonly isErrored: boolean
25 readonly isInterrupted: boolean
26 readonly output?: unknown
27}
28
29/** What every decision reads. Built once per draw by the shell. */
30export type Context = {
31 readonly t: Transcript
32 readonly view: View
33 readonly obs: Observations
34 readonly opened: Opened
35 /** session.openTurn: the Turn, not an id, since an open turn with no call yet has none. */
36 readonly open?: Turn
37 readonly phase: Phase
38 readonly agents: readonly import('claude-code').AgentInfo[]
39 readonly now: number
40 readonly surface: 'terminal' | 'desktop' | 'vscode' | 'mobile' | string
41 /** Clicks reach a mod: any surface but the terminal, or a fullscreen terminal (§4.6). */
42 readonly clickable: boolean
43 readonly room: number // columns for text
44 /** The desktop band's rate-limit percentages (§9.2), read only for a desktop AbovePrompt draw. */
45 readonly usage?: { readonly fiveHour?: number; readonly week?: number }
46}
47
48/**
49 * A decision, whether it was taken from the open turn's state (the shell then
50 * reads LIVE), and which clock tokens what it draws moves with: TICK for an
51 * elapsed time or a polled count, PULSE for an animation frame.
52 */
53export type Decision<V> = { readonly verdict: V; readonly live: boolean; readonly clock?: Clock }
54
55/** The clock tokens a drawing reads besides LIVE; absent: none. */
56export type Clock = 'tick' | 'pulse' | 'both'
57
58/** Both drawings' clocks, for a row that stacks several decisions. */
59export function clocks(all: readonly (Clock | undefined)[]): Clock | undefined {
60 const tick = all.some(clock => clock === 'tick' || clock === 'both')
61 const pulse = all.some(clock => clock === 'pulse' || clock === 'both')
62 return tick && pulse ? 'both' : tick ? 'tick' : pulse ? 'pulse' : undefined
63}
64
65export type CallVerdict =
66 | { readonly draw: 'nothing' }
67 | { readonly draw: 'turn'; readonly turn: TurnId; readonly done: number; readonly opened: boolean; readonly body: readonly CallVerdict[] }
68 | { readonly draw: 'run'; readonly run: Run; readonly naming?: CallId; readonly opened: boolean }
69 | { readonly draw: 'call'; readonly call: CallView; readonly filed?: FiledCall; readonly opened: boolean }
70 | { readonly draw: 'helper'; readonly call: CallView; readonly state: HelperState; readonly elapsedMs?: number }
71 | { readonly draw: 'question'; readonly call: CallView; readonly opened: boolean }
72
73export type HelperState = 'dispatched' | 'running' | 'finished' | 'failed' | 'stopped'
74
75/**
76 * A helper's state with no event observed (§7.1): the row's own props, the
77 * filed call's agentId (ToolUseSummary) and the polled agent list.
78 * call in flight -> dispatched
79 * call answered, its agent still busy -> running (a background launch)
80 * else errored -> failed, interrupted -> stopped, else finished
81 * Elapsed: running -> now - obs.helperStartedAt (absent when this load never
82 * saw the spawn: no time, the §7.1 pin); finished -> outcome.durationMs (absent
83 * on a background launch: no time).
84 */
85export function helperState(call: CallView, filed: FiledCall | undefined, agents: readonly import('claude-code').AgentInfo[]): HelperState {
86 if (call.isRunning) return 'dispatched'
87 const agent = filed?.agentId ? agents.find(agent => agent.id === filed.agentId) : undefined
88 if (agent && helperBusy(agent.status)) return 'running'
89 if (call.isErrored || agent?.status === 'failed') return 'failed'
90 if (call.isInterrupted || agent?.status === 'killed') return 'stopped'
91 return 'finished'
92}
93
94export function helperBusy(status: string): boolean {
95 return status === 'running' || status === 'pending' || status === 'waiting'
96}
97
98/**
99 * §4.5, for one call. Order of tests:
100 * - rule 1 first for the turn's first call (the turn line), then:
101 * - kind hidden (tool, search, agent, question) -> nothing
102 * Ruling §20.3: a hidden kind hides the call's own line and run lines made
103 * only of hidden calls, never the turn line: the turn line is the turn's,
104 * not its first call's. Hidden calls still count in "{n} steps this turn".
105 * - helper -> 'helper' (never folded); question -> 'question'
106 * - unfiled (id not in t.calls):
107 * open turn and isRunning -> nothing (a call about to be filed; the next
108 * refresh files it, and LIVE redraws this row); else -> 'call' (history
109 * the snapshot does not hold, drawn from its props)
110 * - then the fork's rules 1-6 with firstOfTurn, firstOfRun, live = isRunning,
111 * spent = (filed.turn !== cx.open?.id), turnOpened = opened.has(`turn-${filed.turn}`)
112 * - rule 1's turn line yields to a reply only when that reply's row DREW
113 * it (`stepsOwner`): the reply anchored to the turn's last filed call
114 * recorded `stepsDrawn` true when it settled its claim. Then the first
115 * call draws nothing (§4.4). While that reply has not settled its claim
116 * (`stepsDrawn` has no entry: not evaluated yet, or evaluated while the
117 * turn was live and left pending) the line stays here and the decision is
118 * live, so the LIVE bump the reply's claim schedules redraws this row.
119 * A fenced reply never claims, so its line stays here in both plugin orders.
120 * live = the call's turn is the open turn, or the call is unfiled while a turn is open.
121 */
122export function decideCall(call: CallView, cx: Context): Decision<CallVerdict> {
123 return callDecision(call, cx, true)
124}
125
126function callDecision(call: CallView, cx: Context, turnLine: boolean): Decision<CallVerdict> {
127 const filed = call.id ? cx.t.calls.get(call.id) : undefined
128 const turn = filed ? cx.t.byId.get(filed.turn) : undefined
129 // An unfiled call reads LIVE only while a turn is open (it may be about to be filed, rationale decision 8);
130 // while idle it is history older than the snapshot's window, or id-less, and no refresh will file it.
131 const live = filed ? filed.turn === cx.open?.id : cx.open !== undefined
132 const opened = filed ? cx.opened.has(`turn-${filed.turn}`) : false
133 const folded = cx.view.group && !(!live && cx.view.keep)
134 if (filed && turn && folded && turnLine && turn.first === filed.id) {
135 const owner = stepsOwner(filed.turn, cx)
136 if (typeof owner === 'object') return { verdict: { draw: 'nothing' }, live }
137 const done = turn.steps.filter(id => cx.t.calls.get(id)?.outcome.state === 'done').length
138 return { verdict: !done && !opened ? { draw: 'nothing' } : { draw: 'turn', turn: filed.turn, done, opened, body: opened ? [callDecision(call, cx, false).verdict] : [] }, live: live || owner === 'pending' }
139 }
140 const refused = call.isErrored && typeof call.output === 'string' && isRefusal(call.output)
141 const cls = filed?.cls ?? (refused ? 'machinery' : call.tool === 'Agent' || call.tool === 'Task' ? 'helper' : call.tool === 'AskUserQuestion' ? 'question' : 'machinery')
142 if (hidden(cx.view, callKind(call.tool, cls))) return { verdict: { draw: 'nothing' }, live }
143 if (cls === 'helper') {
144 const state = helperState(call, filed, cx.agents)
145 const started = call.id ? cx.obs.helperStartedAt.get(call.id) : undefined
146 const elapsedMs = state === 'running' || state === 'dispatched' ? started === undefined ? undefined : Math.max(0, cx.now - started) : filed?.outcome.state === 'done' ? filed.outcome.durationMs : undefined
147 // A running helper's end comes from the tick's agent list and its elapsed from the clock; a dispatched one's end is its own row's props.
148 return { verdict: { draw: 'helper', call, state, elapsedMs }, live, clock: state === 'running' || (state === 'dispatched' && elapsedMs !== undefined) ? 'tick' : undefined }
149 }
150 if (cls === 'question') return { verdict: { draw: 'question', call, opened: call.id ? cx.opened.has(call.id) : false }, live }
151 if (!filed && cx.open && call.isRunning) return { verdict: { draw: 'nothing' }, live }
152 if (filed?.cls === 'machinery' && folded && !opened) return { verdict: { draw: 'nothing' }, live }
153 if (filed && !live && !cx.view.keep && cx.view.clear && !opened) return { verdict: { draw: 'nothing' }, live }
154 if (filed && cx.view.group && call.isRunning && !opened) return { verdict: { draw: 'nothing' }, live }
155 if (filed?.run && (cx.view.commands === 'grouped' || (!live && cx.view.after === 'clear' && !cx.view.keep && !opened))) return { verdict: runDecision(call, filed, cx), live }
156 return { verdict: { draw: 'call', call, filed, opened: cx.view.style === 'expanded' || (call.id ? cx.opened.has(call.id) : false) }, live }
157}
158
159function callKind(tool: string, cls = 'machinery'): Kind {
160 return cls === 'helper' ? 'agent' : cls === 'question' ? 'question' : tool === 'WebSearch' || tool === 'WebFetch' ? 'search' : 'tool'
161}
162
163function runDecision(call: CallView, filed: FiledCall, cx: Context): CallVerdict {
164 const stored = filed.run ? cx.t.runs.get(filed.run) : undefined
165 if (!stored) return { draw: 'call', call, filed, opened: cx.opened.has(filed.id) }
166 const visible = stored.calls.filter(id => { const member = cx.t.calls.get(id); return member && !hidden(cx.view, callKind(member.tool, member.cls)) })
167 if (visible[0] !== filed.id) return { draw: 'nothing' }
168 const naming = !cx.view.group || !cx.open ? visible.findLast(id => id === filed.id ? call.isRunning : cx.t.calls.get(id)?.outcome.state === 'running') : undefined
169 return { draw: 'run', run: { ...stored, calls: visible }, naming, opened: cx.view.style === 'expanded' || cx.opened.has(`open-${stored.id}`) }
170}
171
172/**
173 * Who draws a turn's steps line, as the tool row sees it:
174 *
175 * last = the turn's last filed call; uuid = obs.latestByCall.get(last)
176 * no uuid, `said` hidden, or !repliesOwnSteps(text) -> 'tool'
177 * obs.stepsDrawn.get(uuid) === true -> { reply: uuid } (the tool row draws nothing)
178 * === false -> 'tool'
179 * no entry yet (not evaluated, or pending) -> 'pending' (the tool row draws it, live)
180 *
181 * The reply row's side is decideReply's claim (StepsClaim). An evaluation of
182 * a reply with no record yet:
183 * cannot own the line (no anchor, `said` hidden, a fenced anchored text,
184 * or not latestByCall.get(last)) -> 'declined'
185 * could own it, its turn still live -> 'pending': no line, reads LIVE
186 * could own it, its turn spent (cx.phase 'idle', or not the open turn):
187 * handed the anchored text -> 'drawn'
188 * handed other text (a fragment) -> 'declined'
189 * site() records a settled claim once (session.noteStepsDrawn) and every
190 * later evaluation of that row reuses it; a pending claim records nothing.
191 * So exactly one row draws the line once the claim settles, and a claimed
192 * line is never stale: a spent turn gets no more calls.
193 *
194 * Why a recorded claim, not a shared derivation: the reply is evaluated only
195 * right after it is stored (spike5 M18), and, while pending, at each LIVE
196 * bump. Without a Stop hook (spike5's rig) turn.complete comes first, the
197 * reply's first evaluation claims, and the tool row, live while pending,
198 * yields at the claim's LIVE bump. With one (cs sessions; spike4's rig, 36 of
199 * 36 turns, open-measurements M27) the Stop hook runs between the reply's
200 * store and turn.complete: the reply's first evaluations find the turn live
201 * and stay pending; turn.complete's phase change bumps LIVE, which
202 * re-evaluates the reply (§20.23 (a));
203 * it finds the turn spent and claims, and its claim's bump makes the tool row
204 * yield. A derivation both rows evaluate at different moments drew it nowhere
205 * in that order. Keyed by the turn's LAST call: a reply followed by more
206 * calls is never the owner (a pending one settles 'declined' at the refresh
207 * that files the next call).
208 */
209export function stepsOwner(turn: TurnId, cx: Context): 'tool' | 'pending' | { readonly reply: string } {
210 const last = [...cx.t.calls.values()].filter(call => call.turn === turn).at(-1)
211 const uuid = last ? cx.obs.latestByCall.get(last.id) : undefined
212 const anchor = uuid ? cx.obs.anchors.get(uuid) : undefined
213 if (!uuid || !anchor || hidden(cx.view, 'said') || !repliesOwnSteps(anchor.text)) return 'tool'
214 const drawn = cx.obs.stepsDrawn.get(uuid)
215 return drawn === true ? { reply: uuid } : drawn === false ? 'tool' : 'pending'
216}
217
218export type GroupVerdict =
219 | { readonly draw: 'engine' } // ctrl+o (isExpanded), or a group a hook other than ours set apart
220 | { readonly draw: 'batch'; readonly key: string; readonly opened: boolean; readonly helpers: readonly BatchHelper[]; readonly elapsedMs?: number }
221 | { readonly draw: 'stack'; readonly calls: readonly CallVerdict[] } // each call's own verdict, stacked; all 'nothing' draws nothing
222
223export type BatchHelper = { readonly call: CallView; readonly kind: string; readonly state: HelperState }
224
225/**
226 * A ToolGroup row is drawn here, never unfolded: unfolding would hand the
227 * engine's ToolUse rows `isExpanded: true` (IDX ToolUse.isExpanded), which is
228 * how ctrl+o says itself, and the fork had to infer ctrl+o globally (C2). So a
229 * group passes through on its own `isExpanded` and otherwise stacks its calls'
230 * decisions; ctrl+o needs no module flag.
231 */
232export function decideGroup(props: { calls: readonly CallView[]; isActive: boolean; isExpanded: boolean; isCallsOnly?: boolean }, cx: Context): Decision<GroupVerdict> {
233 if (props.isExpanded || props.isCallsOnly !== true) return { verdict: { draw: 'engine' }, live: false }
234 const decisions = props.calls.map(call => decideCall(call, cx))
235 const live = decisions.some(decision => decision.live)
236 if (props.calls.length >= 2 && !hidden(cx.view, 'agent') && props.calls.every(call => call.id && cx.t.calls.get(call.id)?.cls === 'helper')) {
237 const helpers = props.calls.map(call => ({ call, kind: typeof call.input.subagent_type === 'string' ? call.input.subagent_type : 'general-purpose', state: helperState(call, call.id ? cx.t.calls.get(call.id) : undefined, cx.agents) }))
238 const starts = props.calls.flatMap(call => { const at = call.id ? cx.obs.helperStartedAt.get(call.id) : undefined; return at === undefined ? [] : [at] })
239 const ends = props.calls.flatMap(call => {
240 const start = call.id ? cx.obs.helperStartedAt.get(call.id) : undefined
241 const outcome = call.id ? cx.t.calls.get(call.id)?.outcome : undefined
242 return start !== undefined && outcome?.state === 'done' && outcome.durationMs !== undefined ? [start + outcome.durationMs] : []
243 })
244 const running = helpers.some(helper => helper.state === 'running' || helper.state === 'dispatched')
245 const end = running ? cx.now : ends.length ? Math.max(...ends) : undefined
246 const key = `helpers-${props.calls[0]?.id}`
247 // §7.2 while any runs: the spinner turns (PULSE), the elapsed and the states move with the tick.
248 return { verdict: { draw: 'batch', key, opened: cx.opened.has(key), helpers, elapsedMs: starts.length && end !== undefined ? Math.max(0, end - Math.min(...starts)) : undefined }, live, clock: running ? 'both' : undefined }
249 }
250 return { verdict: { draw: 'stack', calls: decisions.map(decision => decision.verdict) }, live, clock: clocks(decisions.map(decision => decision.clock)) }
251}
252
253/** ToolResult: nothing when its call is filed (the result rides on the call's chip and opened output); else the engine's (§4.8). */
254export function decideToolResult(id: CallId, isExpanded: boolean | undefined, cx: Context): 'nothing' | 'engine' {
255 return !isExpanded && cx.t.calls.has(id) ? 'nothing' : 'engine'
256}
257
258export type UserRow = {
259 readonly uuid: string
260 readonly text: string
261 readonly origin: PromptOrigin
262 readonly isExpanded: boolean
263 readonly task?: UserMessageTask
264 readonly from?: UserMessageFrom
265}
266
267export type UserVerdict =
268 | { readonly draw: 'engine' }
269 | { readonly draw: 'nothing' }
270 | { readonly draw: 'you'; readonly text: string; readonly at?: number }
271 | { readonly draw: 'hookText'; readonly line: string }
272 | { readonly draw: 'frames'; readonly frames: readonly (Frame & { readonly from: string })[] }
273 | { readonly draw: 'peer'; readonly sender: string; readonly headline: string; readonly nested?: true } // nested: a member row of an opened burst, indented by 2
274 | { readonly draw: 'burst'; readonly members: readonly { uuid: string; sender: string; at?: number }[]; readonly opened: boolean; readonly own?: { readonly sender: string; readonly headline: string } } // own: the head's message, its indented line while opened
275 | { readonly draw: 'helperNote'; readonly durationMs?: number; readonly headline: string }
276 | { readonly draw: 'backgroundNote' }
277
278/**
279 * §8.1 routing, terminal and desktop, isExpanded -> engine. Ruling §20.8: a
280 * task notification is a helper's when ctx.agents knows task.id or the filed
281 * call task.toolUseId is a helper; a filed non-helper call is a background
282 * task; an id neither knows reads as a background task, not a helper.
283 */
284export function decideUser(row: UserRow, cx: Context): Decision<UserVerdict> {
285 const answer = (verdict: UserVerdict): Decision<UserVerdict> => ({ verdict, live: false })
286 if (row.isExpanded || !drawsOrigin(row.origin.kind)) return answer({ draw: 'engine' })
287 const origin = row.origin.kind
288 const typed = origin === 'composer' || origin === 'bridge' || origin === 'sdk'
289 const hook = hookTextLine(row.text)
290 if (!typed && origin !== 'task-notification') {
291 const frames = (cx.obs.frames.get(row.uuid) ?? framesFromText(row.text)).map(frame => ({ ...frame, from: frame.from || (row.from?.name ? senderOf(row.from) : 'helper') }))
292 if (frames.length) return answer(hidden(cx.view, 'peer') ? { draw: 'nothing' } : { draw: 'frames', frames })
293 }
294 if (typed || origin === 'scheduled-trigger' || (origin === 'unclassified' && !row.from?.name && !hook)) {
295 if (cx.surface !== 'terminal') return answer({ draw: 'engine' })
296 return answer(hidden(cx.view, 'you') ? { draw: 'nothing' } : { draw: 'you', text: row.text, at: cx.obs.promptAt.get(row.uuid) })
297 }
298 if (hook) return answer({ draw: 'hookText', line: hook })
299 if (origin === 'task-notification') {
300 const filed = row.task?.toolUseId ? cx.t.calls.get(callId(row.task.toolUseId)) : undefined
301 const helper = filed?.cls === 'helper' || cx.agents.some(agent => agent.id === row.task?.id)
302 if (helper) return answer(hidden(cx.view, 'helper') ? { draw: 'nothing' } : { draw: 'helperNote', durationMs: row.task?.durationMs, headline: peerHeadline(row.text, cx.room) })
303 return answer(cx.view.clear && !cx.view.keep ? { draw: 'nothing' } : { draw: 'backgroundNote' })
304 }
305 if (hidden(cx.view, 'peer')) return answer({ draw: 'nothing' })
306 const head = cx.obs.bursts.of.get(row.uuid)
307 const members = head ? cx.obs.bursts.members.get(head) : undefined
308 const live = head === row.uuid && cx.obs.bursts.open === head
309 const own = { sender: senderOf(row.from, row.text), headline: peerHeadline(row.text, cx.room - 2) }
310 if (head && members && members.length >= 2) {
311 // §8.2: the rest draw nothing until the burst is opened; then each message, the head's included, is its own indented line.
312 const opened = cx.opened.has(`burst-${head}`)
313 if (head === row.uuid) return { verdict: { draw: 'burst', members, opened, ...(opened ? { own } : {}) }, live }
314 if (!opened) return answer({ draw: 'nothing' })
315 return { verdict: { draw: 'peer', ...own, nested: true }, live }
316 }
317 return { verdict: { draw: 'peer', sender: own.sender, headline: peerHeadline(row.text, cx.room) }, live }
318}
319
320/**
321 * The origins §8.1 routes: typed rows, task notifications, scheduled
322 * triggers, peer and teammate messages, and unclassified rows. Every other
323 * kind is unmeasured, so its row keeps the engine's drawing. Exhaustive over
324 * the contract's kinds: a kind the engine adds fails to compile here instead
325 * of drawing as a peer line.
326 */
327function drawsOrigin(kind: PromptOrigin['kind']): boolean {
328 switch (kind) {
329 case 'composer': case 'bridge': case 'sdk': case 'task-notification': case 'scheduled-trigger':
330 case 'peer': case 'peer-send-message': case 'unclassified':
331 return true
332 case 'projects-relay': case 'channel': case 'coordinator': case 'observer': case 'observer-activity':
333 case 'auto-continuation': case 'slack-ping': case 'plugin':
334 return false
335 }
336}
337
338export type ReplyVerdict = {
339 /** The engine draws the text, with isFirstOfReply rewritten to false so it draws no bullet of its own. */
340 readonly body: 'engine' | 'nothing' | { readonly error: ErrorLine }
341 readonly name: boolean // the "claude" margin: first block of a reply, terminal only
342 readonly stepsLine?: CallVerdict // §4.4, the turn line moved under this reply
343}
344
345/** The reply row's side of the steps line (stepsOwner): settled ('drawn', 'declined') and recorded once, or 'pending' while its turn is live. */
346export type StepsClaim = 'drawn' | 'declined' | 'pending'
347
348/**
349 * A reply decision is live exactly while its steps-line claim is pending
350 * (§20.23 (a)): a pending reply reads
351 * LIVE so that turn.complete's bump re-evaluates it, and it draws no steps
352 * line yet (the tool row does). A settled claim never reads LIVE: the reply
353 * is not evaluated while it streams and twice once stored (spike5 M18), so
354 * nothing a LIVE read could redraw remains for it.
355 */
356export type ReplyDecision =
357 | { readonly verdict: ReplyVerdict; readonly claim: 'drawn' | 'declined'; readonly live: false }
358 | { readonly verdict: ReplyVerdict & { readonly stepsLine?: never }; readonly claim: 'pending'; readonly live: true }
359
360/**
361 * AssistantMessage. Error detection runs before the `said` check (ruling §20.2:
362 * hiding what Claude said never hides that the reply failed), and the safeguard
363 * rules require an error prefix or a whole one-line reply (ruling §20.6). On desktop the body stays the
364 * engine's own and the steps line is still attached under it (ruling §20.1).
365 *
366 * Live only while its steps-line claim is pending (ReplyDecision, ruling
367 * §20.23 (a)): the instance is not evaluated while the reply streams and is
368 * evaluated twice once stored (+6-9 ms and +13-26 ms after the append), then
369 * not again while idle unless it read LIVE (spike5 M18). Whether the turn is
370 * spent at those evaluations depends on whether a Stop hook runs
371 * (stepsOwner): without one it is, and the first evaluation settles the
372 * claim; with one (every cs session) it is not, the claim stays pending, and
373 * the LIVE bump at turn.complete re-evaluates this one instance, which then
374 * settles it. site() records a settled claim once (obs.stepsDrawn); later
375 * evaluations reuse it and are not live. `row.uuid` is
376 * AssistantMessage.requestId, the append's e.uuid (M17).
377 *
378 * Whole reply or fragment (finding 6): `row.text` is what the hook was handed.
379 * With cs-mermaid outer it is one prose part of a fenced reply. The steps line
380 * is drawn only when obs.stepsDrawn says this uuid claimed it (or, at the
381 * evaluation that settles the claim, the claim rule in stepsOwner holds) and
382 * row.text === anchor.text: at most once, and never on a fragment. A fragment
383 * read while the turn is live leaves the claim pending (the stored text may
384 * still replace a pre-next anchor text); one read once the turn is spent
385 * settles it 'declined'. A
386 * fragment (text differs from the anchored text) draws `body: 'engine'` with
387 * no margin, so the prose parts line up with the diagrams cs-mermaid draws
388 * between them. A reply this load did not observe has no anchor and cannot be
389 * told from a fragment: it draws the margin (residual, rationale finding 6).
390 *
391 * order diagram-only prose · diagram · prose
392 * feed outer margin around mermaid's margin around mermaid's whole
393 * drawing; line on tool drawing; line on tool row
394 * row (fenced) (fenced)
395 * mermaid outer feed not called: the feed called twice, each a fragment:
396 * engine/mermaid look, engine look, no line, no margin;
397 * line on tool row line on tool row
398 */
399export function decideReply(row: { uuid: string; text: string; isFirstOfReply: boolean }, cx: Context): ReplyDecision {
400 const anchor = cx.obs.anchors.get(row.uuid)
401 const filed = anchor ? cx.t.calls.get(anchor.call) : undefined
402 const turn = filed ? cx.t.byId.get(filed.turn) : undefined
403 const last = turn?.id ? [...cx.t.calls.values()].filter(call => call.turn === turn.id).at(-1) : undefined
404 const fragment = anchor !== undefined && anchor.text !== row.text
405 const spent = cx.phase === 'idle' || turn !== cx.open
406 const eligible = anchor && turn?.first && last?.id === anchor.call && cx.obs.latestByCall.get(last.id) === row.uuid && !hidden(cx.view, 'said') && repliesOwnSteps(anchor.text) && cx.view.group && !(spent && cx.view.keep)
407 const recorded = cx.obs.stepsDrawn.get(row.uuid)
408 const claim: StepsClaim = recorded !== undefined ? recorded ? 'drawn' : 'declined' : !eligible ? 'declined' : !spent ? 'pending' : fragment ? 'declined' : 'drawn'
409 const error = fragment ? undefined : errorLine(row.text)
410 const verdict: Omit<ReplyVerdict, 'stepsLine'> = { body: error ? { error } : hidden(cx.view, 'said') ? 'nothing' : 'engine', name: !error && !hidden(cx.view, 'said') && !fragment && row.isFirstOfReply && cx.surface === 'terminal' }
411 if (claim === 'pending') return { verdict, claim, live: true }
412 if (claim !== 'drawn' || !eligible || fragment || !turn?.id || !turn.first) return { verdict, claim, live: false }
413 const done = turn.steps.filter(id => cx.t.calls.get(id)?.outcome.state === 'done').length
414 const opened = cx.opened.has(`turn-${turn.id}`)
415 const first = cx.t.calls.get(turn.first)
416 const stepsLine: CallVerdict = { draw: 'turn', turn: turn.id, done, opened, body: opened && first ? [callDecision(callView(first), cx, false).verdict] : [] }
417 return { verdict: { ...verdict, stepsLine }, claim, live: false }
418}
419
420function callView(filed: FiledCall): CallView {
421 return { id: filed.id, tool: filed.tool, input: filed.input, isRunning: filed.outcome.state === 'running', isErrored: filed.outcome.state === 'done' && filed.outcome.errored, isInterrupted: filed.outcome.state === 'done' && filed.outcome.interrupted, output: filed.outcome.state === 'done' ? filed.outcome.output : undefined }
422}
423
424export type SpinnerVerdict =
425 | { readonly draw: 'engine' } // props untouched
426 | { readonly draw: 'rewrite'; readonly message: string; readonly suffix: '' } // our words; the engine draws the row, clock, tokens and tip
427// No 'nothing' arm: hiding the row is replacing it (ruling 15, rationale). A bare line after a turn passes through.
428
429/**
430 * §5. Only when the engine's message is null: words = live call's present
431 * words (group on, machinery) | 'Writing' (group on, and the row's own
432 * `mode === 'responding'`) | 'Working' (steps > 0) | 'Thinking'; + ' · step N'
433 * (N counts what the turn line counts,
434 * ruling §20.12); + ' · N helper(s) working'. With a message of the engine's own
435 * (compaction) the row is the engine's, suffix included (ruling §20.9).
436 *
437 * The reply row is stored only once it has streamed (spike5 M18), so
438 * the tail says 'text' only after the writing is over. `mode` is the engine's
439 * own word on what the turn does. That the Spinner instance is re-evaluated
440 * when `mode` changes is inferred (a prop change), not measured
441 * (open-measurements M22).
442 */
443export function decideSpinner(props: { message: string | null; mode: 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use' }, cx: Context): Decision<SpinnerVerdict> {
444 if (props.message !== null || !cx.open) return { verdict: { draw: 'engine' }, live: false }
445 const steps = cx.open.steps.length
446 const running = cx.open.steps.map(id => cx.t.calls.get(id)).findLast(call => call?.outcome.state === 'running')
447 const words = cx.view.group && props.mode === 'responding' ? 'Writing' : cx.view.group && running ? callWords(running, 'present') : steps ? 'Working' : 'Thinking'
448 const helpers = cx.agents.filter(agent => !agent.parentId && (agent.status === 'running' || agent.status === 'pending')).length
449 const message = words + (steps ? ` · step ${steps}` : '') + (helpers ? ` · ${helpers} ${helpers === 1 ? 'helper' : 'helpers'} working` : '')
450 // The helper count is the tick's agent list (§5.1), so the line reads TICK while it counts any.
451 return { verdict: { draw: 'rewrite', message, suffix: '' }, live: true, clock: helpers ? 'tick' : undefined }
452}
453
454export type BandVerdict =
455 | { readonly draw: 'engine' }
456 | { readonly draw: 'desktop'; readonly usage: { fiveHour?: number; week?: number }; readonly pills: readonly Pill[] }
457 | { readonly draw: 'helpers'; readonly head: string; readonly kinds: readonly { kind: string; cells: string; done: number; n: number }[]; readonly more: number; readonly spinning: boolean }
458 | { readonly draw: 'background'; readonly words: string; readonly isTest: boolean; readonly more: number; readonly elapsedMs?: number; readonly typing: boolean }
459export type Pill = { readonly kind: 'helper' | 'job' | 'test'; readonly words: string; readonly elapsedMs?: number }
460
461/**
462 * §9.3: ours stacked ABOVE whatever next(e) drew (cs's band included), one
463 * engine pass, survey -> engine. Rows: maxRows - 2 for the band beneath, minus
464 * the head; kinds beyond fold into "+N more kinds" (§7.3c). Background line only
465 * with no open turn and no helper line.
466 *
467 * What the band reads (rationale "Cost while on: the band hook"; idle with
468 * nothing to show it reads no token and the feed never redraws it):
469 * LIVE while a turn is open (the engine's isWorking, or the open turn) or a
470 * helper is known (busy, or kept in the batch): a spawn's refresh
471 * bumps LIVE when Seen.helpers changed, so the band learns of a helper
472 * from the agent.spawn hook, never by polling
473 * TICK while it shows an elapsed time or a count the 1 s tick moves
474 * PULSE while it animates (the helper spinner, the breathing dot)
475 */
476export function decideBand(props: { hasSurvey: boolean; isWorking: boolean; maxRows: number; bodyColumns: number }, usage: { fiveHour?: number; week?: number }, cx: Context): Decision<BandVerdict> {
477 const agents = cx.agents.filter(agent => !agent.parentId && !agent.teammateId)
478 const helpersKnown = agents.some(agent => helperBusy(agent.status)) || cx.obs.batch.kinds.size > 0
479 const live = props.isWorking || cx.open !== undefined || helpersKnown
480 const answer = (verdict: BandVerdict, clock?: Clock): Decision<BandVerdict> => ({ verdict, live, clock })
481 if (props.hasSurvey) return { verdict: { draw: 'engine' }, live: false }
482 const background = cx.t.background.filter(run => !cx.obs.bgEnded.has(run.id) && !cx.agents.some(agent => agent.id === run.id || agent.teammateId === run.id || agent.description === run.words))
483 const elapsedMs = (id: CallId) => { const at = cx.obs.bgSeenAt.get(id); return at === undefined ? undefined : Math.max(0, cx.now - at) }
484 if (cx.surface === 'desktop') {
485 const pills: Pill[] = [...agents.filter(agent => agent.status === 'running').map(agent => ({ kind: 'helper' as const, words: fit(agent.description, 28) })), ...background.map(run => ({ kind: run.isTest ? 'test' as const : 'job' as const, words: run.words, elapsedMs: elapsedMs(run.id) }))]
486 if (usage.fiveHour === undefined && usage.week === undefined && !pills.length) return answer({ draw: 'engine' })
487 const moving = pills.some(pill => pill.elapsedMs !== undefined)
488 const turning = pills.some(pill => pill.kind === 'helper')
489 return answer({ draw: 'desktop', usage, pills }, moving && turning ? 'both' : moving ? 'tick' : turning ? 'pulse' : undefined)
490 }
491 if (agents.some(agent => helperBusy(agent.status))) {
492 const members = [...cx.obs.batch.kinds].map(([id, kind]) => ({ kind, status: agents.find(agent => agent.id === id)?.status ?? 'completed' }))
493 const running = members.filter(member => helperBusy(member.status)).length
494 const failed = members.filter(member => member.status === 'failed' || member.status === 'killed').length
495 const done = members.length - running - failed
496 const kinds = new Map<string, { kind: string; cells: string; done: number; n: number }>()
497 for (const member of members) {
498 const kind = kinds.get(member.kind) ?? { kind: fit(member.kind === 'general-purpose' ? 'general' : member.kind, 18), cells: '', done: 0, n: 0 }
499 const working = helperBusy(member.status)
500 const failed = member.status === 'failed' || member.status === 'killed'
501 kind.cells += working ? marks.working : failed ? marks.failed : marks.live
502 kind.done += !working && !failed ? 1 : 0
503 kind.n++
504 kinds.set(member.kind, kind)
505 }
506 const room = Math.max(0, props.maxRows - 3)
507 const shown = kinds.size > room ? Math.max(0, room - 1) : room
508 const head = [`${members.length} ${members.length === 1 ? 'helper' : 'helpers'}`, ...(done ? [`${done} done`] : []), ...(running ? [`${running} running`] : []), ...(failed ? [`${failed} failed`] : []), ...(cx.obs.batch.startedAt === undefined ? [] : [elapsed(cx.now - cx.obs.batch.startedAt)])].join(' · ')
509 // Busy helpers: the counts move with the tick's agent list and the head's elapsed with the clock; the spinner turns.
510 return answer({ draw: 'helpers', head, kinds: [...kinds.values()].slice(0, shown), more: Math.max(0, kinds.size - shown), spinning: running > 0 }, running > 0 ? 'both' : 'tick')
511 }
512 const first = background[0]
513 if (!first || props.isWorking || cx.open) return answer({ draw: 'engine' })
514 const shownElapsed = elapsedMs(first.id)
515 return answer({ draw: 'background', words: first.words, isTest: background.some(run => run.isTest), more: background.length - 1, elapsedMs: shownElapsed, typing: props.bodyColumns >= 90 }, shownElapsed === undefined ? 'pulse' : 'both')
516}
517
518/** One helper in the Helpers pane (§7.4): its head's parts, its current step while it works, and its own last calls in words. */
519export type HelperBlock = {
520 readonly name: string
521 readonly status: 'working' | 'finished' | 'failed' | 'stopped'
522 readonly elapsedMs?: number
523 readonly now?: string
524 readonly calls: readonly string[]
525}
526
527/**
528 * §7.4: the last 6 top-level helpers of `agents` (as $.agent.list() orders
529 * them), newest first. A running helper's elapsed comes from the spawn this
530 * load saw (helperStartedAt, by its Agent call, found through the call's
531 * agentId); a finished one's from the call's durationMs; neither known: no
532 * time. Its own calls are the last 12 tool uses of its transcript (`own`, by
533 * agent id), present while a call has no result, past once it has one; "now"
534 * is the newest call's present words while the helper works. An unreadable
535 * transcript (`own` has undefined) leaves the head alone (§7.4 (c)).
536 */
537export function decideHelpersPane(agents: readonly AgentInfo[], t: Transcript, own: ReadonlyMap<string, readonly SessionMessage[] | undefined>, obs: Observations, now: number): readonly HelperBlock[] {
538 const crew = agents.filter(agent => !agent.parentId && !agent.teammateId).slice(-6).reverse()
539 return crew.map(agent => {
540 const working = helperBusy(agent.status)
541 const filed = [...t.calls.values()].find(call => call.agentId === agent.id)
542 const started = filed ? obs.helperStartedAt.get(filed.id) : undefined
543 const elapsedMs = working ? started === undefined ? undefined : Math.max(0, now - started) : filed?.outcome.state === 'done' ? filed.outcome.durationMs : undefined
544 const uses = (own.get(agent.id) ?? []).flatMap(message => message.toolUses).slice(-12)
545 const done = (use: (typeof uses)[number]) => use.result !== undefined || use.text !== undefined
546 const last = uses.at(-1)
547 return {
548 name: clean(agent.description) || agent.name || agent.type || 'helper',
549 status: working ? 'working' : agent.status === 'failed' ? 'failed' : agent.status === 'killed' ? 'stopped' : 'finished',
550 elapsedMs,
551 now: working && last ? callWords(last, 'present') : undefined,
552 calls: uses.map(use => callWords(use, done(use) ? 'past' : 'present')),
553 }
554 })
555}
556
557/** CommandOutput §3.3: dim "·" and the text, red on error, a leading "cs-feed:" removed; isExpanded -> engine. */
558export function decideCommandOutput(props: { command: string; text: string; isErrored: boolean }, cx: Context): 'engine' | 'nothing' | { text: string; isErrored: boolean } {
559 const text = props.text.trim().replace(/^cs-feed:\s*/, '').trim()
560 return text ? { text, isErrored: props.isErrored } : 'nothing'
561}
562
563/** SessionMode §3.5: append "feed {mode} · /feed to switch" while this session's feed is on. Off (raw), site() passes the row through before any painter runs, so this never says "feed raw". */
564export function sessionModes(modes: readonly string[], view: View): readonly string[] {
565 return [...modes, `feed ${modeOf({ on: true, view })} · /feed to switch`]
566}
567
568/** Shared with the pane: is a kind hidden? */
569export function hidden(view: View, kind: Kind): boolean {
570 return view.hide.includes(kind)
571}
572hooks/transcript.ts 427 lines1// ABOUTME: cs-feed's one cross-row structure: turns, runs and filed calls, a pure function of one $.session.messages() result.
2// ABOUTME: Built whole on every refresh and swapped in; never merged, never written to $.state. Same transcript, same answer, whatever was switched when.
3import type { ApiMessage, SessionMessage, ToolUseSummary } from 'claude-code'
4import { isRefusal, callWords, looksLikeTest } from './words'
5
6export type CallId = string & { readonly __brand: 'CallId' }
7
8export function callId(value: string): CallId {
9 if (!value) throw new Error('A tool call id must not be empty')
10 // Engine tool-use ids are opaque strings; this constructor enforces their only local invariant.
11 return value as CallId
12}
13
14/**
15 * A turn's identity is its first call's tool_use_id (any class: a turn that
16 * opens with a helper still has one). It is stable across reads while
17 * messages() slides its 4096-entry window, where a position would shift and
18 * file every call under the wrong turn until the next build. A turn with no
19 * call has no id and needs none: nothing of it folds, nothing has a chevron.
20 *
21 * Residual: a turn whose real first call has slid out of the window takes the
22 * id of its first call still in the window, once. Its turn chevron closes.
23 */
24export type TurnId = CallId
25
26/**
27 * Machinery joins runs and counts as a step of the turn. A helper (Agent, Task)
28 * or a question (AskUserQuestion) stands alone, unless a hook refused it, when
29 * it is filed as machinery (§4.7c).
30 */
31export type CallClass = 'machinery' | 'helper' | 'question'
32
33export type QuestionPair = { readonly question: string; readonly answer: string }
34
35export type Outcome =
36 | { readonly state: 'running' } // no result in the transcript yet
37 | {
38 readonly state: 'done'
39 readonly errored: boolean
40 readonly refused: boolean // errored and the text reads as a hook refusal (words.isRefusal)
41 readonly interrupted: boolean
42 readonly lines: number // the "+N lines" chip
43 readonly output?: string // stdout+stderr or the error text, cut to 2000; kept for the newest OUTPUT_KEPT calls only
44 readonly answers?: readonly QuestionPair[] // questions only
45 /** ToolUseSummary.durationMs: a foreground helper that ran to completion. Absent on a background launch. */
46 readonly durationMs?: number
47 }
48
49export type FiledCall = {
50 readonly id: CallId
51 readonly tool: string
52 readonly input: Readonly<Record<string, unknown>>
53 readonly cls: CallClass
54 readonly outcome: Outcome
55 /** Always set: a filed call's turn holds at least this call, so it has an id. */
56 readonly turn: TurnId
57 /** The run this call is in; absent for a standalone call. A run's id is its first call's. */
58 readonly run?: CallId
59 /** Machinery steps filed through this call; helpers and questions do not increment it. */
60 readonly step: number
61 /**
62 * ToolUseSummary.agentId on an Agent call: known while the session tracks the
63 * run and once the call is answered. Links the row to $.agent.list() and to
64 * $.session.messages({ agentId }) without an agent.spawn observation.
65 */
66 readonly agentId?: string
67}
68
69export type Run = { readonly id: CallId; readonly turn: TurnId; readonly calls: readonly CallId[] }
70
71export type Turn = {
72 /** The first call's id; absent while the turn has no call. */
73 readonly id?: TurnId
74 /** The turn's first machinery call: the row the turn line draws on. */
75 readonly first?: CallId
76 /** Machinery calls plus refused standalone ones, in order: what "{n} steps this turn" counts (done ones). */
77 readonly steps: readonly CallId[]
78 readonly runs: readonly CallId[]
79}
80
81/** A Bash call started with run_in_background that no later entry says has ended (§9.1). */
82export type BackgroundRun = { readonly id: CallId; readonly words: string; readonly isTest: boolean }
83
84export type Transcript = {
85 readonly calls: ReadonlyMap<CallId, FiledCall>
86 /** In transcript order. Only the last one is ever looked up by place (the open turn); every other lookup goes by id. */
87 readonly turns: readonly Turn[]
88 readonly byId: ReadonlyMap<TurnId, Turn>
89 readonly runs: ReadonlyMap<CallId, Run>
90 readonly background: readonly BackgroundRun[]
91}
92
93export const EMPTY: Transcript = { calls: new Map(), turns: [], byId: new Map(), runs: new Map(), background: [] }
94export const OUTPUT_KEPT = 400
95
96/**
97 * Does this user entry open a turn? A user entry opens a turn when it has text,
98 * no tool results, and is not bash-mode text (<bash-input>, <bash-stdout>),
99 * which runs no model turn. Measured on 2.1.309 (spike Q4, spike2 M1):
100 *
101 * - Plain messages() drops isMeta rows (Stop-hook feedback, a skill's body)
102 * and local-command echoes. A Stop-hook continuation is two assistant
103 * entries in a row: one turn.
104 * - A prompt typed mid-turn, a peer message and a task notification delivered
105 * into a running turn never appear: the engine folds them into the next
106 * tool_result's content (visible only in `as: 'api'`). They cannot split a turn.
107 * - A prompt typed during a text-only turn, and a task notification arriving
108 * while idle, appear as user entries after the last turn's reply. Each is a
109 * turn of its own in the engine too, so opening one is right.
110 *
111 * Residual (rationale, open question Q-idle-delivery): a peer message arriving
112 * while idle is stored isMeta, so plain drops it and its turn's reply and calls
113 * read as part of the previous turn, the same plain shape as a Stop-hook
114 * continuation. Not measured: what an interrupted turn leaves before the next
115 * prompt (the rule opens a turn on that prompt either way).
116 */
117export function opensTurn(entry: SessionMessage): boolean {
118 return entry.role === 'user' && entry.text.trim().length > 0 && !entry.toolResults?.length && !/^\s*<bash-(?:input|stdout)>/.test(entry.text)
119}
120
121/**
122 * The whole structure from the whole transcript, in one pass:
123 *
124 * turn = none; run = undefined
125 * for entry of messages:
126 * user: if opensTurn(entry): turn = new Turn (no id yet); run = undefined
127 * (tool-result entries add nothing: toolUses carry their outcome)
128 * assistant: if the joined api message before it opens a peer turn (opensPeerTurn via joinApi),
129 * or its first tool_use id is in openers: turn = new Turn; run = undefined
130 * if entry.text.trim(): run = undefined
131 * for use of entry.toolUses: file(use)
132 * file(use): turn.id ??= use.id (the turn's identity is its first call)
133 * cls = classOf(use); refused standalones become machinery
134 * machinery: run ??= use.id; runs[run].push(use.id); turn.first ??= use.id; steps.push
135 * standalone: run = undefined (a standalone call ends the run)
136 * step = turn.steps.length; agentId = use.agentId; durationMs = use.durationMs
137 *
138 * A total function of the transcript: every tool_use in it is filed exactly
139 * once, under the turn whose opening entry precedes it, and that turn's id is
140 * a call id the transcript itself holds. With no `openers` there is no input
141 * from live events, and the same transcript files the same way before and after
142 * any switch; `openers` is the one live input (finding 1, below).
143 */
144export function buildTranscript(messages: readonly SessionMessage[], api?: readonly ApiMessage[], openers?: ReadonlySet<CallId>): Transcript {
145 const calls = new Map<CallId, FiledCall>()
146 const turns: { id?: TurnId; first?: CallId; steps: CallId[]; runs: CallId[] }[] = []
147 const byId = new Map<TurnId, Turn>()
148 const runs = new Map<CallId, { id: CallId; turn: TurnId; calls: CallId[] }>()
149 const joined = api ? joinApi(messages, api) : new Map<number, ApiMessage>()
150 let turn: (typeof turns)[number] | undefined
151 let run: (typeof runs extends Map<CallId, infer R> ? R : never) | undefined
152 const count = messages.reduce((n, entry) => n + entry.toolUses.length, 0)
153 let filed = 0
154 for (const [index, entry] of messages.entries()) {
155 const peer = joined.get(index)
156 const first = entry.toolUses[0]
157 if (opensTurn(entry) || (peer && opensPeerTurn(peer)) || (first && openers?.has(callId(first.tool_use_id)))) {
158 turn = { steps: [], runs: [] }
159 turns.push(turn)
160 run = undefined
161 }
162 if (entry.role !== 'assistant') continue
163 if (!turn && (entry.text.trim() || entry.toolUses.length)) {
164 turn = { steps: [], runs: [] }
165 turns.push(turn)
166 }
167 if (entry.text.trim()) run = undefined
168 for (const use of entry.toolUses) {
169 if (!turn) throw new Error(`Call ${use.tool_use_id} has no transcript turn`)
170 const id = callId(use.tool_use_id)
171 if (calls.has(id)) throw new Error(`Duplicate tool call id ${id}`)
172 turn.id ??= id
173 byId.set(turn.id, turn)
174 const outcome = outcomeOf(use, filed++ >= count - OUTPUT_KEPT)
175 const cls: CallClass = outcome.state === 'done' && outcome.refused ? 'machinery' : use.tool === 'Agent' || use.tool === 'Task' ? 'helper' : use.tool === 'AskUserQuestion' ? 'question' : 'machinery'
176 if (cls === 'machinery') {
177 if (!run) {
178 run = { id, turn: turn.id, calls: [] }
179 runs.set(id, run)
180 turn.runs.push(id)
181 }
182 run.calls.push(id)
183 turn.first ??= id
184 turn.steps.push(id)
185 } else run = undefined
186 calls.set(id, { id, tool: use.tool, input: use.input, cls, outcome, turn: turn.id, run: cls === 'machinery' ? run?.id : undefined, step: turn.steps.length, agentId: use.agentId })
187 }
188 }
189 return { calls, turns, byId, runs, background: backgroundRuns(messages, api) }
190}
191
192function object(value: unknown): value is Record<string, unknown> {
193 return typeof value === 'object' && value !== null && !Array.isArray(value)
194}
195
196function outcomeOf(use: ToolUseSummary, keepOutput: boolean): Outcome {
197 if (use.text === undefined && use.result === undefined) return { state: 'running' }
198 const result = object(use.result) ? use.result : undefined
199 const output = [result?.stdout, result?.stderr].filter(part => typeof part === 'string' && part.length > 0).join('\n')
200 const error = [result?.error, result?.denyReason].filter(part => typeof part === 'string' && part.length > 0).join('\n')
201 const text = output || error || use.text || (typeof use.result === 'string' ? use.result : '')
202 const errored = use.isError === true || typeof result?.denyReason === 'string'
203 return { state: 'done', errored, refused: errored && isRefusal(text), interrupted: result?.interrupted === true, lines: text.trim() ? text.trimEnd().split('\n').length : 0, output: keepOutput && text ? text.slice(0, 2000) : undefined, durationMs: use.durationMs, answers: use.tool === 'AskUserQuestion' ? questionPairs(use.input, use.result) : undefined }
204}
205
206/**
207 * Q-idle-delivery option B (finding 1). An idle peer message is
208 * stored isMeta, so plain drops it and its turn reads as part of the previous
209 * one. Two sources open such a turn, and only these two:
210 *
211 * 1. Live provenance, for rows stored while this load ran: a call id in
212 * `openers` (Observations.turnOpeners, from session.append origin `peer`)
213 * opens a turn at that call. Exact, and it covers teammate envelopes.
214 * 2. History (rows from before the load): where plain has two assistant
215 * entries in a row, the `api` user message between those two replies
216 * opens a turn only if opensPeerTurn says so.
217 *
218 * Not a substring search. The previous rule (an envelope tag anywhere in a text
219 * block) split on a Stop hook whose feedback quoted `<task-notification>`:
220 * M1d shows that feedback becomes a text-only api user message between two
221 * adjacent assistant entries. Withdrawn with it: "an injection the feed does
222 * not know can only merge, never split" as a structural claim. It now holds
223 * only as far as opensPeerTurn's anchored prefix holds, which the tests below
224 * check case by case; it is not proven for every hook type.
225 *
226 * Also withdrawn: "no switch can file a call in a different turn than a fresh
227 * build". It holds for source 2 alone. With source 1 a fresh load (a change
228 * of these files, such as a cs update, or a relaunch) loses the openers and
229 * falls back to source 2: a teammate idle turn, which source 2 does not match,
230 * merges again after it. A per-session toggle is not a reload and loses nothing.
231 *
232 * Source 1's live order is measured (spike5 M19): the prompt hook sets
233 * peerPending synchronously after its next(e), turn.start (0-1 ms later)
234 * consumes it into the turn's record, and the response hook adds that turn's
235 * first tool_use id. See session.Observations.peerPending.
236 */
237
238/**
239 * Source 2 needs, for two adjacent plain assistant entries, the `api` message
240 * between them. Neither form carries a uuid, and their windows differ (plain
241 * drops isMeta and local-command rows; api is "the messages the next request is
242 * built from", the last compaction's summary in place of what it replaced,
243 * opening on a user message; each holds at most 4096). So the join is
244 * positional, anchored at the newest end, and verified pair by pair:
245 *
246 * A = plain's assistant entries, newest first; B = api's assistant messages, newest first
247 * pair A[k] with B[k] for k = 0, 1, … while the pair is CONSISTENT:
248 * - same tool_use ids in the same order (plain toolUses[].tool_use_id, api tool_use blocks' `id`), and
249 * - same text (plain `text` === api text blocks joined the way plain joins them)
250 * stop at the first inconsistent pair, or when either list ends.
251 * A consistent pair is VERIFIED only when:
252 * - it carries tool_use ids (they are unique, so a shifted pair cannot match them), or
253 * - it is tool-free and its text occurs exactly once among plain's tool-free
254 * assistant texts AND exactly once among api's.
255 * A consistent tool-free pair whose text repeats is unverified: the walk goes on
256 * past it, and every gap that touches it answers false.
257 * For a verified pair (A[k], B[k]) whose next-older pair (A[k+1], B[k+1]) is also
258 * verified, and whose plain entries are adjacent (no plain user entry between them):
259 * the api messages strictly between B[k+1] and B[k] must be exactly one user
260 * message U; then opensPeerTurn(U) decides. Zero or two or more user messages
261 * between them, or anything else: false.
262 *
263 * Why text alone does not verify: two consecutive identical short tool-free
264 * texts (m1d's Stop-hook continuation is `ok`, `ok`) with a one-message offset
265 * between the windows would verify a shifted pair and put a peer preamble one
266 * entry off. The offset is reachable: the refresh reads plain, then api, so a
267 * row stored between the reads gives api one message plain lacks. Under one
268 * divergence a shifted pair is inconsistent (the walk stops) or a repeated
269 * tool-free text (unverified), so it never verifies. Cost: a repeated tool-free
270 * text closes the gaps beside it to the merge.
271 *
272 * Every failure answers false, the merge: the conservative direction (a peer
273 * turn reads as part of the previous turn, as without option B). Compaction
274 * replaces the oldest api messages with a summary, so the pairing stops there;
275 * turns older than the last compaction fall back to the merge.
276 *
277 * Assumption, not measured, load-bearing (open-measurements M23): between two
278 * verified anchors, plain and api hold the same assistant messages in the same
279 * order (api never merges two stored assistant rows that plain keeps apart,
280 * nor the reverse). One divergence makes the walk stop or leaves the pair
281 * unverified, so the rule only stops splitting. Two compensating divergences
282 * between verified pairs (one api message extra, one missing) are not closed
283 * by the rule above: there it could split wrongly, which M23 measures.
284 *
285 * Kit tests use synthetic adjacent assistant pairs for peer joining, changed
286 * text, differing or reordered ids, and uniqueness on each side. The recorded
287 * m1d `ok`, `ok` pair remains unverified even with the newest API reply absent.
288 * m1b has no earlier assistant to verify around its first peer row (M23).
289 *
290 * Returns, for each plain index of an A[k] that passed, the one api user
291 * message U between it and the older verified entry; buildTranscript asks
292 * opensPeerTurn(U) for those indexes and for no other.
293 */
294export function joinApi(plain: readonly SessionMessage[], api: readonly ApiMessage[]): ReadonlyMap<number, ApiMessage> {
295 const left = plain.flatMap((message, index) => message.role === 'assistant' ? [{ index, text: message.text, ids: message.toolUses.map(use => use.tool_use_id) }] : []).reverse()
296 const right = api.flatMap((message, index) => message.role === 'assistant' ? [{ index, text: apiText(message), ids: message.content.flatMap(block => block.type === 'tool_use' && typeof block.id === 'string' ? [block.id] : []) }] : []).reverse()
297 const joined = new Map<number, ApiMessage>()
298 const leftCounts = textCounts(left)
299 const rightCounts = textCounts(right)
300 let newer: { plain: number; api: number; verified: boolean } | undefined
301 for (let i = 0; i < Math.min(left.length, right.length); i++) {
302 const a = left[i]
303 const b = right[i]
304 if (!a || !b) throw new Error(`Missing API join pair at ${i}`)
305 if (a.text !== b.text || a.ids.length !== b.ids.length || a.ids.some((id, j) => id !== b.ids[j])) break
306 const verified = a.ids.length > 0 || (leftCounts.get(a.text) === 1 && rightCounts.get(b.text) === 1)
307 if (verified && newer?.verified && newer.plain === a.index + 1 && newer.api === b.index + 2) {
308 const between = api[b.index + 1]
309 if (between?.role === 'user') joined.set(newer.plain, between)
310 }
311 newer = { plain: a.index, api: b.index, verified }
312 }
313 return joined
314}
315
316function textCounts(rows: readonly { text: string; ids: readonly string[] }[]): ReadonlyMap<string, number> {
317 const counts = new Map<string, number>()
318 for (const row of rows) if (row.ids.length === 0) counts.set(row.text, (counts.get(row.text) ?? 0) + 1)
319 return counts
320}
321
322function apiText(message: ApiMessage): string {
323 return message.content.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n')
324}
325
326/**
327 * The history rule, conservative by construction: a whitelist of block
328 * shapes, true only when EVERY block of the message is one of
329 * (a) a text block that STARTS (offset 0, no leading whitespace) with the one
330 * measured engine preamble `Another Claude session sent a message:\n<cross-session-message `
331 * (M1b) and holds its closing `</cross-session-message>`; or
332 * (b) a text block that starts with `<system-reminder>` and holds none of
333 * `<cross-session-message`, `<teammate-message`, `<task-notification>`
334 * (an engine reminder riding along, such as the token count),
335 * and exactly one block is (a). Anything else answers false: a tool_result
336 * block, a `Stop hook feedback:` block or a `<system-reminder>\nStop hook
337 * blocking error` block (M1d: neither is (a); the reminder is (b) only while it
338 * quotes no envelope), any block of a shape not measured.
339 * A tag quoted anywhere else, a block that only contains the preamble later in
340 * its text, a teammate envelope (not measured: added only once measured), and
341 * `<task-notification>` (an idle notification is a plain user entry already,
342 * M1c, so it is never this shape) all answer false: the two replies stay one turn.
343 *
344 * Kit tests, fixtures derived from spike2's recorded dumps with neutral text:
345 * - m1d api, the Stop-hook feedback edited to read `Stop hook feedback:\n<task-notification>…</task-notification>` -> false, one turn
346 * - the same with `<cross-session-message from="x">…</cross-session-message>` quoted in the feedback -> false
347 * - a system-reminder block (hook context) holding the full peer preamble -> false
348 * - a text block `see: Another Claude session sent a message:\n<cross-session-message …>` (preamble not at 0) -> false
349 * - m1b api entry 0 (the idle peer row) -> true, a turn of its own
350 * - m1b starts with an orphan reply, so it cannot prove an adjacent-pair peer join (M23).
351 * - source 1 uses a shape-derived event sequence; the first peer call is a live opener.
352 */
353export function opensPeerTurn(between: ApiMessage): boolean {
354 if (between.role !== 'user' || typeof between.content === 'string') return false
355 let peers = 0
356 for (const block of between.content) {
357 if (block.type !== 'text' || typeof block.text !== 'string') return false
358 if (block.text.startsWith('Another Claude session sent a message:\n<cross-session-message ') && block.text.includes('</cross-session-message>')) {
359 peers++
360 } else if (!block.text.startsWith('<system-reminder>') || /<(?:cross-session-message|teammate-message|task-notification)/.test(block.text)) return false
361 }
362 return peers === 1
363}
364
365/** Questions' answers and pairs from a stored AskUserQuestion record (§11b). */
366export function questionPairs(input: unknown, result: unknown): readonly QuestionPair[] {
367 if (!object(result)) return []
368 const question = (text: string) => {
369 const normalized = text.replace(/\s+/g, ' ')
370 const boundary = normalized.search(/[?:](?:\s|$)/)
371 return (boundary >= 0 ? normalized.slice(0, boundary) : normalized.split('.')[0] ?? '').trim()
372 }
373 const pairs = object(result.answers) ? Object.entries(result.answers).flatMap(([key, value]) => typeof value === 'string' ? [{ question: question(key), answer: value }] : []) : []
374 if (pairs.length || typeof result.response !== 'string') return pairs
375 const first: unknown = object(input) && Array.isArray(input.questions) ? input.questions[0] : undefined
376 return [{ question: object(first) && typeof first.question === 'string' ? question(first.question) : 'a question', answer: result.response }]
377}
378
379/**
380 * Background runs from the transcript (§9.1): a Bash use with
381 * `run_in_background` (or a result marked `backgroundedToDeliverMessage`, spike2
382 * M1a) opens one; a KillShell/TaskStop naming it, or a later user entry naming
383 * its id in a <tool-use-id> or <task-id> tag (an idle task notification), closes
384 * it. A notification delivered mid-turn is not in plain messages() (M1c), so
385 * the API tool_result content supplies that close (rationale option B). A run
386 * started before a reload is recovered here, its elapsed counted from this
387 * load's first sight.
388 */
389export function backgroundRuns(messages: readonly SessionMessage[], api: readonly ApiMessage[] = []): readonly BackgroundRun[] {
390 const running = new Map<CallId, BackgroundRun>()
391 const taskCalls = new Map<string, CallId>()
392 const close = (id: string) => running.delete(taskCalls.get(id) ?? callId(id))
393 for (const entry of messages) {
394 if (entry.role === 'user') for (const id of notificationIds(entry.text)) close(id)
395 for (const use of entry.toolUses) {
396 const result = object(use.result) ? use.result : undefined
397 if (use.tool === 'Bash' && (use.input.run_in_background === true || result?.backgroundedToDeliverMessage === true)) {
398 const id = callId(use.tool_use_id)
399 const label = use.input.description || use.input.command
400 const commandLike = typeof label === 'string' && /[;&|<>`$\/\\]|^\s*(?:cd|bash|python\d*(?:\.\d+)*|node|npm|git|pgrep)\b/.test(label)
401 const words = callWords(commandLike ? { tool: 'Bash', input: {} } : use, 'present')
402 running.set(id, { id, words, isTest: typeof label === 'string' && looksLikeTest(label) })
403 if (typeof result?.backgroundTaskId === 'string') taskCalls.set(result.backgroundTaskId, id)
404 }
405 if (use.tool === 'KillShell' || use.tool === 'TaskStop') {
406 const id = use.input.task_id ?? use.input.shell_id
407 if (typeof id === 'string' && id) close(id)
408 }
409 }
410 }
411 for (const entry of api) {
412 if (entry.role !== 'user') continue
413 for (const block of entry.content) {
414 const text = block.type === 'tool_result' ? block.content : block.type === 'text' ? block.text : undefined
415 if (typeof text !== 'string') continue
416 for (const notification of text.matchAll(/<task-notification>[\s\S]*?<\/task-notification>/g)) {
417 for (const id of notificationIds(notification[0])) close(id)
418 }
419 }
420 }
421 return [...running.values()]
422}
423
424function notificationIds(text: string): readonly string[] {
425 return [...text.matchAll(/<(?:tool-use-id|task-id)>([^<]+)<\/(?:tool-use-id|task-id)>/g)].flatMap(match => match[1]?.trim() ? [match[1].trim()] : [])
426}
427hooks/draw.tsx 184 lines1// ABOUTME: cs-feed's trees: turns a verdict into elements from $.ui.resolve(e), with the inks (§13) and the 7-column name margin.
2// ABOUTME: No decisions and no $ calls but resolve; a Button never sits inside a Text (the engine refuses the whole tree).
3import type { EngineInterface, RenderElement } from 'claude-code'
4
5import type { BandVerdict, CallVerdict, Context, GroupVerdict, HelperBlock, ReplyVerdict, UserVerdict } from './verdict'
6import type { FeedCommand, FieldSpec, Session, View } from './settings'
7import { FIELDS, KINDS, parseFeedArgs } from './settings'
8import type { FrameLine } from './words'
9import { helperNowLine, helperPaneHead, helpersPaneWords, kindChip, settingCommand, settingValue, settingsWords, slashCommand } from './words'
10import { backgroundLine, backgroundTail, braille, breathing, burstLine, callChips, callWords, fit, frameBody, frameHead, helperCounts, helperFraction, helperLine, helperNoteLine, hhmm, hookNotice, labels, marks, moreKinds, outputLines, peerLine, pillLabel, questionLines, runChips, runWords, stepsLine, usageLabel, usagePeriods } from './words'
11
12type Elements = ReturnType<EngineInterface['ui']['resolve']>
13
14export type Inks = {
15 readonly you: string; readonly claude: string; readonly peer: string; readonly mark: string; readonly chip: string
16 readonly green: string; readonly red: string; readonly amber: string; readonly accent: string
17}
18/** §13 table; `light` from the /config theme, read by the 1 s tick at most every 30 s. */
19export function inks(light: boolean): Inks {
20 return light
21 ? { you: '#2f4f6f', claude: '#8a4a1f', peer: '#5b3fb5', mark: '#5b3fb5', chip: '#e4e6eb', green: '#1f7a4a', red: '#b42318', amber: '#8a5a00', accent: '#d97757' }
22 : { you: '#9fb4c8', claude: '#e3c4a2', peer: '#a78bfa', mark: '#b9acec', chip: '#3b3b3b', green: '#7fbf8f', red: '#e06c6c', amber: '#d8b36a', accent: '#d97757' }
23}
24
25/** Context every tree needs: elements, inks, room, whether chevrons are Buttons, and the press handler. */
26export type Pen = {
27 readonly el: Elements
28 readonly ink: Inks
29 readonly room: number
30 readonly pad: number // 7 on the terminal, 0 on desktop
31 readonly clickable: boolean
32 readonly press: (key: string) => Promise<void> // session.toggle
33 readonly helpers: () => Promise<void>
34 readonly cx: Context
35}
36
37export const NOTHING = (pen: Pen): RenderElement => pen.el.Box({})
38
39export function drawCommandOutput(v: { readonly text: string; readonly isErrored: boolean }, pen: Pen): RenderElement {
40 const { Box, Text } = pen.el
41 return <Box paddingLeft={pen.pad} flexDirection="row"><Box width={2}><Text dimColor>{marks.dot}</Text></Box><Text color={v.isErrored ? pen.ink.red : undefined} dimColor={!v.isErrored}>{v.text}</Text></Box>
42}
43
44/** Turn line, run line, call line, helper row, question rows (§4.6, §7.1, §11). */
45export function drawCall(v: CallVerdict, pen: Pen): RenderElement {
46 const { Box, Text, Button } = pen.el
47 switch (v.draw) {
48 case 'nothing': return NOTHING(pen)
49 case 'turn': return <Box flexDirection="column">{v.done > 0 ? <Box paddingLeft={pen.pad} flexDirection="row" gap={1}><Text dimColor>{stepsLine(v.done)}</Text>{chevron(`turn-${v.turn}`, v.opened, pen)}</Box> : null}{v.opened ? <Box paddingLeft={2} flexDirection="column">{v.body.map(call => drawCall(call, pen))}</Box> : null}</Box>
50 case 'run': {
51 const naming = v.naming ? pen.cx.t.calls.get(v.naming) : undefined
52 const words = runWords(v.run, pen.cx.t, naming ? callWords(naming, 'present') : undefined)
53 const chips = !naming && pen.cx.view.style !== 'line' ? runChips(v.run, pen.cx.t) : []
54 return <Box flexDirection="column"><Box paddingLeft={pen.pad} flexDirection="row" gap={1}><Text color={naming ? pen.ink.accent : undefined} dimColor={!naming}>{naming ? marks.live : marks.idle}</Text><Text>{fit(words, pen.room - 4)}</Text>{chips.map(chip => <Text backgroundColor={pen.ink.chip}>{chip}</Text>)}{chevron(`open-${v.run.id}`, v.opened, pen)}</Box>{v.opened ? <Box paddingLeft={2} flexDirection="column">{v.run.calls.flatMap(id => {
55 const filed = pen.cx.t.calls.get(id)
56 if (!filed) return []
57 const done = filed.outcome.state === 'done' ? filed.outcome : undefined
58 return [drawCall({ draw: 'call', call: { id, tool: filed.tool, input: filed.input, isRunning: !done, isErrored: done?.errored === true, isInterrupted: done?.interrupted === true, output: done?.output }, filed, opened: true }, pen)]
59 })}</Box> : null}</Box>
60 }
61 case 'call': {
62 const chips = pen.cx.view.style === 'line' ? [] : callChips(v.call, v.filed)
63 return <Box flexDirection="column"><Box paddingLeft={pen.pad} flexDirection="row" gap={1}><Text color={v.call.isErrored ? pen.ink.red : v.call.isRunning ? pen.ink.accent : undefined} dimColor={!v.call.isRunning && !v.call.isErrored}>{v.call.isRunning ? marks.live : marks.idle}</Text><Text>{fit(callWords(v.call, v.call.isRunning ? 'present' : 'past'), pen.room - 4)}</Text>{chips.map(chip => <Text backgroundColor={pen.ink.chip}>{chip}</Text>)}{v.call.id ? chevron(v.call.id, v.opened, pen) : null}</Box>{v.opened ? <Box paddingLeft={pen.pad + 2} flexDirection="column">{outputLines(v.call.output ?? (v.filed?.outcome.state === 'done' ? v.filed.outcome.output : undefined)).map(line => <Text dimColor>{line}</Text>)}</Box> : null}</Box>
64 }
65 case 'helper': return <Box paddingLeft={pen.pad} flexDirection="row" gap={1}><Text color={v.state === 'failed' || v.state === 'stopped' ? pen.ink.red : pen.ink.peer}>{helperLine(v.call, v.state, v.elapsedMs)}</Text>{pen.clickable ? <Button key={`helper-${v.call.id ?? v.call.tool}`} label={marks.closed} plain dimColor onPress={pen.helpers} /> : <Text dimColor>{marks.closed}</Text>}</Box>
66 case 'question': return <Box paddingLeft={pen.pad} flexDirection="column">{questionLines(v.call, v.call.id ? pen.cx.t.calls.get(v.call.id) : undefined).map(line => <Text>{line}</Text>)}</Box>
67 }
68}
69/** 'stack' draws each call's tree in a column; 'batch' the §7.2 helper batch; 'engine' never reaches here. */
70export function drawGroup(v: Exclude<GroupVerdict, { draw: 'engine' }>, pen: Pen): RenderElement {
71 const { Box, Text } = pen.el
72 if (v.draw === 'stack') return <Box flexDirection="column">{v.calls.map(call => drawCall(call, pen))}</Box>
73 const busy = v.helpers.some(helper => helper.state === 'running' || helper.state === 'dispatched')
74 const failed = v.helpers.some(helper => helper.state === 'failed' || helper.state === 'stopped')
75 const kinds = [...new Set(v.helpers.map(helper => helper.kind))]
76 const cell = (state: import('./verdict').HelperState) => <Text color={state === 'failed' || state === 'stopped' ? pen.ink.red : state === 'finished' ? pen.ink.green : pen.ink.peer}>{state === 'failed' || state === 'stopped' ? marks.failed : state === 'finished' ? marks.live : marks.working}</Text>
77 return <Box flexDirection="column"><Box paddingLeft={pen.pad} flexDirection="row" gap={1}><Text color={busy ? pen.ink.peer : failed ? pen.ink.red : pen.ink.green}>{busy ? braille[Math.floor(pen.cx.now / 70) % braille.length] : failed ? marks.failed : marks.live}</Text><Text>{helperCounts(v.helpers.map(helper => helper.state), v.elapsedMs)}</Text>{chevron(v.key, v.opened, pen)}</Box>{kinds.map(kind => {
78 const members = v.helpers.filter(helper => helper.kind === kind)
79 return <Box paddingLeft={pen.pad + 2} flexDirection="row" gap={1}><Text dimColor>{fit(kind, 22)}</Text><Text dimColor>{String(members.length)}</Text>{members.map(helper => cell(helper.state))}<Text dimColor>{helperFraction(members.filter(helper => helper.state === 'finished').length, members.length, members.filter(helper => helper.state === 'failed' || helper.state === 'stopped').length)}</Text></Box>
80 })}{v.opened ? v.helpers.map(helper => <Box paddingLeft={pen.pad + 2} flexDirection="row" gap={1}>{cell(helper.state)}<Text>{fit(callWords(helper.call, helper.state === 'running' || helper.state === 'dispatched' ? 'present' : 'past'), 60)}</Text></Box>) : null}</Box>
81}
82/** The "you" margin, the hook-text line, peer lines, bursts, frames and cards (§3.1, §8). */
83export function drawUser(v: Exclude<UserVerdict, { draw: 'engine' }>, pen: Pen): RenderElement {
84 const { Box, Text } = pen.el
85 switch (v.draw) {
86 case 'nothing': return NOTHING(pen)
87 case 'you': {
88 const command = slashCommand(v.text)
89 const text = command
90 ? <Box flexGrow={1} flexDirection="row" gap={1}><Text color={pen.ink.you}>{command.name}</Text>{command.args ? <Text dimColor>{command.args}</Text> : null}</Box>
91 : <Box flexGrow={1}><Text color={pen.ink.you}>{v.text}</Text></Box>
92 return <Box flexDirection="column" marginTop={1}><Box flexDirection="row"><Box width={pen.pad}><Text color={pen.ink.you} dimColor>{labels.you}</Text></Box>{text}{v.at === undefined ? null : <Text dimColor>{hhmm(v.at)}</Text>}</Box></Box>
93 }
94 case 'hookText': return <Box paddingLeft={pen.pad}><Text dimColor>{hookNotice(v.line)}</Text></Box>
95 case 'peer': return <Box paddingLeft={pen.pad + (v.nested ? 2 : 0)}><Text color={pen.ink.peer}>{peerLine(v.sender, v.headline)}</Text></Box>
96 case 'helperNote': return <Box paddingLeft={pen.pad}><Text color={pen.ink.peer}>{helperNoteLine(v.headline, v.durationMs)}</Text></Box>
97 case 'backgroundNote': return <Box paddingLeft={pen.pad}><Text dimColor>{labels.backgroundUpdate}</Text></Box>
98 case 'burst': {
99 const head = v.members[0]
100 if (!head) return NOTHING(pen)
101 return <Box flexDirection="column" paddingLeft={pen.pad}><Box flexDirection="row"><Text color={pen.ink.peer}>{burstLine(v.members)}</Text>{chevron(`burst-${head.uuid}`, v.opened, pen)}</Box>{v.own ? <Box paddingLeft={2}><Text color={pen.ink.peer}>{peerLine(v.own.sender, v.own.headline)}</Text></Box> : null}</Box>
102 }
103 case 'frames': return <Box flexDirection="column" paddingLeft={pen.pad}>{v.frames.map(frame => <Box flexDirection="column"><Text color={pen.ink.peer}>{frameHead(frame)}</Text><Box flexDirection="column" paddingLeft={2}>{frameBody(frame).map(line => drawFrameLine(line, pen))}</Box></Box>)}</Box>
104 }
105}
106
107function drawFrameLine(line: FrameLine, pen: Pen): RenderElement {
108 const { Box, Markdown, Text } = pen.el
109 const tone = (value: 'ok' | 'bad' | 'mid') => value === 'ok' ? pen.ink.green : value === 'bad' ? pen.ink.red : pen.ink.amber
110 switch (line.kind) {
111 case 'markdown': return <Markdown text={line.text} />
112 case 'status': return <Box flexDirection="row" gap={1}><Text color={tone(line.tone)}>{line.glyph}</Text>{line.status ? <Text>{line.status}</Text> : null}{line.sub ? <Text dimColor>{line.sub}</Text> : null}</Box>
113 case 'text': return <Text>{line.text}</Text>
114 case 'score': return <Box flexDirection="row" gap={1}><Text>{line.label}</Text><Box flexDirection="row">{line.dots.map(dot => <Text color={tone(dot)}>{marks.live}</Text>)}</Box><Text dimColor>{line.tally}</Text></Box>
115 case 'list': return <Box flexDirection="row" gap={1}><Text>{line.label}</Text><Text bold>{line.count}</Text><Text dimColor>{line.first}</Text></Box>
116 case 'hint': return <Text dimColor>{line.text}</Text>
117 }
118}
119
120function chevron(key: string, opened: boolean, pen: Pen): RenderElement {
121 const { Button, Text } = pen.el
122 const label = opened ? marks.opened : marks.closed
123 return pen.clickable ? <Button key={key} label={label} plain dimColor onPress={() => pen.press(key)} /> : <Text dimColor>{label}</Text>
124}
125/**
126 * The "claude" margin beside the ENGINE's drawing of the reply (passed in as
127 * `engine`), the error line in its place, and the steps line under it. The
128 * reply's markdown is never redrawn by the mod: an inner hook's drawing (cs-
129 * mermaid's diagrams) survives whichever order the two load in.
130 */
131export function drawReply(v: ReplyVerdict, engine: RenderElement, pen: Pen): RenderElement {
132 const { Box, Text } = pen.el
133 const body = typeof v.body === 'object'
134 ? <Box flexDirection="column" paddingLeft={pen.pad}><Text color={v.body.error.tone === 'red' ? pen.ink.red : v.body.error.tone === 'amber' ? pen.ink.amber : pen.ink.peer}>{v.body.error.head}</Text>{v.body.error.rest.map(line => <Text dimColor>{line}</Text>)}</Box>
135 : v.body === 'nothing' ? NOTHING(pen)
136 : <Box flexDirection="row"><Box width={pen.pad}>{v.name ? <Text color={pen.ink.claude} dimColor>{labels.claude}</Text> : null}</Box><Box flexGrow={1} flexDirection="column">{engine}</Box></Box>
137 return <Box flexDirection="column">{body}{v.stepsLine ? drawCall(v.stepsLine, pen) : null}</Box>
138}
139/** Ours above `beneath` (what next(e) drew: cs's band, or the engine's), in one column. */
140export function drawBand(v: Exclude<BandVerdict, { draw: 'engine' }>, beneath: RenderElement, frame: number, pen: Pen): RenderElement {
141 const { Box, Text } = pen.el
142 let ours: RenderElement
143 switch (v.draw) {
144 case 'helpers': ours = <Box flexDirection="column" paddingLeft={pen.pad}><Box flexDirection="row" gap={1}><Text color={pen.ink.peer}>{v.spinning ? braille[frame % braille.length] : marks.live}</Text><Text color={pen.ink.peer}>{v.head}</Text></Box>{v.kinds.map(kind => <Box flexDirection="row" gap={1}><Text dimColor>{kind.kind}</Text>{[...kind.cells].map(cell => <Text color={cell === marks.working ? pen.ink.peer : cell === marks.failed ? pen.ink.red : pen.ink.green}>{cell}</Text>)}<Text dimColor>{helperFraction(kind.done, kind.n)}</Text></Box>)}{v.more ? <Text dimColor>{moreKinds(v.more)}</Text> : null}</Box>; break
145 case 'background': ours = <Box paddingLeft={pen.pad} flexDirection="row" gap={1}><Text color={pen.ink.accent}>{breathing[Math.floor(frame / 2) % breathing.length]}</Text><Text>{fit(backgroundLine(v.words, v.isTest, v.more), pen.room)}</Text><Text dimColor>{backgroundTail(v.elapsedMs, v.typing)}</Text></Box>; break
146 case 'desktop': ours = <Box justifyContent="flex-end" flexDirection="row" gap={1}>{v.usage.fiveHour === undefined ? null : <Text>{usageLabel(v.usage.fiveHour, usagePeriods.fiveHour)}</Text>}{v.usage.week === undefined ? null : <Text>{usageLabel(v.usage.week, usagePeriods.week)}</Text>}{v.pills.map(pill => <Text backgroundColor={pen.ink.chip}>{pillLabel(pill)}</Text>)}</Box>; break
147 }
148 return <Box flexDirection="column">{ours}{beneath}</Box>
149}
150
151/** The "Chat view" pane (§2.3) for this session: header, an on/off row (FeedCommand `switch`), one row per FIELDS entry with its picture, the Show chips. Buttons are siblings of Texts, never inside one. A Button never shrinks, so the chips wrap onto another row when the pane is narrower than them; `columnGap`, not `gap`, which would also put a blank row between the two. */
152export function drawSettingsPane(mode: string, session: Session, pen: Pick<Pen, 'el' | 'ink' | 'room'>, onPress: (cmd: FeedCommand) => Promise<void>): RenderElement {
153 const { Box, Text, Button } = pen.el
154 return <Box flexDirection="column"><Box flexDirection="row" gap={1}><Text bold>{settingsWords.title}</Text><Text color={pen.ink.accent}>{mode}</Text><Text dimColor>{settingsWords.shortcuts}</Text></Box><Box flexDirection="row" gap={1}><Button key="btn-feed" label={settingsWords.switch} onPress={() => onPress({ kind: 'switch', on: !session.on })} /><Text color={pen.ink.accent} bold>{settingValue(session.on)}</Text></Box>{FIELDS.map(spec => {
155 const current = session.view[spec.field]
156 const next = spec.values[(spec.values.findIndex(value => value === current) + 1) % spec.values.length]
157 if (next === undefined) throw new Error(`setting ${spec.field} has no next value`)
158 const command = settingCommand(spec.word, next)
159 return <Box flexDirection="column"><Box flexDirection="row" gap={1}><Button key={`btn-${spec.field}`} label={spec.title} onPress={() => onPress(parseFeedArgs(command.slice(6)))} /><Text color={pen.ink.accent} bold>{settingValue(current)}</Text><Text dimColor>{`${marks.dot} ${command}`}</Text></Box><Box paddingLeft={2} flexDirection="column">{fieldPicture(spec, session.view).map(line => <Text dimColor>{fit(line, Math.max(1, pen.room - 4))}</Text>)}</Box></Box>
160 })}<Text bold>{settingsWords.show}</Text><Box flexDirection="row" flexWrap="wrap" columnGap={1}>{KINDS.map(kind => {
161 const shown = !session.view.hide.includes(kind)
162 return <Button key={`kind-${kind}`} label={kindChip(kind, shown)} onPress={() => onPress({ kind: shown ? 'hide' : 'show', what: kind })} />
163 })}</Box><Text dimColor>{settingsWords.hideHelp}</Text></Box>
164}
165
166function fieldPicture(spec: FieldSpec, view: View): readonly string[] {
167 switch (spec.field) {
168 case 'group': case 'clear': case 'keep': return spec.picture(view[spec.field])
169 case 'style': return spec.picture(view.style)
170 case 'commands': return spec.picture(view.commands)
171 case 'after': return spec.picture(view.after)
172 }
173}
174/** The "Helpers" pane (§7.4): the newest helpers first, each a head with the live glyph while it works, "now:" and its own last calls, indented. */
175export function drawHelpersPane(blocks: readonly HelperBlock[], pen: Pick<Pen, 'el' | 'ink' | 'room'>): RenderElement {
176 const { Box, Text } = pen.el
177 if (!blocks.length) return <Box><Text dimColor>{helpersPaneWords.none}</Text></Box>
178 const room = Math.max(1, pen.room - 2)
179 return <Box flexDirection="column">{blocks.map(block => {
180 const working = block.status === 'working'
181 return <Box flexDirection="column"><Box flexDirection="row" gap={1}><Text color={working ? pen.ink.accent : block.status === 'finished' ? undefined : pen.ink.red} dimColor={!working}>{working ? marks.live : marks.idle}</Text><Text>{fit(helperPaneHead(block.name, block.status, block.elapsedMs), room)}</Text></Box>{block.now ? <Box paddingLeft={2}><Text dimColor>{fit(helperNowLine(block.now), room)}</Text></Box> : null}{block.calls.map(call => <Box paddingLeft={2}><Text dimColor>{fit(call, room)}</Text></Box>)}</Box>
182 })}</Box>
183}
184hooks/words.ts 550 lines1// ABOUTME: cs-feed's words: every string the feed draws that is not a setting, from call words and run counts to error lines, peer headlines and report frames.
2// ABOUTME: Pure. The strings are the spec's (explore-features §4.6, §6, §8), pinned by tests; the dropped clock strings ("· 19s ·") are not brought back.
3import type { FiledCall, Run, Transcript } from './transcript'
4import type { CallView } from './verdict'
5
6export type Tense = 'present' | 'past'
7
8export const labels = { you: 'you', claude: 'claude', backgroundUpdate: 'background task update', reportHint: 'ctrl+o shows the whole report' } as const
9export const marks = { dot: '·', live: '●', idle: '○', peer: '⇄', closed: '›', opened: '⌄', question: '?', failed: '✕', working: '◐' } as const
10export const braille = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
11export const breathing = ['·', '•', '●', '•'] as const
12export const usagePeriods = { fiveHour: '5h', week: 'week' } as const
13export const settingsWords = { title: 'Chat view', switch: 'Feed', shortcuts: '· /feed clean · /feed normal · /feed raw · /feed close', show: 'Show', hideHelp: '/feed hide <kind> · /feed show <kind>' } as const
14export function settingValue(value: boolean | string): string { return typeof value === 'boolean' ? value ? 'on' : 'off' : value }
15export function settingCommand(word: string, value: boolean | string): string { return `/feed ${word} ${settingValue(value)}` }
16export function kindChip(kind: string, shown: boolean): string { return `${shown ? marks.live : marks.idle} ${kind}` }
17
18// The lines the feed hands the engine to draw on surfaces of the engine's own:
19// its transcript notices (the engine leads each with `cs-feed: `), the
20// replies of /feed and /helpers that are not a setting's (settings.ts
21// transition holds those) and its slash menu rows.
22export const logs = {
23 viewUnusable: (path: string, reason: string) => `could not use ${path}: ${reason}; the feed draws its default view until /feed writes the file again`,
24 unread: (path: string, reason: string) => `could not read ${path}: ${reason}`,
25 feedOff: (refusal: string) => `the feed is off in this session: ${refusal}`,
26 clockStopped: (reason: string) => `clock stopped: ${reason}`,
27 pulseStopped: (reason: string) => `pulse stopped: ${reason}`,
28 helperUnread: (id: string, deny: string) => `could not read helper ${id}'s transcript: ${deny}`,
29} as const
30export const replies = {
31 notASession: 'this directory is not a cs session (no .cs/local), so nothing changed.',
32 refused: (refusal: string) => `no feed change in this session: ${refusal}`,
33 helpersOpen: 'Helpers open above the prompt.',
34} as const
35export const commands = {
36 feed: { name: 'feed', description: 'Chat view for this session: on, off, clean, normal, raw, or one setting', argumentHint: 'on | off | clean | normal | raw | status | <setting> <value>', immediate: true },
37 helpers: { name: 'helpers', description: "Show each helper's own work, call by call", immediate: true },
38} as const
39
40export function moreKinds(count: number): string { return `+${count} more ${count === 1 ? 'kind' : 'kinds'}` }
41
42export function helperCounts(states: readonly import('./verdict').HelperState[], durationMs?: number): string {
43 const done = states.filter(state => state === 'finished').length
44 const running = states.filter(state => state === 'running' || state === 'dispatched').length
45 const failed = states.filter(state => state === 'failed' || state === 'stopped').length
46 return joined([`${states.length} ${states.length === 1 ? 'helper' : 'helpers'}`, done ? `${done} done` : undefined, running ? `${running} running` : undefined, failed ? `${failed} failed` : undefined, durationMs === undefined ? undefined : elapsed(durationMs)])
47}
48
49export function helperFraction(done: number, count: number, failed = 0): string {
50 return joined([`${done}/${count}`, failed ? `${failed} failed` : undefined])
51}
52
53export function backgroundLine(words: string, isTest: boolean, more: number): string {
54 const label = /[|;&><`$\/]|^(?:cd|bash|python|node|npm|git|pgrep)\b/.test(words) ? 'Running a shell command' : words
55 return joined([isTest ? 'Test running' : 'Running in the background', label, more ? `+${more} more` : undefined])
56}
57
58export function backgroundTail(durationMs: number | undefined, typing: boolean): string {
59 const tail = joined([durationMs === undefined ? undefined : elapsed(durationMs), typing ? 'you can keep typing' : undefined])
60 return tail ? `${marks.dot} ${tail}` : ''
61}
62
63export function usageLabel(percent: number, period: '5h' | 'week'): string {
64 return joined([`${Math.round(percent)}%`, period])
65}
66
67export function pillLabel(pill: import('./verdict').Pill): string {
68 return joined([pill.kind === 'helper' ? 'Helper' : pill.kind === 'test' ? '⚗ Test' : 'Job', pill.words, pill.elapsedMs === undefined ? undefined : elapsed(pill.elapsedMs)])
69}
70
71/** The Helpers pane's words (§7.4). */
72export const helpersPaneWords = { none: 'No helper ran in this session.', now: 'now:' } as const
73
74/** §7.4 head: "{name} · working · {elapsed}" while running, else "{name} · {status} · {elapsed}"; no time when none is known. */
75export function helperPaneHead(name: string, status: 'working' | 'finished' | 'failed' | 'stopped', durationMs?: number): string {
76 return joined([name, status, durationMs === undefined ? undefined : elapsed(durationMs)])
77}
78
79export function helperNowLine(step: string): string {
80 return `${helpersPaneWords.now} ${step}`
81}
82
83/** §3.1: a message that starts with a slash command: its name, then its arguments (empty when it has none). */
84export function slashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
85 const match = /^(\/[A-Za-z0-9][\w:.-]*)(?:\s+([\s\S]*))?$/.exec(text.trim())
86 return match?.[1] ? { name: match[1], args: (match[2] ?? '').trim() } : undefined
87}
88
89export function joined(parts: readonly (string | undefined)[]): string {
90 return parts.filter(part => part !== undefined && part !== '').join(' · ')
91}
92
93export function peerLine(sender: string, headline: string): string {
94 return `${marks.peer} ${joined([sender, headline])}`
95}
96
97export function hookNotice(line: string): string {
98 return `${marks.idle} ${line}`
99}
100
101export function burstLine(members: readonly { sender: string; at?: number }[]): string {
102 const last = members.at(-1)?.at
103 return `${marks.peer} ${joined([`${members.length} messages`, [...new Set(members.map(member => member.sender))].join(', '), last === undefined ? undefined : `last ${hhmm(last)}`])}`
104}
105
106export function helperNoteLine(headline: string, durationMs?: number): string {
107 return `${marks.peer} ${joined(['helper', durationMs === undefined ? undefined : elapsed(durationMs), headline])}`
108}
109
110/** A dispatched helper's call is in flight while the helper works, so it reads `running` too. */
111export function helperLine(call: CallView, state: import('./verdict').HelperState, durationMs?: number): string {
112 const working = state === 'running' || state === 'dispatched'
113 return joined([callWords(call, working ? 'present' : 'past'), working ? 'running' : state, durationMs === undefined ? undefined : elapsed(durationMs)])
114}
115
116export function outputLines(output: unknown): readonly string[] {
117 const text = typeof output === 'string' ? output : output && typeof output === 'object' && !Array.isArray(output)
118 ? ['stdout', 'stderr'].flatMap(key => { const value = (output as Record<string, unknown>)[key]; return typeof value === 'string' ? [value] : [] }).join('\n') : ''
119 return text.split('\n').filter((line, index, rows) => line !== '' || index < rows.length - 1).slice(0, 12)
120}
121
122export function questionLines(call: CallView, filed?: FiledCall): readonly string[] {
123 if (filed?.outcome.state === 'done' && filed.outcome.answers?.length) return filed.outcome.answers.map(pair => `${marks.question} ${pair.question} → ${pair.answer}`)
124 const questions = Array.isArray(call.input.questions) ? call.input.questions.flatMap(question => {
125 if (!question || typeof question !== 'object' || !('question' in question) || typeof question.question !== 'string') return []
126 return [question.question.split(/(?<=[?.!])\s/)[0] ?? question.question]
127 }) : []
128 return [...questions.map(question => `Asked you: ${question}`), ...(call.isRunning ? [] : ['No answer'])]
129}
130
131export function frameHead(frame: Frame & { from: string }): string {
132 return `${marks.peer} ${joined([frame.from, frame.state, frame.at === undefined ? undefined : hhmm(frame.at), frame.summary, frame.relays ? 'relays content written by others' : undefined])}`
133}
134
135/** One line under a report frame's header (§8.3 (a)): a verdict card's parts, the lead or its sentence as Markdown, and the dim hints. */
136export type FrameLine =
137 | { readonly kind: 'markdown'; readonly text: string }
138 | { readonly kind: 'status'; readonly tone: Card['tone']; readonly glyph: string; readonly status: string; readonly sub?: string }
139 | { readonly kind: 'text'; readonly text: string }
140 | { readonly kind: 'score'; readonly label: string; readonly dots: readonly Card['tone'][]; readonly tally: string }
141 | { readonly kind: 'list'; readonly label: string; readonly count: string; readonly first: string }
142 | { readonly kind: 'hint'; readonly text: string }
143
144/**
145 * §8.3 (a): a verdict card (status chip "✓"/"✕"/"●" by tone, "target …", a
146 * score line per judged list with up to 24 dots and "{P} of {M} pass", a line
147 * per plain list, "report {file}", the dim footer), with the sentence before
148 * its JSON drawn as Markdown above it; or the lead drawn as Markdown, then
149 * "+N more line(s) · ctrl+o shows all" when lines were left out.
150 */
151export function frameBody(frame: Frame): readonly FrameLine[] {
152 const lead = frame.lead
153 if (!lead) return []
154 const card = lead.card
155 if (!card) return [
156 ...(lead.text ? [{ kind: 'markdown' as const, text: lead.text }] : []),
157 ...(lead.more ? [{ kind: 'hint' as const, text: `+${lead.more} more ${lead.more === 1 ? 'line' : 'lines'} · ctrl+o shows all` }] : []),
158 ]
159 const lines: FrameLine[] = []
160 if (lead.sentence) lines.push({ kind: 'markdown', text: lead.sentence })
161 if (card.status || card.sub) lines.push({ kind: 'status', tone: card.tone, glyph: card.tone === 'ok' ? '✓' : card.tone === 'bad' ? marks.failed : marks.live, status: card.status, sub: card.sub })
162 if (card.target) lines.push({ kind: 'text', text: `target ${card.target}` })
163 for (const score of card.scores) lines.push({ kind: 'score', label: score.label, dots: score.dots, tally: `${score.pass} of ${score.of} pass` })
164 for (const list of card.lists) lines.push({ kind: 'list', label: list.label, count: String(list.count), first: `${marks.dot} ${list.first}` })
165 if (card.report) lines.push({ kind: 'text', text: `report ${card.report}` })
166 lines.push({ kind: 'hint', text: labels.reportHint })
167 return lines
168}
169
170/** §4.6 call words: a cleaned description of 3+ characters, else by tool ("Reading app.py", "Opened example.com"). */
171export function callWords(call: Pick<CallView, 'tool' | 'input'>, tense: Tense): string {
172 const description = typeof call.input.description === 'string' ? clean(call.input.description) : ''
173 if (description.length >= 3) return description
174 const past = tense === 'past'
175 const path = call.input.file_path ?? call.input.notebook_path
176 const file = typeof path === 'string' ? basename(path) : ''
177 const skill = typeof call.input.skill === 'string' ? clean(call.input.skill) : ''
178 const host = typeof call.input.url === 'string' ? hostname(call.input.url) : undefined
179 switch (call.tool) {
180 case 'Bash': case 'PowerShell': return past ? 'Ran a shell command' : 'Running a shell command'
181 case 'Read': return `${past ? 'Read' : 'Reading'} ${file || 'a file'}`
182 case 'Edit': case 'MultiEdit': case 'Write': case 'NotebookEdit': return `${past ? 'Edited' : 'Editing'} ${file || 'a file'}`
183 case 'Grep': case 'Glob': return past ? 'Searched the code' : 'Searching the code'
184 case 'WebSearch': return past ? 'Searched the web' : `Searching the web${typeof call.input.query === 'string' && call.input.query ? ` for ${clean(call.input.query)}` : ''}`
185 case 'WebFetch': return `${past ? 'Opened' : 'Opening'} ${host || 'a web page'}`
186 case 'Skill': return `${past ? 'Loaded' : 'Loading'} ${skill ? `the ${skill} skill` : 'a skill'}`
187 case 'Agent': case 'Task': return past ? 'Asked a helper' : 'Asking a helper'
188 case 'AskUserQuestion': return past ? 'Used AskUserQuestion' : 'Asking you a question'
189 case 'TodoWrite': return past ? 'Updated the todo list' : 'Updating the todo list'
190 default: return `${past ? 'Used' : 'Using'} ${toolName(call.tool)}`
191 }
192}
193
194/** §4.6 cleaning: first non-empty line, no NAME=value, paths to base names, no UUIDs, hex runs or long digit tokens. */
195export function clean(text: string): string {
196 return (text.split('\n').find(line => line.trim()) ?? '')
197 .replace(/\b[A-Za-z_][A-Za-z_0-9]*=(?:"[^"]*"|'[^']*'|\S+)/g, '')
198 .replace(/(?:~\/|\.\.?\/|\/)[^\s,;]+/g, path => basename(path))
199 .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '')
200 .replace(/\b[0-9a-f]{12,}\b/gi, '')
201 .replace(/\S{20,}/g, token => /\d/.test(token) ? '' : token)
202 .replace(/\s+/g, ' ').replace(/\s+([.,;:!?])/g, '$1').trim()
203}
204
205function basename(path: string): string {
206 return path.replace(/\/+$/, '').split(/[\\/]/).at(-1) ?? ''
207}
208
209function hostname(value: string): string | undefined {
210 try {
211 const url = new URL(value)
212 return url.protocol === 'http:' || url.protocol === 'https:' ? url.hostname.replace(/^www\./, '') : undefined
213 } catch {
214 return undefined
215 }
216}
217
218/** "mcp__claude_ai_Slack__send" -> "Slack send"; a built-in keeps its name. */
219export function toolName(tool: string): string {
220 return tool.startsWith('mcp__') ? tool.slice(5).replace(/^claude_ai_/, '').replace(/_+/g, ' ') : tool
221}
222
223/** §4.6 run words: the naming call's present words, "N steps refused by a hook", one call's words, or the counts ("Ran 2 shell commands · read 1 file"). */
224export function runWords(run: Run, t: Transcript, naming?: string): string {
225 if (naming) return naming
226 const calls = run.calls.flatMap(id => { const call = t.calls.get(id); return call ? [call] : [] })
227 const ran = calls.filter(call => call.outcome.state !== 'done' || !call.outcome.refused)
228 if (calls.length && !ran.length) return refusedWords(calls.length)
229 const first = calls[0]
230 if (calls.length === 1 && first) return callWords(first, first.outcome.state === 'running' ? 'present' : 'past')
231 const counts = new Map<string, number>()
232 for (const call of ran) {
233 const kind = call.tool === 'Bash' || call.tool === 'PowerShell' ? 'shell' : ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(call.tool) ? 'Edit' : call.tool === 'Glob' ? 'Grep' : call.tool === 'Task' ? 'Agent' : call.tool
234 counts.set(kind, (counts.get(kind) ?? 0) + 1)
235 }
236 const text = [...counts].sort(([a, n], [b, m]) => m - n || (a < b ? -1 : a > b ? 1 : 0)).map(([kind, n]) => countWords(kind, n)).join(' · ')
237 return text.charAt(0).toUpperCase() + text.slice(1)
238}
239
240function refusedWords(n: number): string {
241 return `${n} ${n === 1 ? 'step' : 'steps'} refused by a hook`
242}
243
244function countWords(kind: string, n: number): string {
245 const noun = (word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
246 const times = n === 1 ? 'once' : `${n} times`
247 switch (kind) {
248 case 'shell': return `ran ${noun('shell command')}`
249 case 'Read': return `read ${noun('file')}`
250 case 'Edit': return `made ${noun('edit')}`
251 case 'Grep': return `searched the code ${times}`
252 case 'Skill': return `loaded ${noun('skill')}`
253 case 'WebSearch': return `searched the web ${times}`
254 case 'WebFetch': return `opened ${noun('web page')}`
255 case 'Agent': return `asked ${noun('helper')}`
256 default: return `used ${toolName(kind)} ${times}`
257 }
258}
259
260/** §4.6 chips after " · ": refused, "N failed", "+1 line" or "+N lines" (ruling §20.10). */
261export function runChips(run: Run, t: Transcript): readonly string[] {
262 const outcomes = run.calls.flatMap(id => { const outcome = t.calls.get(id)?.outcome; return outcome?.state === 'done' ? [outcome] : [] })
263 const refused = outcomes.filter(outcome => outcome.refused).length
264 const failed = outcomes.filter(outcome => outcome.errored && !outcome.refused).length
265 const lines = outcomes.reduce((n, outcome) => n + outcome.lines, 0)
266 return [...(refused && refused < run.calls.length ? [refusedWords(refused)] : []), ...(failed ? [`${failed} failed`] : []), ...lineChip(lines)]
267}
268export function callChips(call: CallView, filed?: FiledCall): readonly string[] {
269 if (call.isRunning) return []
270 const outcome = filed?.outcome
271 const text = typeof call.output === 'string' ? call.output : ''
272 const refused = call.isErrored && (isRefusal(text) || (outcome?.state === 'done' && outcome.refused))
273 const lines = outcome?.state === 'done' ? outcome.lines : text.trim() ? text.trimEnd().split('\n').length : 0
274 return [...(refused ? [refusedWords(1)] : call.isErrored ? ['1 failed'] : []), ...lineChip(lines)]
275}
276
277function lineChip(lines: number): readonly string[] {
278 return lines ? [`+${lines} ${lines === 1 ? 'line' : 'lines'}`] : []
279}
280
281/** "1 step this turn" / "{n} steps this turn". */
282export function stepsLine(done: number): string {
283 return `${done} ${done === 1 ? 'step' : 'steps'} this turn`
284}
285
286/** §4.6 fit: cut to room-1 (at least 4) at a space in the second half, trim separators, add "…". */
287export function fit(text: string, room: number): string {
288 if (text.length <= room) return text
289 const width = Math.max(4, room - 1)
290 const space = text.lastIndexOf(' ', width)
291 return text.slice(0, space > width / 2 ? space : width).replace(/[\s·,;:|\-]+$/, '') + '…'
292}
293
294/** "8s", "1m 12s", "1h 3m"; floors at 0. */
295export function elapsed(ms: number): string {
296 const seconds = Math.max(0, Math.floor(ms / 1000))
297 if (seconds < 60) return `${seconds}s`
298 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
299 return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`
300}
301
302/** Local 24-hour "09:07". One formatter (the fork had two). */
303export function hhmm(at: number): string {
304 const date = new Date(at)
305 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
306}
307
308/** §4.7b: errored text that reads as a PreToolUse/PermissionRequest hook's refusal. */
309export function isRefusal(text: string): boolean {
310 return /(?:PreToolUse|PermissionRequest)(?::\S+)?\s+hook\s+(?:error|blocked|denied)|blocked by a hook|hook[^\n]*denied/i.test(text)
311}
312
313export type Tone = 'violet' | 'red' | 'amber'
314export type ErrorLine = { readonly head: string; readonly rest: readonly string[]; readonly tone: Tone; readonly act: boolean }
315
316/**
317 * §6 rules 1-12, first match wins. Ruling §20.6: rules 1 and 2 (safeguards)
318 * need an error prefix or a whole one-line reply; multiline quoted prose stays a reply.
319 */
320export function errorLine(text: string): ErrorLine | undefined {
321 const prefixed = /^(?:API Error|Error:|Request timed out|Connection error|Claude AI usage limit|Credit balance|Invalid API key|Please run \/login|Prompt is too long)/i.test(text)
322 if (!prefixed && text.includes('\n')) return undefined
323 const content = text.replace(/^(?:API Error|Error):?\s*/i, '')
324 const who = content.match(/^(.{1,40})['’]s safeguards/i)?.[1]
325 const line = (head: string, rest: readonly string[], tone: Tone, act = tone === 'red'): ErrorLine => ({ head, rest, tone, act })
326 if (who && /stopped the response above|continuing/i.test(content)) return line(`${who}'s safeguards cut the reply above`, ['Claude continues', 'esc esc goes back to an earlier message'], 'violet')
327 if (/stopped by a safety classifier/i.test(content)) return line('A safeguard cut the reply above', ['Claude continues', 'esc esc goes back to an earlier message'], 'violet')
328 if (!prefixed) return undefined
329 if (/safeguards|usage policy|\/legal\/aup/i.test(content)) return line(`${who ?? 'The model'}'s safeguards stopped this reply`, ['often a false alarm', 'esc esc to edit your message, or /model to switch'], 'violet', true)
330 if (/529|overloaded/i.test(content)) return line("Claude's servers are busy", ['not your setup', 'Claude Code retries, or send again'], 'amber')
331 if (/usage limit|rate limit|429/i.test(content)) {
332 const reset = content.match(/resets?\s+([^\n,;]+)/i)?.[1]?.trim()
333 return line('Usage limit reached', [...(reset ? [`resets ${reset}`] : []), 'nothing is lost', '/model to switch'], 'red')
334 }
335 if (/prompt is too long|context length|too many tokens/i.test(content)) return line('This chat is too long for the model', ['run /compact', 'nothing is lost'], 'red')
336 if (/401|403|authentication|login|api key|oauth/i.test(content)) return line('Login needed', ['run /login', 'nothing is lost'], 'red')
337 if (/credit|billing/i.test(content)) return line('Billing problem on the account', ['nothing is lost'], 'red')
338 if (/connection|network|socket|timeout|timed out/i.test(content)) return line('Connection lost', ['check your internet', 'send again to retry'], 'amber')
339 if (/\b5\d\d\b|server_error|internal/i.test(content)) return line("Server error on Claude's side", ['your work is kept', 'send a message to try again'], 'amber')
340 if (/400|invalid_request|max_output/i.test(content)) return line('Claude refused this request', ['ctrl+o shows why', 'esc esc to edit your message'], 'red')
341 return line('Claude Code hit an error', ['ctrl+o shows it'], 'amber')
342}
343
344/** §8.2 headline: first non-empty line, tags removed, paths shrunk, a status object read as words, fit. */
345export function peerHeadline(text: string, room: number): string {
346 const first = text.split('\n').find(line => line.trim()) ?? ''
347 const status = jsonObject(first)
348 const words = status ? [stateWords(status.type), string(status.summary) ?? string(status.message) ?? string(status.status)].filter(Boolean).join(' · ') : first.replace(/<[^>]*>/g, '')
349 return fit(words.replace(/(?:\/(?:Users|home|private|tmp|var|opt|Volumes)\/|~\/)[^\s,;]+/g, basename).replace(/\s+/g, ' ').trim(), room)
350}
351
352/** §8.2 sender from the envelope's from, teammate_id or name, cleaned; else "another session". */
353export function senderOf(from: unknown, raw?: string): string {
354 const name = string(from) ?? (record(from) ? string(from.name) : undefined) ?? attribute(raw ?? '', 'from') ?? attribute(raw ?? '', 'teammate_id') ?? attribute(raw ?? '', 'name')
355 return (name ? clean(name) : '') || 'another session'
356}
357
358function record(value: unknown): value is Record<string, unknown> {
359 return typeof value === 'object' && value !== null && !Array.isArray(value)
360}
361
362function string(value: unknown): string | undefined {
363 return typeof value === 'string' && value.trim() ? value : undefined
364}
365
366function jsonObject(text: string): Record<string, unknown> | undefined {
367 try {
368 const value: unknown = JSON.parse(text)
369 return record(value) ? value : undefined
370 } catch {
371 return undefined
372 }
373}
374
375function attribute(raw: string, name: 'from' | 'teammate_id' | 'name' | 'summary'): string | undefined {
376 return new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`).exec(raw)?.[1]
377}
378
379function stateWords(value: unknown): string | undefined {
380 const state = string(value)?.replace(/_notification$/, '').replace(/_/g, ' ')
381 return state === 'idle' ? 'waiting' : state
382}
383
384export type Card = {
385 readonly tone: 'ok' | 'bad' | 'mid'
386 readonly status: string
387 readonly sub?: string
388 readonly target?: string
389 readonly scores: readonly { label: string; dots: readonly ('ok' | 'bad' | 'mid')[]; pass: number; of: number }[]
390 readonly lists: readonly { label: string; count: number; first: string }[]
391 readonly report?: string
392}
393export type Frame = {
394 readonly from?: string
395 readonly state?: string // "_notification" stripped, underscores as spaces, idle -> "waiting"
396 readonly at?: number
397 readonly summary?: string
398 readonly relays: boolean
399 readonly lead?: { readonly sentence?: string; readonly card?: Card; readonly text?: string; readonly more: number }
400}
401
402/** §8.3 source 1: teammate-message envelopes in a raw user row (session.append), parsed once by uuid. */
403export function parseEnvelopes(raw: string): readonly Frame[] {
404 return [...raw.matchAll(/<teammate-message\b([^>]*)>([\s\S]*?)<\/teammate-message>/g)].flatMap(match => {
405 const attributes = match[1]
406 const body = match[2]
407 return attributes !== undefined && body !== undefined ? [frame(body, attribute(attributes, 'from') ?? attribute(attributes, 'teammate_id'), attribute(attributes, 'summary'))] : []
408 })
409}
410/** §8.3 source 2: frames read from a row's own text (a row stored before this load). */
411export function framesFromText(text: string): readonly Frame[] {
412 if (!text.split('\n').some(line => statusObject(line) !== undefined)) return []
413 const frames: Frame[] = []
414 let plain: string[] = []
415 const flush = () => {
416 const body = plain.join('\n').trim()
417 if (body) frames.push(frame(body, undefined, body.split('\n')[0]))
418 plain = []
419 }
420 for (const line of text.split('\n')) {
421 const status = statusObject(line)
422 if (status) {
423 flush()
424 frames.push(frame(line))
425 } else plain.push(line)
426 }
427 flush()
428 return frames
429}
430
431function statusObject(line: string): Record<string, unknown> | undefined {
432 const value = jsonObject(line.trim())
433 return value && [value.type, value.status, value.verdict, value.result].some(value => string(value)) ? value : undefined
434}
435
436function frame(body: string, from?: string, summary?: string): Frame {
437 let relays = false
438 const text = body.split('\n').filter(line => {
439 const marker = /^\s*<[^>]+\/>\s*$/.test(line) || /This agent read .*written by people other than you/.test(line)
440 relays ||= marker
441 return !marker
442 }).join('\n').trim()
443 const whole = jsonObject(text)
444 const rawTime = whole?.timestamp
445 const at = typeof rawTime === 'number' ? rawTime : typeof rawTime === 'string' && Number.isFinite(Date.parse(rawTime)) ? Date.parse(rawTime) : undefined
446 const lead = reportLead(text)
447 const name = from ?? string(whole?.from) ?? string(whole?.teammate_id)
448 return { from: name ? clean(name) || undefined : undefined, state: stateWords(whole?.type), at, summary: summary ?? string(whole?.summary), relays, lead: whole?.type && !lead?.card ? undefined : lead }
449}
450
451function reportLead(text: string): Frame['lead'] {
452 if (!text) return undefined
453 if (text.startsWith('[')) {
454 try {
455 const value: unknown = JSON.parse(text)
456 if (Array.isArray(value)) return { text: `${value.length} items`, more: 0 }
457 } catch {
458 return { text: 'a structured report', more: 0 }
459 }
460 }
461 const opening = text.indexOf('{')
462 const object = opening >= 0 ? jsonObject(text.slice(opening)) : undefined
463 const sentence = opening > 0 ? text.slice(0, opening).trim() : undefined
464 const card = object ? reportCard(object, sentence) : undefined
465 if (card) return { ...(sentence ? { sentence } : {}), card, more: 0 }
466 if (opening === 0) return { text: 'a structured report', more: 0 }
467 const remaining = sentence ?? text
468 const paragraph = remaining.split(/\n\s*\n/)[0] ?? ''
469 const lead = paragraph.split('\n').slice(0, 6).join('\n').slice(0, 600)
470 return { text: lead, more: Math.max(0, remaining.split('\n').filter(line => line.trim()).length - lead.split('\n').filter(line => line.trim()).length) }
471}
472
473function reportTone(value: unknown): Card['tone'] {
474 const word = string(value)?.toLowerCase() ?? ''
475 if (/^(?:done|pass|passed|ok|success|succeeded|green|complete|completed|confirms?)$/.test(word)) return 'ok'
476 if (/^(?:fail|failed|error|blocked|red|contradicts|refused)$/.test(word)) return 'bad'
477 return 'mid'
478}
479
480function reportCard(object: Record<string, unknown>, sentence?: string): Card | undefined {
481 const status = string(object.status) ?? string(object.verdict) ?? string(object.result)
482 const entries = Object.entries(object)
483 const target = Object.values(object).find(value => record(value) && (string(value.name) || string(value.title) || string(value.id)))
484 const arrays = entries.filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1]))
485 const report = entries.find(([key, value]) => typeof value === 'string' && (/(?:_file|_path|report|dossier)$/.test(key) || /^\//.test(value)))?.[1]
486 if (!status && !target && !arrays.length && !report) return undefined
487 const scores: Card['scores'][number][] = []
488 const lists: Card['lists'][number][] = []
489 for (const [label, items] of arrays) {
490 const tones = items.map(item => record(item) ? reportTone(item.verdict ?? item.status ?? item.result) : 'mid')
491 if (tones.some(tone => tone !== 'mid')) scores.push({ label, dots: tones.slice(0, 24), pass: tones.filter(tone => tone === 'ok').length, of: items.length })
492 else {
493 const first = items[0]
494 const words = string(first) ?? (record(first) ? string(first.name) ?? string(first.title) ?? string(first.description) : undefined) ?? (first === undefined ? '' : JSON.stringify(first))
495 lists.push({ label, count: items.length, first: words })
496 }
497 }
498 const targetWords = record(target) ? [string(target.name) ?? string(target.title) ?? string(target.id), string(target.model), string(target.profile), typeof target.temperature === 'number' ? `temp ${target.temperature}` : undefined].filter(Boolean).join(' · ') : undefined
499 return { tone: reportTone(status), status: (status ?? '').replace(/[_-]/g, ' ').slice(0, 40), sub: string(object.review_status) ?? string(object.summary) ?? string(object.message) ?? (object.result !== status ? string(object.result) : undefined) ?? (!status && sentence && sentence.length > 8 ? sentence : undefined), target: targetWords, scores, lists, report: typeof report === 'string' ? basename(report) : undefined }
500}
501
502/** §9.1 test words: test, tests, suite, spec, specs, e2e, verify, check. */
503export function looksLikeTest(words: string): boolean {
504 return /\b(?:tests?|suite|specs?|e2e|verify|check)\b/i.test(words)
505}
506
507/** §8.1 #3: text whose first line starts with a hook name and reaches a colon; the quiet line it draws. */
508export function hookTextLine(text: string): string | undefined {
509 if (!/^(?:Stop hook feedback|UserPromptSubmit|PostToolUse|PreToolUse)[^\n]*:/.test(text)) return undefined
510 return (text.split('\n').map(line => line.trim()).find(line => line && !/(?:feedback|context):$/.test(line) && !/^(?:Stop hook|UserPromptSubmit)/.test(line)) ?? 'hook feedback').slice(0, 110)
511}
512
513/**
514 * Whether a reply may carry its turn's steps line (§4.4), from the reply's
515 * whole stored text alone (finding 6). False when the text holds a mermaid
516 * fence with a non-blank body: cs-mermaid, when it is the outer hook, calls next once per prose part
517 * and not at all for a diagram-only reply (mods/cs-mermaid/hooks/register.tsx
518 * 195-197), so no single drawing of ours would own the line. A function of
519 * the text, not of what this load saw drawn, so the tool row and the reply row
520 * decide the same in either plugin order.
521 *
522 * The fence scan is cs-mermaid's rule (`scanFences`: a ```mermaid line, outer
523 * ```` or ~~~ blocks quote it), duplicated: plugins do not import each other,
524 * and the test kit refuses an import from another plugin's folder. cs-mermaid
525 * draws the reply only when a closed fence has a non-blank body (`fencesOf`
526 * drops blank ones), so a blank fence leaves the line to the reply. A test pins
527 * the answers cs-mermaid's own fence fixtures give, and blank fences.
528 */
529export function repliesOwnSteps(text: string): boolean {
530 let outer: string | undefined
531 let mermaid: { body: boolean } | undefined
532 for (const line of text.split('\n')) {
533 const trimmed = line.trim()
534 if (mermaid) {
535 if (trimmed === '```') {
536 if (mermaid.body) return false
537 mermaid = undefined
538 } else if (trimmed) mermaid.body = true
539 continue
540 }
541 const mark = /^(`{3,}|~{3,})/.exec(trimmed)?.[1]
542 if (!mark) continue
543 if (outer) {
544 if (mark[0] === outer[0] && mark.length >= outer.length && trimmed === mark) outer = undefined
545 } else if (mark === '```' && trimmed.slice(3).trim() === 'mermaid') mermaid = { body: false }
546 else if (mark.length > 3 || mark.startsWith('~')) outer = mark
547 }
548 return true
549}
550hooks/state.d.ts 8 lines1// ABOUTME: cs-feed's $.state names: three redraw tokens, each a counter that holds no fact (see session.ts). The per-session on/off is a file, not state.
2// ABOUTME: Declared once here so the atoms are typed; KEEP IN SYNC with the atom() calls in session.ts.
3declare module 'claude-code' {
4 interface PluginState {
5 'cs-feed': { live: number; tick: number; pulse: number }
6 }
7}
8