SLOPSHOPPER

cs-feed

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…

newpanebandspinnerrowscommand
★ 51v0.1.0MITupdated 2026-10-25hex/claude-sessions/mods/cs-feed
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cs-feed
│ ┃ Chat view ✕ › fix the failing auth test and add an audit log call │ ┃ Chat view raw · /feed clean · /feed normal · │ ┃ /feed close ⏺ Read(src/auth.ts) │ ┃ [ Feed ] off ⎿ Read 6 lines │ ┃ [ Group a turn into one line ] on · /feed gr ⏺ Update(src/auth.ts) │ ┃ you fix the login bug ⎿ Added 2 lines, removed 1 line │ ┃ 6 steps this turn › ⏺ Bash(bun test) │ ┃ claude Fixed: the token now refreshes. ⎿ 3 pass, 1 fail │ ┃ [ Clear after the turn ] on · /feed clear of │ ┃ a finished turn keeps only its one line ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ (grouping… │ ┃ [ Keep every card ] off · /feed keep on ✻ Worked for 42s · done 4:20 PM │ ┃ "clear after the turn" decides │ ┃ [ Card style ] subtle · /feed style line › /feed │ ┃ ○ Ran 2 shell commands +16 lines › ⎿ cs-feed: Chat view: raw. Settings are open above the prompt; /fe │ ┃ [ Commands in the chat ] grouped · /feed com │ ┃ ○ Ran 3 shell commands · read 1 file │ ┃ [ After a command finishes ] clear · /feed a │ ┃ a finished turn keeps the run line, not │ ┃ each command │ ┃ Show │ ┃ [ ● you ] [ ● said ] [ ● tool ] [ ● agent ] │ ┃ /feed hide <kind> · /feed show <kind> │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chat view
Chat view raw · /feed clean · /feed normal · /feed raw · /fe [ Feed ] off [ Group a turn into one line ] on · /feed group off you fix the login bug 6 steps this turn › claude Fixed: the token now refreshes. [ Clear after the turn ] on · /feed clear off a finished turn keeps only its one line (grouping… [ Keep every card ] off · /feed keep on "clear after the turn" decides [ Card style ] subtle · /feed style line ○ Ran 2 shell commands +16 lines › [ Commands in the chat ] grouped · /feed commands each ○ Ran 3 shell commands · read 1 file [ After a command finishes ] clear · /feed after keep a finished turn keeps the run line, not each command Show [ ● you ] [ ● said ] [ ● tool ] [ ● agent ] [ ● question ] [ /feed hide <kind> · /feed show <kind>
README

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

Test

Install

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.

Usage

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.

First run in 60 seconds

  1. Run cs debug-api. cs creates a workspace and opens Claude Code.
  2. Describe your task. cs records the first substantive prompt in the session's Objective section and keeps notes under .cs/.
  3. Exit Claude Code. Run 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.

Session creation banner, picker with demo focused, and picker with demo marked

cs statusline cycling through quiet, context warning, critical context, queued tasks and unread mail, and quota reset countdowns

The statusline guide covers the quiet default layout, the band and the capsules, colors, thresholds, font setup, and agent rows.

Features

FeatureWhat it doesDetails
Session recordsKeeps objectives, per-actor narratives, memory, plans, checkpoints, and a timeline in .cs/.Session layout
Crash recoverySaves edits to shadow git refs without adding normal branch commits.Autosave hook
Conversation rotationHands work to a fresh conversation through /rotate and /clear.Rotation and queue hooks
Existing projectscs -adopt <name> registers your project without moving it.Adoption
Parallel worktreescs <base>@<feature> gives each feature its own checkout and session records.Worktrees
Remote controlcs <name> --remote serves a session for the Claude app or web.Remote control
Task queueRuns confirmed tasks at turn boundaries, with failure, context, and quota breakers.Queue behavior
Cross-session mailDelivers local messages and wakes idle sessions for unread arrivals.Mail wakes
SecretsUses the macOS keychain or encrypted files, with signed age exports for sync.Secrets guide
Encrypted sessionsKeeps notes, transcripts, plans, and private state in a vault on macOS.Encrypted sessions
StatuslineShows session, git, model, context, queue, mail, and rate limits.Statusline guide
Prompt groundingAdds matching files, recent commits, changes, and clarification guidance to code-work prompts.Scope hook
Prompt rewritingCtrl+G rewrites a draft in the composer for you to review before sending.Prompt rewriter
Writing skillsJudges 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 MermaidDraws 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 feedFolds 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 notesShows pending release notes in-session through /cs-update.Update mod
Other front endsSession hooks also work in desktop and IDE conversations opened in a .cs/ workspace.Session resolution

Uninstall

cs -uninstall

Documentation

GuideContents
CommandsCommand reference, adoption, worktrees, queues, remote control, and completion.
ConfigurationEvery environment variable cs reads and the values it exports.
Session layoutShared and local records, encryption, and merge policy.
HooksHook behavior, prompt rewriting, and Claude Code mods.
SecretsStorage backends, backups, and signed sync files.
StatuslineSegments, the quiet, band and capsule layouts, colors, thresholds, and agent rows.

License

MIT. Contributions can start with an issue or pull request.

Source 8 files
hooks/register.tsx 567 lines
1// 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}
567
hooks/session.ts 1022 lines
1// 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}
1022
hooks/settings.ts 263 lines
1// 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}
263
hooks/verdict.ts 572 lines
1// 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}
572
hooks/transcript.ts 427 lines
1// 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}
427
hooks/draw.tsx 184 lines
1// 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}
184
hooks/words.ts 550 lines
1// 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}
550
hooks/state.d.ts 8 lines
1// 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