SLOPSHOPPER

flow-session

Flow's session mod: restarts a long session in place, loads dragged files and handoff notes, warns the agent at the wrap-up mark, and draws the band of tickets…

bandspinnerguardcommandprompttool
★ 36v0.1.0MITupdated 2026-10-26Adrian333Dev/flow/mods/flow-session
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flow-session
› fix the failing auth test and add an audit log call ● flow-session: restart guide not found at prompts/restart-guide.md ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /restart ⎿ flow-session: Restarting: the agent writes its note. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › opus 5.5 │ context 97k/170k │ 5h 31%

Draws

Prompt hint
opus 5.5 │ context 97k/170k │ 5h 31%
README

Flow

Flow is a personal workflow for Claude Code, built for its author's own work and shared as is. It has no support, no promise to stay stable, and no instructions for anyone else. Read it, copy from it, or fork it.

It runs a solo developer's work in 3 phases: design the decisions, build one ticket at a time, and file what each session taught back into its rules and skills.

What the repository holds

  • home/: the templates for the global rule file and Claude Code's settings
  • skills/: the /flow:* skills, one per phase and tool
  • scripts/: the flow command and its hooks, in Node and bash
  • mods/flow-session/: Flow's Claude Code mod, in TypeScript: the ticket badges, the meters under the prompt and the restart tool
  • project-template/: the files flow init writes into a project
  • docs/spec/: what each part of Flow does, and why
  • docs/manual/: every command, file, setting and skill, and how a session restarts
  • lab/: the maintainers' workspace, with the archive of retired code

Install

curl -fsSL https://raw.githubusercontent.com/Adrian333Dev/flow/main/install.sh | bash

The script clones Flow into ~/.flow/repos/flow, then runs flow install. It needs Claude Code 2.1.287 or later, git, node and gh. Built and used on Linux, through WSL, with Claude's 5.5 models.

To update, run flow update. It pulls the clone, then runs flow install again.

The command reference lists every flow command.

Source 29 files
hooks/register.ts 636 lines
1/**
2 * register.ts: the hooks module of Flow's session mod.
3 *
4 * The restart: at its wrap-up checkpoint, or when the user types `/restart`,
5 * the agent calls the `restart` tool with its note and the files and skills to
6 * load. The mod ends that turn, compacts between turns, and answers the
7 * compaction itself, so the session and its transcript stay and no model
8 * writes a summary. The answer is built by hooks/window.ts: the tickets with
9 * the `context.md` sections of their open questions, and the files and skills,
10 * loaded as `Read` and `Skill` calls, the note, and the user's messages it did
11 * not answer, each whole. The old turns stay in the transcript. A typed
12 * `/restart` stops after the compaction. An automatic one submits `CONTINUE`.
13 *
14 * `/copy-v2` (Alt+C in `~/.claude/keybindings.json`) copies the agent's last message, `/copy-all` (Alt+A)
15 * everything since the user's last message, and each turn's end saves that to `.flow/replies/` (hooks/copy.ts).
16 *
17 * `/keepwarm` (hooks/keepwarm.ts) pings the prompt cache before it expires, for one session at a time, and stops at the first ping that misses.
18 *
19 * Also: dragged files load as mentions, the agent is warned at the wrap-up
20 * mark, `#` in the prompt box lists the notes of `flow notes`, and every
21 * permission ask opens Claude Code's question
22 * dialog (hooks/pane.ts) and is logged (hooks/log.ts).
23 *
24 * The band above the prompt, one badge for each ticket the session works and a
25 * Restart button between turns past the wrap-up mark (hooks/button.ts), is hooks/band.tsx,
26 * registered from here.
27 */
28import { atom, read, update } from 'claude-code'
29import type { EngineInterface, Register, TurnStepChunk } from 'claude-code'
30import type { Entry, Restart, Tracked } from '../types'
31import { restartAsk } from './ask'
32import { restartGuide } from './restart-guide'
33import { pressed, pressLost, restartEnded, turnEnded, turnStarted } from './button'
34import { copyAll, copyLast, saveReply } from './copy'
35import { CONTINUE } from './dialogue'
36import { addAsked, afterAnswer } from './first-message'
37import { buildWindow, resolveLoads } from './window'
38import { mention, pastedPaths, relativeTo } from './drag'
39import { bandMock, bandShell, bandStart, bandToolCall, bandTurnComplete, bandTurnStart, drawBand } from './band'
40import { closeWarm, keepWarmCommand, cancel as warmCancel, noteActive, noteBroken, noteTranscript, tick as warmTick, type Deps as WarmDeps } from './keepwarm'
41import { drawMeters, metersClear, metersMeasure, metersRead, metersRefresh, metersStart, metersSwitch, metersWarmed } from './meters-row'
42import { guardedSentence, headline } from './headline'
43import { appendCommand, logLine, logPath, sourceOf, type LogAnswer, type LogFields } from './log'
44import { parseNote, rows, ticketPrefix, type Note } from './notes'
45import {
46  classify,
47  cover,
48  denyFor,
49  guardedWrite,
50  isAutoAllow,
51  isCovered,
52  nobodyReason,
53  storeVerdict,
54  takeVerdict,
55  verdictKey,
56} from './pane'
57import { steer, type Streamed } from './situations'
58import { heldBy, linksOf, wikiText, withWiki } from './skill-wiki'
59import { AGENT_LINES, kilo, sizeOf, wrapUpLine, wrapUpSettings } from './wrap-up'
60
61const RESTART_TOOL = 'mcp__flow-session__restart'
62const TOOL = new RegExp(`^${RESTART_TOOL}$`)
63const REFUSAL = 'Flow compacts only at its own restart. Run /restart.'
64
65let sizeBefore: number | undefined
66// Why the compaction was skipped, when the window could not be built. The hook sets it, `runRestart` reads it.
67let stopped: string | undefined
68let lastChecked = 0
69// What the restart keeps word for word, the turn it ends and the restart itself. The host holds it: a hot reload empties module variables.
70// The validator reads an atom only where it is written, so meters-row.tsx spells this one again for the band's Restart button.
71const tracked = atom({ plugin: 'flow-session', key: 'tracked' } as const, { asked: [] } as Tracked)
72// The band's badges, spelled again here to read the tickets in flight (hooks/band.tsx writes them).
73const band = atom({ plugin: 'flow-session', key: 'band' } as const, [] as Entry[])
74// The helper agents that run now (their `turn.start` seen, their `turn.complete` not). A restart waits for them.
75const helpers = new Set<string>()
76// Each helper agent's context size at its last request, and at the last check that could send a line. A subagent's size is its own.
77const agentSizes = new Map<string, number>()
78const agentChecked = new Map<string, number>()
79// The `#` list runs on every keystroke, so a note file is parsed again only when its change time moves.
80const listedNotes = new Map<string, Note>()
81// Whether a person is at the prompt (false for `claude -p`). Unknown after a hot reload, which counts as somebody.
82let interactive: boolean | undefined
83// The header each call steered by hooks/situations.ts gets on its result, by call id, taken once the result is in.
84const guideHeaders = new Map<string, string>()
85/** The agents whose `Skill` call is running, last on top: `skill.prompt` names no agent, and fires inside the call. */
86const skillCallers: (string | undefined)[] = []
87
88/** Prints the size change once the new conversation has a response: `usage()` has no figure before that. */
89async function logSize($: EngineInterface): Promise<void> {
90  const after = (await $.session.usage().catch(() => undefined))?.context.tokens
91  if (after === undefined || sizeBefore === undefined) return
92  // The engine puts `flow-session:` in front of a log line, so no colon of our own.
93  $.ui.log(`context ${kilo(sizeBefore)} → ${kilo(after)} after restart`)
94  sizeBefore = undefined
95}
96
97/** The note the agent passed, trimmed. */
98function readMessage(e: object): string {
99  const input = e as { message?: unknown }
100  return typeof input.message === 'string' ? input.message.trim() : ''
101}
102
103/** A list of strings the agent passed (`files`, `skills`), or undefined where it is not one. */
104function readList(e: object, key: 'files' | 'skills'): string[] | undefined {
105  const value = (e as Record<string, unknown>)[key]
106  if (value === undefined) return []
107  return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.trim() !== '') ? value.map((item: string) => item.trim()) : undefined
108}
109
110/** A settings file's text, or undefined when it is missing or unreadable. */
111const readText = ($: EngineInterface, file: string) => $.fs.read(file).catch(() => undefined)
112
113/** The wiki text of a skill's links, for the window of the agent that called it. */
114async function skillWiki($: EngineInterface, links: Parameters<typeof wikiText>[0], agentId: string | undefined): Promise<string> {
115  return wikiText(links, await flowHome($), await heldBy($, agentId), (path) => readText($, path))
116}
117
118/** The Flow home: `FLOW_HOME`, else `~/.flow`. */
119const flowHome = async ($: EngineInterface) => (await $.env.get('FLOW_HOME')) ?? `${await $.env.get('HOME')}/.flow`
120
121/** True while a keep-warm ping must wait: a main turn, a restart or a helper agent runs, or nobody is at the prompt. */
122async function warmBusy($: EngineInterface): Promise<boolean> {
123  if (interactive === false || helpers.size > 0) return true
124  const t = await read($, tracked)
125  return t.turn !== undefined || t.restart !== undefined || t.requested !== undefined
126}
127
128const warmDeps = ($: EngineInterface): WarmDeps => ({ busy: () => warmBusy($), warmed: (coldAt) => metersWarmed($, coldAt) })
129
130/** The wrap-up line for the size now, or undefined. Remembers the size, so each mark speaks once. */
131async function wrapUp($: EngineInterface): Promise<string | undefined> {
132  const home = await flowHome($)
133  const { wrapUp: on, limit } = wrapUpSettings(
134    await readText($, `${home}/settings.json`),
135    await readText($, `${home}/settings.local.json`),
136  )
137  const now = (await $.session.usage().catch(() => undefined))?.context.tokens
138  if (!on || now === undefined) return undefined
139  const before = lastChecked
140  lastChecked = now
141  const line = wrapUpLine(before, now, limit)
142  if (!line) return undefined
143  $.ui.log(`wrap-up line sent at ${kilo(now)}`)
144  return `${line}\n\n${await restartGuide($)}`
145}
146
147/**
148 * The wrap-up line for one subagent at its own size, or undefined. A subagent cannot restart
149 * (`$.turn.abort` and `$.session.compact` act on the main conversation alone), so its line tells it
150 * to save its state to files and finish. Each mark speaks once per agent.
151 */
152async function agentWrapUp($: EngineInterface, id: string): Promise<string | undefined> {
153  const home = await flowHome($)
154  const { wrapUp: on, limit } = wrapUpSettings(
155    await readText($, `${home}/settings.json`),
156    await readText($, `${home}/settings.local.json`),
157  )
158  const now = agentSizes.get(id)
159  if (!on || now === undefined) return undefined
160  const before = agentChecked.get(id) ?? 0
161  agentChecked.set(id, now)
162  const line = wrapUpLine(before, now, limit, AGENT_LINES)
163  if (line) $.ui.log(`wrap-up line sent to a subagent at ${kilo(now)}`)
164  return line
165}
166
167/** A paste of paths that all exist under the session's folder, as mentions. Undefined for any other edit. */
168async function mentionsFor($: EngineInterface, pasted: string): Promise<string | undefined> {
169  const paths = pastedPaths(pasted)
170  if (!paths) return undefined
171  const root = await $.session.root()
172  const parts: string[] = []
173  for (const path of paths) {
174    const relative = relativeTo(root, path)
175    if (relative === undefined || !(await $.fs.exists(path))) return undefined
176    parts.push(mention(relative))
177  }
178  return `${parts.join(' ')} `
179}
180
181/** Every note in `<home>/notes/`, each parsed once per change. */
182async function readNotes($: EngineInterface, home: string): Promise<Note[]> {
183  const dir = `${home}/notes`
184  const entries = await $.fs.list(dir).catch(() => [])
185  const found = await Promise.all(
186    entries
187      .filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md') && !entry.name.startsWith('.'))
188      .map(async (entry) => {
189        const path = `${dir}/${entry.name}`
190        const kept = listedNotes.get(path)
191        if (kept?.changed === entry.mtimeMs) return kept
192        const raw = await readText($, path)
193        if (raw === undefined) return undefined
194        const note = parseNote(path, entry.mtimeMs, raw)
195        listedNotes.set(path, note)
196        return note
197      }),
198  )
199  return found.filter((note): note is Note => note !== undefined)
200}
201
202/** The project name the ask log records: the session folder's name, `global` in the Flow home. */
203async function projectName($: EngineInterface, home: string): Promise<string> {
204  const root = (await $.session.root()).replace(/\/+$/, '')
205  return root === home || `${root}/.flow` === home ? 'global' : root.slice(root.lastIndexOf('/') + 1)
206}
207
208/**
209 * The word the notes of this session's project carry in `project:`: its ticket
210 * prefix, `home` in the Flow home. A folder with no prefix falls back to its
211 * name, which matches no note.
212 */
213async function noteProject($: EngineInterface, home: string): Promise<string> {
214  const root = (await $.session.root()).replace(/\/+$/, '')
215  if (root === home || `${root}/.flow` === home) return 'home'
216  return ticketPrefix(await readText($, `${root}/.flow/settings.json`)) ?? root.slice(root.lastIndexOf('/') + 1)
217}
218
219/** True for a message the user typed, not the mod's own prompt, a notification or the /restart command. */
220const usersOwn = (e: { origin?: { kind: string }; isMeta?: true; text: string }) =>
221  !e.isMeta && (e.origin === undefined || e.origin.kind === 'composer' || e.origin.kind === 'bridge') && !/^\/restart\b/.test(e.text)
222
223/**
224 * Asks the agent for its note: what `/restart` does and what a press of the band's Restart button
225 * does, one prompt and one model request. A command's hidden `context` starts no turn (probed
226 * 2.1.301), so the ask is submitted as a prompt once the caller has returned. A press that is
227 * already waiting on the agent asks nothing more. The ask lost, the button comes back.
228 */
229async function askForRestart($: EngineInterface, args: string): Promise<void> {
230  if ((await read($, tracked)).requested) return
231  const ask = restartAsk(args, await restartGuide($))
232  await update($, tracked, pressed)
233  void $.clock
234    .sleep(100)
235    .then(() => $.prompt.submit({ text: ask, asUser: true }))
236    .catch(async (error) => {
237      await update($, tracked, pressLost).catch(() => undefined)
238      $.ui.log(`/restart could not start the agent: ${error instanceof Error ? error.message : String(error)}`)
239    })
240}
241
242/**
243 * Starts the compaction, then the turn that works from the note.
244 * The note is in the compacted conversation by then, so a crash before the
245 * turn loses nothing. A typed restart starts no turn: the user writes next.
246 * Not awaited by its caller, so it owns its errors.
247 */
248async function runRestart($: EngineInterface, restart: Restart): Promise<void> {
249  await update($, tracked, (t) => ({ ...t, restart: { ...restart, started: true } }))
250  await metersClear($).catch(() => undefined)
251  // The compacted conversation has no cached prefix: a ping now would pay for all of it.
252  await noteBroken($).catch(() => undefined)
253  try {
254    stopped = undefined
255    await $.command.run({ command: 'compact' })
256    if (stopped) {
257      $.ui.log(stopped)
258    } else {
259      sizeBefore = restart.size
260      if (restart.manual) $.ui.log('restarted. Type your next message.')
261      else await $.prompt.submit({ text: CONTINUE, asUser: true })
262    }
263  } catch (error) {
264    $.ui.log(`restart failed, the session is as it was: ${error instanceof Error ? error.message : String(error)}`)
265  } finally {
266    await update($, tracked, restartEnded).catch(() => undefined)
267  }
268}
269
270const reasonOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
271
272type Check = { tool: string; input: unknown; agentId?: string }
273type Decided = { decision: 'allow' | 'ask' | 'deny'; reason?: string; hook?: string; rule?: string }
274
275/** One line in the permission log. Never throws: a lost line must not change the answer. */
276async function logAsk($: EngineInterface, fields: Omit<LogFields, 'at' | 'project' | 'session'>): Promise<void> {
277  try {
278    const home = await flowHome($)
279    const now = new Date()
280    const session = await $.session.id().catch(() => '')
281    const line = logLine({ ...fields, at: now, project: await projectName($, home), session })
282    await $.process.run(appendCommand(line, logPath(home, now)), { timeoutMs: 5000 })
283  } catch (error) {
284    $.ui.log(`permission log line lost: ${reasonOf(error)}`)
285  }
286}
287
288/**
289 * A permission ask, put to the user in the question dialog. Allow returns an
290 * allow. A refusal, Esc or the dialog's fourth row stores a verdict and returns
291 * the ask unchanged, so `classic.PermissionRequest` can deny with an interrupt.
292 * A typed note stores a verdict without one. A dialog that fails for any other
293 * reason returns the ask unchanged with no verdict, so Claude Code asks itself.
294 * The question is the sentence alone: the tool's row above the dialog shows the command.
295 */
296async function askUser($: EngineInterface, e: Check, decided: Decided): Promise<Decided> {
297  const shown = headline(e.tool, e.input)
298  const guarded = guardedWrite(e.tool, e.input, decided)
299  const guardReason = decided.hook === 'PreToolUse' ? decided.reason?.trim() : undefined
300  const sentence = guarded !== undefined ? guardedSentence(e.tool, guarded) : guardReason || shown.sentence
301  const entry = { tool: e.tool, agent: e.agentId, rule: decided.rule, source: sourceOf(decided.hook, decided.rule), headline: sentence, command: shown.text }
302  const done = (answer: LogAnswer, ms?: number) => logAsk($, { ...entry, answer, ms })
303
304  if (isAutoAllow(e.tool, e.input, decided, e.agentId) || (guarded !== undefined && isCovered(guarded))) {
305    await done('auto-allow')
306    return { decision: 'allow' }
307  }
308  if (interactive === false) {
309    await done('nobody')
310    return { decision: 'deny', reason: nobodyReason() }
311  }
312
313  const started = Date.now()
314  let label: string | undefined
315  let failure = ''
316  try {
317    label = await $.ui.ask(sentence, { options: ['Allow', 'Refuse'], header: 'Permission' })
318  } catch (error) {
319    failure = reasonOf(error)  }
320  const ms = Date.now() - started
321
322  // Esc and the fourth row reject with "no answer (…)". Anything else is the dialog failing.
323  if (label === undefined && !/no answer/i.test(failure)) {
324    $.ui.log(`permission dialog failed, Claude Code asks instead: ${failure}`)
325    return decided
326  }
327  const answer = label === undefined ? 'refuse' : classify(label)
328  const key = verdictKey(e.tool, e.input)
329  if (answer === 'allow') {
330    takeVerdict(key)
331    if (guarded !== undefined) cover(guarded)
332    await done('allow', ms)
333    return { decision: 'allow' }
334  }
335  storeVerdict(key, answer === 'note' ? { interrupt: false, note: label ?? '' } : { interrupt: true })
336  await done(answer, ms)
337  return decided
338}
339
340export const register: Register = (on) => {
341  on('session.start', async ($, e, next) => {
342    interactive = e.isInteractive
343    await bandStart($)
344    await metersStart($).catch(() => undefined)
345    // A hot reload loses the timer, so a switch that is on plans again.
346    void warmTick($, warmDeps($))
347    await $.tool.register({
348      name: 'restart',
349      description: 'Restart this session, as the restart guide sent with the ask says. Call it last. A subagent never restarts: it saves its state to files and begins its reply with `UNFINISHED:`.',
350      isDeferred: false,
351      inputSchema: {
352        type: 'object',
353        properties: {
354          message: {
355            type: 'string',
356            description: 'Your note to the new window, in the order the restart guide gives.',
357          },
358          files: {
359            type: 'array',
360            items: { type: 'string' },
361            description: 'Files the next step works on: a path (whole file), `path#heading` (a markdown section), `path:from-to` (lines). A miss loads the whole file. With skills, 80 KB at most.',
362          },
363          skills: {
364            type: 'array',
365            items: { type: 'string' },
366            description: 'Skills the new window starts with, such as `flow:execute`.',
367          },
368        },
369        required: ['message'],
370      },
371    })
372    await $.command
373      .register({ name: 'restart', description: 'Restart this session in place, from a start message the agent writes.' })
374      .catch(() => undefined)
375    await $.command
376      .register({ name: 'copy-v2', description: 'Copy the agent’s last message, as markdown. Alt+C runs it.' })
377      .catch(() => undefined)
378    await $.command
379      .register({ name: 'copy-all', description: 'Copy every message the agent wrote since your last message, with a --- between them and no tool calls. Alt+A runs it.' })
380      .catch(() => undefined)
381    await $.command
382      .register({ name: 'keepwarm', description: 'Keep this session’s prompt cache warm while you are away: /keepwarm on, off, status or ping (one test ping now).' })
383      .catch(() => undefined)
384    await $.command
385      .register({ name: 'band', description: 'Switch the test band on or off: /band, /band <number>, next, list, act, off. /band clear empties the real band.' })
386      .catch(() => undefined)
387    return next(e)
388  })
389
390  // `/band` shows a test scenario in place of the real band (hooks/band-mock.ts).
391  on('command.run', { command: 'band' }, async ($, e) => ({ text: await bandMock($, e.args ?? '') })).catch(() => ({
392    text: 'The band command failed, so nothing changed.',
393  }))
394
395  // `/copy-v2` puts the agent's last message on the clipboard, `/copy-all` everything since the user's last message (hooks/copy.ts).
396  on('command.run', { command: 'copy-v2' }, async ($) => ({ text: await copyLast($) })).catch(() => ({
397    text: 'The copy command failed, so nothing was copied.',
398  }))
399  on('command.run', { command: 'copy-all' }, async ($) => ({ text: await copyAll($) })).catch(() => ({
400    text: 'The copy command failed, so nothing was copied.',
401  }))
402
403  // `/keepwarm` switches the cache ping on or off for this session (hooks/keepwarm.ts).
404  on('command.run', { command: 'keepwarm' }, async ($, e) => ({ text: await keepWarmCommand($, warmDeps($), e.args ?? '') })).catch(() => ({
405    text: 'The keepwarm command failed, so nothing changed.',
406  }))
407
408  // `/restart` asks the agent for its note.
409  on('command.run', { command: 'restart' }, async ($, e) => {
410    await askForRestart($, e.args ?? '')
411    return { text: 'Restarting: the agent writes its note.' }
412  }).catch(() => ({ text: 'The restart command failed, so nothing restarted.' }))
413
414  on('turn.start', async ($, e, next) => {
415    await bandTurnStart($, e)
416    if (e.agentId !== undefined) helpers.add(e.agentId)
417    if (e.agentId === undefined) {
418      // The user is back: no ping until this turn ends.
419      warmCancel()
420      await noteActive($).catch(() => undefined)
421      await update($, tracked, (t) => turnStarted(t, e.turnId))
422    }
423    return next(e)
424  })
425
426  // `#` and a word lists the notes of every project (hooks/notes.ts). The rows
427  // join beneath the engine's own, so a failure here shows no rows at all.
428  on('prompt.autocomplete', { token: /^#/ }, async ($, e, next) => {
429    const home = await flowHome($)
430    const found = rows(await readNotes($, home), e.token, await noteProject($, home))
431    const before = await next(e)
432    return { suggestions: [...before.suggestions, ...found] }
433  }).catch(() => ({ suggestions: [] }))
434
435  // A dragged file arrives as one paste. Rewritten here, while the text is in
436  // the box, it loads as a typed `@` does.
437  on('prompt.edit', async ($, e, next) => {
438    const mentions = await mentionsFor($, e.inputText).catch(() => undefined)
439    return next(mentions ? { ...e, inputText: mentions } : e)
440  })
441
442  // The warning rides on the next message the user types, or on a tool result
443  // when a long turn has no message in it. A subagent's size is its own: it is
444  // measured at each request (`turn.step`) and its line is sent to it as a message.
445  on('prompt.submit', async ($, e, next) => {
446    if (usersOwn(e)) await update($, tracked, (t) => ({ ...t, asked: addAsked(t.asked, e.text) }))
447    const line = await wrapUp($).catch(() => undefined)
448    return next(line ? { ...e, context: [...(e.context ?? []), line] } : e)
449  })
450
451  on('tool.call', async ($, e, next) => {
452    if (e.agentId === undefined) void metersRefresh($).catch(() => undefined)
453    const steered = await bandToolCall($, e, () => next(e))
454    const header = guideHeaders.get(e.tool_use_id)
455    guideHeaders.delete(e.tool_use_id)
456    const done = header && steered.result !== undefined ? { ...steered, context: [header, ...(steered.context ?? [])] } : steered
457    if (TOOL.test(e.tool) || done.result === undefined) return done
458    if (e.agentId !== undefined) {
459      // A subagent ignores a line inside a tool result as injected text (probed, Haiku): it goes as a message to the agent.
460      const line = await agentWrapUp($, e.agentId).catch(() => undefined)
461      if (line) await $.session.send({ to: { agentId: e.agentId }, text: line }).catch((error) => $.ui.log(`the wrap-up line to a subagent was not sent: ${reasonOf(error)}`))
462      return done
463    }
464    const line = await wrapUp($).catch(() => undefined)
465    return line ? { ...done, context: [...(done.context ?? []), line] } : done
466  })
467
468  // A skill's wiki folder rides after its text (hooks/skill-wiki.ts), tested against the window of
469  // the agent that called it. A typed skill has no `Skill` call: the main window.
470  on('tool.call', { tool: 'Skill' }, async (_$, e, next) => {
471    skillCallers.push(e.agentId)
472    try {
473      return await next(e)
474    } finally {
475      skillCallers.splice(skillCallers.lastIndexOf(e.agentId), 1)
476    }
477  })
478
479  on('skill.prompt', async ($, e, next) => {
480    const out = await next(e)
481    const links = linksOf(e.frontmatter)
482    if (!links.length) return out
483    const added = await skillWiki($, links, skillCallers.at(-1)).catch((error) => void $.ui.log(`wiki folder not sent: ${reasonOf(error)}`))
484    return added ? { ...out, text: withWiki(out.text, added) } : out
485  })
486
487  // A closed session stops warming, and a session opened again starts with the switch off.
488  on('session.end', async ($, e, next) => {
489    await closeWarm($).catch(() => warmCancel())
490    return next(e)
491  })
492
493  on('session.append', async ($, e, next) => {
494    const kept = await next(e)
495    await bandShell($, e).catch(() => undefined)
496    return kept
497  })
498
499  on('tool.call', { tool: TOOL }, async ($, e) => {
500    if (e.agentId !== undefined) return { deny: 'A subagent does not restart. Write what you learned to your report files and begin your reply with `UNFINISHED:` and their paths.' }
501    const { turn, restart } = await read($, tracked)
502    if (restart) return { deny: 'A restart is already under way.' }
503    if (helpers.size) return { deny: `${helpers.size === 1 ? 'A helper agent is' : `${helpers.size} helper agents are`} still running. Wait for ${helpers.size === 1 ? 'its' : 'their'} finished notice, then call restart again.` }
504    const message = readMessage(e)
505    if (!message) return { deny: 'Pass your note as `message`.' }
506    const files = readList(e, 'files')
507    const skills = readList(e, 'skills')
508    if (!files || !skills) return { deny: '`files` and `skills` are lists of non-empty strings.' }
509    // Read now, so a missing file or a load over the cap goes back to the agent while it can still fix it.
510    const loads = await resolveLoads($, files, skills)
511    if ('error' in loads) return { deny: `Nothing restarted: ${loads.error}.` }
512    const usage = await $.session.usage().catch(() => undefined)
513    const size = usage?.context.tokens ?? 0
514    await update($, tracked, (t) => ({ ...t, restart: { message, files, skills, size, started: false, manual: t.typed === true }, typed: false }))
515    const ended = turn ? await $.turn.abort({ turnId: turn }).then(() => true, () => false) : false
516    if (!ended) $.ui.log('restart: the turn could not be ended, so its closing reply is left out of the new conversation')
517    return { result: 'Restarting. Write nothing more.' }
518  }).catch(() => ({ deny: 'The restart hook failed, so nothing restarted.' }))
519
520  on('turn.complete', async ($, e, next) => {
521    const done = await next(e)
522    await bandTurnComplete($, e)
523    if (e.agentId !== undefined) {
524      helpers.delete(e.agentId)
525      agentSizes.delete(e.agentId)
526      agentChecked.delete(e.agentId)
527    }
528    if (e.agentId === undefined) {
529      void saveReply($).catch((error) => $.ui.log(`the reply was not saved: ${reasonOf(error)}`))
530      if (e.reason === 'answer') await update($, tracked, (t) => afterAnswer(t, e.answer, t.restart !== undefined))
531      await update($, tracked, (t) => turnEnded(t, e.turnId))
532      await noteActive($).catch(() => undefined)
533      void warmTick($, warmDeps($))
534      const { restart } = await read($, tracked)
535      if (restart && !restart.started) void runRestart($, restart)
536      else if (sizeBefore !== undefined) void logSize($).catch(() => undefined)
537    }
538    return done
539  })
540
541  // Every ask the rules and the guard leave open goes to the question dialog.
542  // A hook that throws is skipped, and Claude Code's own prompt appears.
543  on('tool.check', async ($, e, next) => {
544    const decided = await next(e)
545    return decided.decision === 'ask' ? askUser($, e, decided) : decided
546  }).catch(() => ({ decision: 'ask' as const }))
547
548  // The second half of a refusal: the call is denied, and a refusal also stops the turn.
549  on('classic.PermissionRequest', async (_$, e, next) => {
550    const verdict = takeVerdict(verdictKey(e.tool_name, e.tool_input))
551    return verdict ? { decision: denyFor(verdict) } : next(e)
552  }).catch(() => ({}))
553
554  // The band of tickets the session works (hooks/band.tsx).
555  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
556    drawBand($, e, next, () => void askForRestart($, '').catch((error) => $.ui.log(`the Restart button failed: ${reasonOf(error)}`))),
557  )
558
559  // The meters row (hooks/meters-row.tsx): figures from the session, the model, effort and cache from the transcript's end.
560  // During a restart the figure is the old conversation's, so the context stays hidden.
561  on('session.measure', async ($, e, next) => {
562    const { restart } = await read($, tracked)
563    await metersMeasure($, restart ? { ...e, context: { ...e.context, tokens: undefined } } : e).catch(() => undefined)
564    return next(e)
565  })
566  // Every request of the main loop refreshes the context, so a long turn shows its size as it grows.
567  // The guides sent by situation (hooks/situations.ts) steer the response: from its first tool call on,
568  // the chunks wait for the stop, so a write held back never leaves the chain, and the added calls go last.
569  on('turn.step', async function* ($, e, next) {
570    if (e.agentId === undefined) void metersRefresh($).catch(() => undefined)
571    const waiting: TurnStepChunk[] = []
572    const streamed = new Map<number, Streamed>()
573    let last = -1
574    for await (const c of next(e)) {
575      if ('index' in c && typeof c.index === 'number') last = Math.max(last, c.index)
576      if (c.kind === 'tool') streamed.set(c.index, { index: c.index, id: c.id, name: c.name, json: '' })
577      if (c.kind === 'input') {
578        const call = streamed.get(c.index)
579        if (call) call.json += c.json
580      }
581      if (c.kind !== 'stop') {
582        if (streamed.size) waiting.push(c)
583        else yield c
584        continue
585      }
586      if (e.agentId !== undefined && c.usage) agentSizes.set(e.agentId, sizeOf(c.usage))
587      const steering = streamed.size ? await steer($, e.agentId, [...streamed.values()]).catch((error) => void $.ui.log(`guides not sent: ${reasonOf(error)}`)) : undefined
588      // A held block's own chunks are dropped. The engine's opaque ones carry no block, and pass.
589      for (const w of waiting) if (w.kind === 'engine' || w.kind === 'stop' || !steering?.drop.has(w.index)) yield w
590      for (const [id, header] of steering?.headers ?? []) guideHeaders.set(id, header)
591      for (const [i, call] of (steering?.add ?? []).entries()) {
592        last += 1
593        yield { kind: 'tool', index: last, id: steering!.addedIds[i]!, name: call.name }
594        yield { kind: 'input', index: last, json: JSON.stringify(call.input) }
595      }
596      if (steering?.drop.size) $.ui.log(`held back ${steering.drop.size} calls until the guides are read`)
597      waiting.length = 0
598      yield c
599    }
600    // A response cut off before its stop passes on what it held, unsteered.
601    yield* waiting
602  })
603  on('classic.SessionStart', async ($, e, next) => {
604    await metersRead($, e.transcript_path).catch(() => undefined)
605    await noteTranscript($, e.transcript_path).catch(() => undefined)
606    return next(e)
607  })
608  on('classic.Stop', async ($, e, next) => {
609    await metersRead($, e.transcript_path).catch(() => undefined)
610    await noteTranscript($, e.transcript_path).catch(() => undefined)
611    return next(e)
612  })
613  on('classic.PostModelSwitch', async ($, e, next) => {
614    await metersSwitch($, e.to_model).catch(() => undefined)
615    await noteBroken($).catch(() => undefined)
616    return next(e)
617  })
618  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => drawMeters($, e, next))
619
620  // The restart's compaction becomes the window of hooks/window.ts. A dialogue file that cannot be written skips it.
621  on('session.compact', async ($, e, next) => {
622    if (e.agentId !== undefined) return next(e)
623    const { asked, restart } = await read($, tracked)
624    if (e.trigger !== 'manual' || !restart?.started) return { skip: REFUSAL }
625    let built: Awaited<ReturnType<typeof buildWindow>>
626    try {
627      built = await buildWindow($, { restart, asked, entries: await read($, band), home: await flowHome($) })
628    } catch (error) {
629      stopped = `Restart stopped, the session is as it was: ${reasonOf(error)}.`
630      return { skip: stopped }
631    }
632    $.ui.log(built.log)
633    return { messages: built.messages }
634  }).catch(() => ({ skip: REFUSAL }))
635}
636
hooks/ask.ts 15 lines
1/**
2 * ask.ts: what `/restart` and the band's Restart button tell the agent.
3 *
4 * One prompt with the restart guide, one model request: the agent answers with the `restart` call and its note.
5 * The prompt starts with `ASK_PREFIX`, so the next window leaves it out of the dialogue.
6 */
7import { ASK_PREFIX } from './dialogue'
8
9/** What `/restart` tells the agent. It never opens with `/`: the host runs a prompt that does as a command. */
10export function restartAsk(extra: string, guide: string): string {
11  const lines = [ASK_PREFIX]
12  if (extra.trim()) lines.push(`User: ${extra.trim()}`)
13  return [...lines, guide].join('\n\n')
14}
15
hooks/restart-guide.ts 31 lines
1/**
2 * restart-guide.ts: reads `prompts/restart-guide.md`, the guide sent with each ask for a restart
3 * (the wrap-up lines and `/restart`), so it reaches the agent just before it writes the note.
4 *
5 * The mod's folder comes from `CLAUDE_CODE_PLUGIN_DIRS`, the entry ending in `flow-session`.
6 */
7import type { EngineInterface } from 'claude-code'
8
9const FILE = 'prompts/restart-guide.md'
10
11/** The mod's folder in `CLAUDE_CODE_PLUGIN_DIRS` (one path, or several split by `,` or `:`), `~` expanded. */
12export function modFolder(dirs: string | undefined, home: string): string | undefined {
13  const found = (dirs ?? '')
14    .split(/[,:]/)
15    .map((dir) => dir.trim().replace(/\/+$/, ''))
16    .find((dir) => dir.endsWith('/flow-session') || dir === 'flow-session')
17  return found?.replace(/^~(?=\/|$)/, home)
18}
19
20/** The guide's text. A missing file still asks for the restart, and names the path it read. */
21export async function restartGuide($: EngineInterface): Promise<string> {
22  const dirs = await $.env.get('CLAUDE_CODE_PLUGIN_DIRS').catch(() => undefined)
23  const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
24  const folder = modFolder(dirs, home)
25  const path = folder ? `${folder}/${FILE}` : FILE
26  const text = folder ? await $.fs.read(path).catch(() => undefined) : undefined
27  if (text !== undefined) return text.trim()
28  $.ui.log(`restart guide not found at ${path}`)
29  return `The restart guide is missing (${path}). Update \`ticket.md\` and \`context.md\` with everything this window learned, then call \`restart\` with your note.`
30}
31
hooks/button.ts 48 lines
1/**
2 * button.ts: what hides the band's Restart button, and the steps of `Tracked` that set and clear it.
3 *
4 * The button shows only between turns. A press hides it at once and keeps it hidden until the
5 * restart ends, or until the turn the press started ends with no restart (Esc, a refusal), so one
6 * failed press never hides it for good. register.ts calls these from its hooks.
7 */
8import type { Tracked } from '../types'
9
10/** What keeps the button away: a turn running, a press waiting on the agent, a restart under way. */
11export type Busy = { running: boolean; requested: boolean; restarting: boolean }
12
13/** `turnRunning` is the band's own flag. `turn` outlives a hot reload, which empties the flag, so both count. */
14export const busyWith = (t: Tracked, turnRunning: boolean): Busy => ({
15  running: turnRunning || t.turn !== undefined,
16  requested: t.requested !== undefined,
17  restarting: t.restart !== undefined,
18})
19
20/** A press of the button or a typed /restart: the agent is asked, and the restart it makes is typed. */
21export const pressed = (t: Tracked): Tracked => ({ ...t, typed: true, requested: {} })
22
23/** The ask could not be sent: nothing waits on the agent. */
24export function pressLost(t: Tracked): Tracked {
25  const { requested: _, ...rest } = t
26  return { ...rest, typed: false }
27}
28
29/** A main turn began. The first one after a press is the turn that answers it. */
30export function turnStarted(t: Tracked, turnId: string): Tracked {
31  const next: Tracked = { ...t, turn: turnId }
32  if (t.requested && t.requested.turn === undefined) next.requested = { turn: turnId }
33  return next
34}
35
36/** A main turn ended. If it was the one that answered a press and began no restart, the press failed. */
37export function turnEnded(t: Tracked, turnId: string): Tracked {
38  if (t.turn !== turnId) return t
39  const { turn: _, ...rest } = t
40  return rest.requested?.turn === turnId && !rest.restart ? pressLost(rest) : rest
41}
42
43/** The restart is over, whether it finished or failed. */
44export function restartEnded(t: Tracked): Tracked {
45  const { restart: _, requested: __, ...rest } = t
46  return rest
47}
48
hooks/copy.ts 80 lines
1/**
2 * copy.ts: the agent's words as a copy, in 2 sizes.
3 *
4 * `/copy-v2` copies the agent's last message, as Claude Code's own `/copy` does. `/copy-all` copies
5 * every message of words the agent wrote since the user's last message, as raw markdown with a `---`
6 * line between them, so the user sees where one starts. Tool calls are left out. After each turn the
7 * same stretch is written to `.flow/replies/<session>-<n>.md` (n counts the user's messages in the
8 * window), so a reply that `/copy`'s one shared file would overwrite is still on disk. The newest
9 * `KEEP` files stay.
10 */
11import type { EngineInterface, SessionMessage } from 'claude-code'
12import { isUsersOwn } from './dialogue'
13
14/** How many saved replies a project keeps. */
15export const KEEP = 100
16
17/** Between the messages of the stretch. */
18export const SEPARATOR = '\n\n---\n\n'
19
20/** The agent's last message with words in it: what Claude Code's own `/copy` takes. */
21export function lastOf(messages: readonly SessionMessage[]): string {
22  const last = [...messages].reverse().find((m) => m.role === 'assistant' && m.text.trim() !== '')
23  return last?.text.trim() ?? ''
24}
25
26/** Every message of words the agent wrote since the user's last message of their own, and how many such messages the window holds. */
27export function stretchOf(messages: readonly SessionMessage[]): { text: string; users: number } {
28  let from = 0
29  let users = 0
30  messages.forEach((m, i) => {
31    if (!isUsersOwn(m)) return
32    from = i + 1
33    users++
34  })
35  const parts = messages
36    .slice(from)
37    .filter((m) => m.role === 'assistant')
38    .map((m) => m.text.trim())
39    .filter(Boolean)
40  return { text: parts.join(SEPARATOR), users }
41}
42
43/** The names past the newest `keep` markdown files, oldest last. */
44export function staleFiles(entries: readonly { name: string; kind: string; mtimeMs: number }[], keep: number): string[] {
45  return entries
46    .filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
47    .sort((a, b) => b.mtimeMs - a.mtimeMs)
48    .slice(keep)
49    .map((entry) => entry.name)
50}
51
52/** Writes the stretch to `.flow/replies/`, then removes the files past `KEEP`. */
53export async function saveReply($: EngineInterface): Promise<void> {
54  const { text: stretch, users } = stretchOf(await $.session.messages())
55  if (!stretch) return
56  const root = (await $.session.root()).replace(/\/+$/, '')
57  const session = ((await $.session.id().catch(() => '')) || 'session').slice(0, 8)
58  const dir = `${root}/.flow/replies`
59  await $.fs.write(`${dir}/${session}-${users}.md`, `${stretch}\n`)
60  for (const name of staleFiles(await $.fs.list(dir), KEEP)) await $.process.run(['rm', '--', `${dir}/${name}`]).catch(() => undefined)
61}
62
63/** Puts `words` on the clipboard and says how much, or why not. */
64async function put($: EngineInterface, words: string): Promise<string> {
65  if (!words) return 'Nothing to copy: the agent has said nothing yet.'
66  const done = await $.ui.copy({ text: words })
67  if (!done.isCopied) return `Not copied: ${done.reason}.`
68  return `Copied ${words.length} characters, ${words.split('\n').length} lines.`
69}
70
71/** What `/copy-v2` answers: the last message is on the clipboard, or why it is not. */
72export async function copyLast($: EngineInterface): Promise<string> {
73  return put($, lastOf(await $.session.messages()))
74}
75
76/** What `/copy-all` answers: the stretch is on the clipboard, or why it is not. */
77export async function copyAll($: EngineInterface): Promise<string> {
78  return put($, stretchOf(await $.session.messages()).text)
79}
80
hooks/dialogue.ts 37 lines
1/**
2 * dialogue.ts: tells the user's own messages from the text the mod and the host add.
3 *
4 * The prompts the mod submits and the messages it writes into a restarted window begin with the
5 * prefixes below, so neither `/copy-all` nor the restart counts them as the user's. Nothing here
6 * touches the host: no import but types.
7 */
8import type { SessionMessage } from 'claude-code'
9
10/** The prefix of the prompt the mod submits for `/restart` and the button. It is the mod's, never the user's. */
11export const ASK_PREFIX = 'Restart now:'
12/** The prompt the mod submits to start the turn after an automatic restart. */
13export const CONTINUE = 'Continue from the note.'
14/** How the first and the note's message of a restarted window begin. */
15export const OPENING = 'Session restarted.'
16export const NOTE_PREFIX = 'Note from the old agent:'
17/** The id prefix of a call the mod wrote into a window. */
18export const FAKE_ID = 'toolu_restart_'
19
20/** Text in a user message that is not the user's: the mod's own, a command's output, a skill's body, a notification. */
21const NOT_THE_USER = [
22  /^<(local-command-|command-name>|task-notification|system-reminder)/,
23  /^Base directory for this skill:/,
24  /^Launching skill:/,
25  /^Claude Code sent this note/,
26  /^Restarted at \d+k\./,
27]
28
29/** A message the user wrote: not a tool result, not the mod's prompt and not text the host added. */
30export function isUsersOwn(m: Pick<SessionMessage, 'role' | 'text' | 'toolResults'>): boolean {
31  if (m.role !== 'user' || m.toolResults?.length) return false
32  const text = m.text.trim()
33  if (!text || text === CONTINUE) return false
34  if ([OPENING, NOTE_PREFIX, ASK_PREFIX].some((prefix) => text.startsWith(prefix))) return false
35  return !NOT_THE_USER.some((pattern) => pattern.test(text))
36}
37
hooks/first-message.ts 103 lines
1/**
2 * first-message.ts: the messages a restarted session starts from.
3 *
4 * The mod builds the new window from the disk, never the old agent's memory of it: an opening of
5 * instructions, the tickets in flight with their `context.md`, what the agent chose to load, the
6 * agent's note, and the user's messages it never answered, each whole. The loads are `Read`
7 * and `Skill` calls with their results (hooks/loads.ts), so the terminal draws them collapsed and
8 * the agent edits without reading the files again.
9 */
10import type { Tracked } from '../types'
11import { NOTE_PREFIX, OPENING } from './dialogue'
12import { callsFor, type Built, type Loaded } from './loads'
13
14export type { Built }
15
16/** What a restart carries. `tickets` and `loads` are in the order they are shown. */
17export type Window = {
18  /** Each ticket's `ticket.md`, then its `context.md`, whole or the sections of its open questions. */
19  tickets: readonly Loaded[]
20  loads: readonly Loaded[]
21  /** Skills the session lists with no file to load, such as a built-in one: the new agent invokes each. */
22  invoke: readonly string[]
23  note: string
24  /** The user's messages the agent never answered, oldest first. */
25  asked: readonly string[]
26  /** Typed by the user as /restart: nothing runs after the compaction. */
27  manual: boolean
28}
29
30const user = (text: string): Built => ({ role: 'user', text, toolUses: [] })
31
32/** What the new agent is told first: instructions alone. A typed restart waits for the user, so it says nothing of continuing. */
33function opening(window: Window): string {
34  return [
35    window.manual ? OPENING : `${OPENING} Continue from the note.`,
36    ...window.invoke.map((name) => `Invoke the \`${name}\` skill first.`),
37    ...(window.asked.length ? ["Answer every point of the user's messages below."] : []),
38    'Conflicts: files win on what exists; `ticket.md` wins over `context.md` on decisions.',
39    'After answering, run `## Capture` over the note.',
40  ].join('\n')
41}
42
43/** The conversation after a restart, oldest first, below the fixed part Claude Code adds. */
44export function restartWindow(window: Window): Built[] {
45  return [
46    user(opening(window)),
47    ...callsFor([...window.tickets, ...window.loads]),
48    user(`${NOTE_PREFIX}\n\n${window.note}`),
49    ...window.asked.map(user),
50  ]
51}
52
53/** What is tracked once a main turn ends with `answer`: the user's messages are answered. A restart's closing line answers nothing, so it clears nothing. */
54export function afterAnswer(tracked: Tracked, answer: string, restarting: boolean): Tracked {
55  if (restarting || !answer.trim()) return tracked
56  return { ...tracked, asked: [] }
57}
58
59const MAX_TYPO_WORD = 40
60
61const words = (text: string) => text.trim().split(/\s+/)
62
63/** The edit distance between two words, a swap of neighbouring letters counting once. */
64function distance(a: string, b: string): number {
65  const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)))
66  for (let i = 1; i <= a.length; i++) {
67    for (let j = 1; j <= b.length; j++) {
68      d[i]![j] = Math.min(d[i - 1]![j]! + 1, d[i]![j - 1]! + 1, d[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
69      if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i]![j] = Math.min(d[i]![j]!, d[i - 2]![j - 2]! + 1)
70    }
71  }
72  return d[a.length]![b.length]!
73}
74
75const LETTERS = /^\p{L}+$/u
76
77/** A word without the punctuation around it: `script,` and `script` are one word. */
78const bare = (word: string) => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '') || word
79
80/** The same word, or a typo mended. A short word and anything not all letters (a number, a path, a URL, `a.js`) must match exactly: one character makes another thing of them. */
81function sameWord(was: string, is: string): boolean {
82  const before = bare(was)
83  const now = bare(is)
84  if (before === now) return true
85  const shortest = Math.min(before.length, now.length)
86  if (shortest < 4 || Math.max(before.length, now.length) > MAX_TYPO_WORD || !LETTERS.test(before) || !LETTERS.test(now)) return false
87  return distance(before, now) <= (shortest >= 5 ? 2 : 1)
88}
89
90/**
91 * The user's messages with `text` added. A message that the new one begins with, word by word, is
92 * dropped: the user submitted it, interrupted, and sent it again with more or with a typo mended,
93 * and the new one holds every word.
94 */
95export function addAsked(asked: readonly string[], text: string): string[] {
96  const now = words(text)
97  const repeats = (earlier: string) => {
98    const before = words(earlier)
99    return earlier.trim() !== '' && before.length <= now.length && before.every((word, i) => sameWord(word, now[i]!))
100  }
101  return [...asked.filter((earlier) => !repeats(earlier)), text]
102}
103
hooks/window.ts 197 lines
1/**
2 * window.ts: builds the restarted window from the disk and the old conversation.
3 *
4 * Reads the files and skills the agent named, the tickets the session works and the `context.md`
5 * sections of their open questions, and the guides those files need (hooks/situations.ts), and
6 * hands the parts to hooks/first-message.ts. The old
7 * window's turns stay in the session's transcript, where `flow audit` reads them.
8 */
9import type { EngineInterface } from 'claude-code'
10import type { Entry, Restart } from '../types'
11import { restartWindow, type Built } from './first-message'
12import { CAP, parseSpec, sectionLines, skillBody, slice, type Loaded } from './loads'
13import { entryFor, keyOf, keysOf } from './situations'
14import { linksFromFile, wikiText, withWiki } from './skill-wiki'
15import { openHandles } from './ticket'
16
17const bytes = (text: string) => new TextEncoder().encode(text).length
18const kb = (n: number) => `${Math.max(1, Math.round(n / 1024))} KB`
19
20/** The folders a path may be relative to: the session's, and the user's home. */
21type Where = { root: string; user: string }
22
23async function whereAmI($: EngineInterface): Promise<Where> {
24  return { root: (await $.session.root()).replace(/\/+$/, ''), user: (await $.env.get('HOME')) ?? '' }
25}
26
27/** A path as the agent wrote it, absolute. */
28const absolute = (path: string, where: Where) => (path.startsWith('/') ? path : path.startsWith('~/') ? `${where.user}${path.slice(1)}` : `${where.root}/${path}`)
29
30/** The folder of a skill: `plugin:name` under the plugin's folder in `~/.claude/skills/`, a bare name in the project, then in `~/.claude/skills/`. */
31async function skillDir($: EngineInterface, name: string, where: Where): Promise<string | undefined> {
32  const [plugin, bare] = name.includes(':') ? (name.split(':') as [string, string]) : [undefined, name]
33  const dirs = [
34    ...(plugin ? [`${where.user}/.claude/skills/${plugin}/skills/${bare}`] : []),
35    `${where.root}/.claude/skills/${bare}`,
36    `${where.user}/.claude/skills/${bare}`,
37  ]
38  for (const dir of dirs) if (await $.fs.exists(`${dir}/SKILL.md`)) return dir
39  return undefined
40}
41
42/** The skills the session lists, by the names `/skills` shows: a built-in one such as `plugin-authoring` has no file to read. */
43async function listedSkills($: EngineInterface): Promise<string[]> {
44  const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
45  return usage?.context.breakdown?.skills?.skillFrontmatter.map((skill) => skill.name) ?? []
46}
47
48/**
49 * The files and skills the agent named, read from disk, or why they cannot be loaded. Together
50 * they stay under `CAP`: past it the answer names each part's size, so the agent can name fewer.
51 * A skill the session lists with no file on disk goes in `invoke`, for the new agent to run itself.
52 */
53export async function resolveLoads($: EngineInterface, files: readonly string[], skills: readonly string[]): Promise<{ loaded: Loaded[]; invoke: string[] } | { error: string }> {
54  if (!files.length && !skills.length) return { loaded: [], invoke: [] }
55  const where = await whereAmI($)
56  const loaded: Loaded[] = []
57  const sizes: string[] = []
58  for (const raw of files) {
59    const spec = parseSpec(raw)
60    const path = absolute(spec.path, where)
61    const text = await $.fs.read(path).catch(() => undefined)
62    if (text === undefined) return { error: `${raw} cannot be read` }
63    const part = slice(text, spec)
64    loaded.push({ type: 'file', path, ...part })
65    sizes.push(`${raw} ${kb(bytes(part.text))}`)
66  }
67  const invoke: string[] = []
68  let listed: string[] | undefined
69  for (const name of skills) {
70    const dir = await skillDir($, name, where)
71    const text = dir === undefined ? undefined : await $.fs.read(`${dir}/SKILL.md`).catch(() => undefined)
72    if (dir === undefined || text === undefined) {
73      listed ??= await listedSkills($)
74      if (!listed.includes(name)) return { error: `the skill ${name} is not found` }
75      invoke.push(name)
76      continue
77    }
78    loaded.push({ type: 'skill', name, dir, text: skillBody(text), links: linksFromFile(text) })
79    sizes.push(`${name} ${kb(bytes(text))}`)
80  }
81  const total = loaded.reduce((sum, one) => sum + bytes(one.text), 0)
82  if (total > CAP) return { error: `files and skills hold ${kb(total)} (${sizes.join(', ')}), over the ${kb(CAP)} cap. Name fewer, or parts: path#heading, path:from-to` }
83  return { loaded, invoke }
84}
85
86/** The sections of `context.md` (its text at `path`) that the open questions of a `ticket.md` name. A handle with no section is skipped. */
87function openSections(ticket: string, path: string, context: string): Loaded[] {
88  const lines = context.split('\n')
89  return openHandles(ticket).flatMap((handle) => {
90    const found = sectionLines(context, handle)
91    return found ? [{ type: 'file' as const, path, text: lines.slice(found.from - 1, found.to).join('\n'), ...found, whole: false }] : []
92  })
93}
94
95/**
96 * The ticket files of the badges in flight, each followed by its `context.md`: whole in groundwork
97 * (`design`), where every topic stays in play, else the sections of its open questions.
98 * `fw-244` is `.flow/tickets/244/ticket.md` beside its `context.md`, and a `home` ticket sits under the Flow home.
99 */
100async function ticketLoads($: EngineInterface, entries: readonly Entry[], where: Where, home: string): Promise<Loaded[]> {
101  const found: Loaded[] = []
102  for (const entry of entries.filter((e) => !e.isDone)) {
103    const [, prefix, number] = /^(.+)-(\d+)$/.exec(entry.id) ?? []
104    if (!prefix || !number) continue
105    const base = prefix === 'home' ? home : `${where.root}/.flow`
106    for (const dir of [`${base}/tickets/${Number(number)}`, `${base}/tickets/archive/${Number(number)}`]) {
107      const path = `${dir}/ticket.md`
108      const text = await $.fs.read(path).catch(() => undefined)
109      if (text === undefined) continue
110      found.push({ type: 'file', path, text, from: 1, to: text.split('\n').length, whole: true })
111      const context = await $.fs.read(`${dir}/context.md`).catch(() => undefined)
112      if (context !== undefined && /^design\b/i.test(entry.word)) {
113        found.push({ type: 'file', path: `${dir}/context.md`, text: context, from: 1, to: context.split('\n').length, whole: true })
114      } else if (context !== undefined) found.push(...openSections(text, `${dir}/context.md`, context))
115      break
116    }
117  }
118  return found
119}
120
121/**
122 * The guides the window's files need (hooks/situations.ts), each once, whole, from the Flow home.
123 * A guide the window already loads whole is left out, and so is one missing on disk.
124 */
125async function guideLoads($: EngineInterface, files: readonly Loaded[], home: string): Promise<Loaded[]> {
126  const reads = files.flatMap((one) => (one.type === 'file' ? [{ tool: 'Read', input: { file_path: one.path } }] : []))
127  const have = new Set(files.flatMap((one, i) => (one.type === 'file' && one.whole ? keysOf(reads[i]!) : [])))
128  const found: Loaded[] = []
129  for (const call of reads) {
130    for (const send of entryFor(call)?.send(call) ?? []) {
131      if (send.tool !== 'Read' || have.has(keyOf(send))) continue
132      have.add(keyOf(send))
133      const path = `${home}/${send.path}`
134      const text = await $.fs.read(path).catch(() => undefined)
135      if (text !== undefined) found.push({ type: 'file', path, text, from: 1, to: text.split('\n').length, whole: true })
136    }
137  }
138  return found
139}
140
141/**
142 * The loads with each skill's wiki text after it (hooks/skill-wiki.ts), as `skill.prompt` would add
143 * it: a restart writes the `Skill` call itself, so the event never fires. Held: the window's whole
144 * reads, and the text added before.
145 */
146async function withWikis($: EngineInterface, window: readonly Loaded[], home: string): Promise<Loaded[]> {
147  const reads = new Set(window.flatMap((one) => (one.type === 'file' && one.whole ? [one.path] : [])))
148  let text = ''
149  const out: Loaded[] = []
150  for (const one of window) {
151    if (one.type !== 'skill' || !one.links?.length) {
152      out.push(one)
153      continue
154    }
155    const wiki = await wikiText(one.links, home, { text, reads }, (path) => $.fs.read(path).catch(() => undefined))
156    text += `\n${wiki}`
157    out.push({ ...one, text: withWiki(one.text, wiki) })
158  }
159  return out
160}
161
162/** True where a named file is already in the window: a whole ticket file, or a section with the same lines. */
163const alreadyIn = (tickets: readonly Loaded[], one: Loaded) =>
164  one.type === 'file' && tickets.some((t) => t.type === 'file' && t.path === one.path && (t.whole || (t.from === one.from && t.to === one.to)))
165
166export type Input = {
167  restart: Restart
168  asked: readonly string[]
169  /** The badges of the band: the tickets the session works. */
170  entries: readonly Entry[]
171  /** The Flow home, where a `home` ticket sits. */
172  home: string
173}
174
175/** The window the restart answers the compaction with, and the line the mod logs about it. */
176export async function buildWindow($: EngineInterface, input: Input): Promise<{ messages: Built[]; log: string }> {
177  const where = await whereAmI($)
178  const tickets = await ticketLoads($, input.entries, where, input.home)
179  const named = await resolveLoads($, input.restart.files, input.restart.skills)
180  const chosen = 'loaded' in named ? named.loaded.filter((one) => !alreadyIn(tickets, one)) : []
181  const guides = await guideLoads($, [...tickets, ...chosen], input.home)
182  const loads = (await withWikis($, [...tickets, ...guides, ...chosen], input.home)).slice(tickets.length)
183  const invoke = 'invoke' in named ? named.invoke : []
184  const messages = restartWindow({ tickets, loads, invoke, note: input.restart.message, asked: input.asked, manual: input.restart.manual })
185  const sections = tickets.filter((one) => one.type === 'file' && !one.whole).length
186  const log = [
187    `restart: ${tickets.length - sections} tickets with ${sections} open sections, ${kb(tickets.reduce((sum, one) => sum + bytes(one.text), 0))}`,
188    `${input.asked.length} unanswered messages`,
189    `${loads.filter((one) => one.type === 'file').length} files and ${loads.filter((one) => one.type === 'skill').length} skills, ${kb(loads.reduce((sum, one) => sum + bytes(one.text), 0))}`,
190    invoke.length ? `${invoke.length} skills to invoke` : '',
191    'error' in named ? `loads dropped: ${named.error}` : '',
192  ]
193    .filter(Boolean)
194    .join('; ')
195  return { messages, log }
196}
197
hooks/drag.ts 57 lines
1/**
2 * drag.ts: turns a pasted file path into the text of an `@` mention.
3 *
4 * Dragging a file into the prompt box pastes its absolute path, quoted or
5 * not. Claude Code reads mentions while the text is still in the box, so the
6 * rewrite has to happen in `prompt.edit`, before Enter.
7 */
8
9/** The words of a paste, split as a shell would: bare, 'single', "double", or with `\ ` escapes. Undefined for an open quote. */
10export function words(text: string): string[] | undefined {
11  const out: string[] = []
12  let word = ''
13  let started = false
14  let quote: string | undefined
15  for (let i = 0; i < text.length; i++) {
16    const c = text.charAt(i)
17    const next = text.charAt(i + 1)
18    if (quote) {
19      if (c === quote) quote = undefined
20      else if (c === '\\' && quote === '"' && (next === '"' || next === '\\')) word += text.charAt(++i)
21      else word += c
22    } else if (c === "'" || c === '"') {
23      quote = c
24      started = true
25    } else if (c === '\\' && next !== '') {
26      word += text.charAt(++i)
27      started = true
28    } else if (/\s/.test(c)) {
29      if (started) out.push(word)
30      word = ''
31      started = false
32    } else {
33      word += c
34      started = true
35    }
36  }
37  if (quote) return undefined
38  if (started) out.push(word)
39  return out
40}
41
42/** The absolute paths a paste is made of, or undefined when it holds anything else. */
43export function pastedPaths(text: string): string[] | undefined {
44  const found = words(text)
45  return found?.length && found.every((word) => word.startsWith('/')) ? found : undefined
46}
47
48/** The path under `root`, or undefined for the folder itself and anything outside it. */
49export function relativeTo(root: string, path: string): string | undefined {
50  const base = root.endsWith('/') ? root : `${root}/`
51  const rest = path.startsWith(base) ? path.slice(base.length).replace(/\/+$/, '') : ''
52  return rest || undefined
53}
54
55/** One mention. A name with a space takes quotes, as Claude Code reads `@"my file.md"`. */
56export const mention = (relative: string) => (/\s/.test(relative) ? `@"${relative}"` : `@${relative}`)
57
hooks/band.tsx 445 lines
1/**
2 * band.tsx: the band above the prompt, one badge for each ticket the session works.
3 *
4 * A badge is the ticket's id and short name in black on `#ffb703`, joined to its word and
5 * count on the terminal's background plus 7% of that colour. The band is one row: it ends with a
6 * green Restart button, shown between turns from `LEAD` under the wrap-up limit (button.ts), and the issue count in red.
7 * A ticket lights only when `flow start <id>` picks it up, and then sits leftmost; its file is read again whenever a
8 * command or an edit touches it, and at each turn's end. For as long as a turn runs, the badge
9 * the session works (the leftmost open one) shows a pale bar 3 cells wide moving along
10 * it and a spinner turning after its word; `band-settings.json` switches either off. The rules
11 * for joining and leaving are in band-model.ts; this file only feeds them and draws the result.
12 *
13 * register.ts calls these from its own hooks: a mod's hooks on one event are one hook,
14 * and the validator follows `on` only where it is spelled out.
15 */
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, RenderElement, RenderInput, SessionAppendInput, ToolCallInput, TurnCompleteInput, TurnStartInput } from 'claude-code'
18
19import type { Entry } from '../types'
20import { describeScenario, parseMock, parseScenarios, usage, withActing, type Scenario } from './band-mock'
21import { GAP, RESTART, RESTART_GAP, SPIN_CELLS, apply, currentId, layout, mix, setActing, spinnerAt, waveShares, type Badge } from './band-model'
22import { failureFiles, openIssues } from './issues'
23import { DEFAULT_LOOK, parseLook, type Look } from './look'
24import { restartOffered } from './meters-row'
25import { isExpired, isRecordKey, makeRecord, parseRecord, recordKey } from './record'
26import { idsIn, idsInPath, readTicket, startedIn, target } from './ticket'
27
28const band = atom({ plugin: 'flow-session', key: 'band' } as const, [] as Entry[])
29const issues = atom({ plugin: 'flow-session', key: 'issues' } as const, 0)
30const frame = atom({ plugin: 'flow-session', key: 'frame' } as const, 0)
31
32const ORANGE = '#ffb703'
33const GREEN = '#22c55e'
34const RED = '#ef4444'
35const BLACK = '#000000'
36const WHITE = '#ffffff'
37// How far toward white the wave's centre cell goes: pale enough to stay behind black text.
38const WASH = 0.75
39// The terminal's background is not readable from a mod, so the status part mixes with this one.
40const TERMINAL = '#16191d'
41const WAVE_MS = 70
42const FILE_TOOL = /^(Read|Edit|Write|MultiEdit|NotebookEdit)$/
43
44// True from the main session's turn start to its end. A hot reload starts it over as false.
45let turnRunning = false
46let wave: { cancel: () => void } | undefined
47// Which working signs show, read from `band-settings.json` at session start and at each turn's start.
48let look: Look = DEFAULT_LOOK
49// The test band `/band` shows in place of the real one. It is never saved: a hot reload, a restart,
50// `/band off` and the next turn's start all clear it. `mockActing` puts the signs on its open badges.
51let mock: { name: string; scenario: Scenario } | undefined
52let lastMock: string | undefined
53let mockActing = false
54
55/** What a tool call names: ticket ids, and which of them `flow start` picks up, which lights. The call's fields sit on the event itself. */
56async function namedBy($: EngineInterface, tool: string, call: object): Promise<{ ids: string[]; started: string[] }> {
57  const fields = call as { command?: unknown; file_path?: unknown }
58  if (tool === 'Bash' && typeof fields.command === 'string') {
59    const command = fields.command
60    if (!/\bflow\b|\bfw\s/.test(command)) return { ids: [], started: [] }
61    return { ids: idsIn(command), started: startedIn(command) }
62  }
63  if (FILE_TOOL.test(tool) && typeof fields.file_path === 'string' && fields.file_path.includes('/tickets/')) {
64    return { ids: idsInPath(fields.file_path, await prefixOf($)), started: [] }
65  }
66  return { ids: [], started: [] }
67}
68
69/**
70 * The project's ticket prefix: `ticketPrefix` in `.flow/settings.json`, else the word its folder
71 * offers (`offerWord` in scripts/lib/tickets/store.js, which this repeats: the mod cannot require it).
72 */
73async function prefixOf($: EngineInterface): Promise<string | undefined> {
74  const root = await $.session.root()
75  const valid = (word: unknown): word is string => typeof word === 'string' && /^[a-z]{2,8}$/.test(word) && word !== 'home'
76  try {
77    const saved = (JSON.parse((await readText($, `${root}/.flow/settings.json`)) ?? '{}') as { ticketPrefix?: unknown }).ticketPrefix
78    if (valid(saved)) return saved
79  } catch {
80    // A settings file that does not parse falls back to the folder's word, as the scripts do.
81  }
82  const letters = (root.split('/').filter(Boolean).pop() ?? '').toLowerCase().replace(/[^a-z]/g, '')
83  const word = letters.length <= 8 ? letters : letters.slice(0, 3)
84  return valid(word) ? word : 'flow'
85}
86
87/** The Flow home: `FLOW_HOME`, else `~/.flow`. */
88async function flowHome($: EngineInterface): Promise<string> {
89  return (await $.env.get('FLOW_HOME')) ?? `${await $.env.get('HOME')}/.flow`
90}
91
92/** A file's text, or undefined when it is missing or unreadable. */
93function readText($: EngineInterface, file: string): Promise<string | undefined> {
94  return $.fs.read(file).catch(() => undefined)
95}
96
97/** A ticket's folder: `tickets/<n>` or `tickets/<n>-<words>`, open or in `tickets/archive/`. An id of another place has none here. */
98async function folderOf($: EngineInterface, id: string): Promise<string | undefined> {
99  const [, word, number] = /^([a-z]+)-(\d+)$/.exec(id) ?? []
100  if (!number || word !== (await prefixOf($))) return undefined
101  const tickets = `${await $.session.root()}/.flow/tickets`
102  for (const dir of [tickets, `${tickets}/archive`]) {
103    const entries = await $.fs.list(dir).catch(() => [])
104    const found = entries.find((entry) => entry.kind === 'dir' && (entry.name === number || entry.name.startsWith(`${number}-`)))
105    if (found) return `${dir}/${found.name}`
106  }
107  return undefined
108}
109
110/**
111 * A click on a badge opens the ticket in VS Code through `code`. The name opens `ticket.md`;
112 * the word opens the file at the open list its word names (ticket.ts `target`), read as it is now.
113 */
114async function openTicket($: EngineInterface, id: string, atWord: boolean): Promise<void> {
115  const folder = await folderOf($, id)
116  if (!folder) return
117  const file = `${folder}/ticket.md`
118  let path = file
119  if (atWord) {
120    const text = await readText($, file)
121    const map = await readText($, `${folder}/groundwork/map.md`)
122    const found = text === undefined ? undefined : target(text, map, readTicket(text, map, await prefixOf($))?.word ?? '')
123    if (found) path = `${found.file === 'map' ? `${folder}/groundwork/map.md` : file}:${found.line}`
124  }
125  const result = await $.process.run(atWord ? ['code', '-g', path] : ['code', path], { timeoutMs: 10000 })
126  if (result.exitCode !== 0) $.ui.log(`opening ${id} failed: ${result.stderr.trim() || `code exited ${result.exitCode}`}`)
127}
128
129/** Reads the ticket again from its file and applies it to the band. */
130async function refresh($: EngineInterface, id: string, lights: boolean): Promise<void> {
131  const folder = await folderOf($, id)
132  const text = folder ? await readText($, `${folder}/ticket.md`) : undefined
133  if (!folder || text === undefined) return
134  const ticket = readTicket(text, await readText($, `${folder}/groundwork/map.md`), await prefixOf($))
135  if (ticket) await update($, band, (entries) => apply(entries ?? [], ticket, lights))
136}
137
138/** A failure of the saved record, put in the log so a band that never comes back is not silent. */
139function lost($: EngineInterface, what: string, error: unknown): void {
140  $.ui.log(`band ${what} failed: ${error instanceof Error ? error.message : String(error)}`)
141}
142
143/** Writes the band to this session's record. A lost write is rewritten at the next turn's end. */
144async function save($: EngineInterface): Promise<void> {
145  const entries = await read($, band)
146  await $.store.set(recordKey(await $.session.id()), makeRecord(entries, new Date()))
147}
148
149/** Deletes the records nobody has written for 14 days, and any key that holds no record. */
150async function prune($: EngineInterface): Promise<void> {
151  const now = new Date()
152  for (const key of (await $.store.keys()).filter(isRecordKey)) {
153    if (isExpired(await $.store.get(key), now)) await $.store.delete(key)
154  }
155}
156
157/**
158 * Puts the saved band back after a reopen, each ticket read again from its file: one that is
159 * done turns green, one parked or dropped leaves. A hot reload keeps `$.state`, so a band
160 * already there is left as it is.
161 */
162async function restore($: EngineInterface): Promise<void> {
163  if ((await read($, band)).length) return
164  const saved = parseRecord(await $.store.get(recordKey(await $.session.id())))
165  if (!saved?.band.length) return
166  await update($, band, () => saved.band)
167  for (const entry of saved.band) await refresh($, entry.id, false).catch(() => undefined)
168  $.ui.log(`band restored: ${saved.band.length} from ${saved.updated}`)
169}
170
171/** Starts or stops the wave's timer: one frame every 70 ms while any badge shown is acting and a sign is on. */
172async function runWave($: EngineInterface): Promise<void> {
173  const shown = mock ? withActing(mock.scenario.entries, mockActing) : await read($, band)
174  const anyActing = (look.sweep || look.spinner) && shown.some((entry) => entry.acting)
175  if (anyActing && !wave) wave = $.clock.every(WAVE_MS, () => void update($, frame, (n) => (n ?? 0) + 1).catch(() => undefined))
176  if (!anyActing && wave) {
177    wave.cancel()
178    wave = undefined
179  }
180}
181
182/** Puts the signs on the badge the session works while a turn runs, and takes them off every badge otherwise. */
183async function pointWave($: EngineInterface): Promise<void> {
184  await update($, band, (entries) => setActing(entries ?? [], turnRunning ? currentId(entries ?? []) : undefined))
185  await runWave($)
186}
187
188/** Puts the real band back and redraws. */
189async function clearMock($: EngineInterface): Promise<void> {
190  mock = undefined
191  mockActing = false
192  await runWave($)
193  await update($, frame, (n) => (n ?? 0) + 1)
194}
195
196/** The mod's own folder, found from `CLAUDE_CODE_PLUGIN_DIRS` (`~` expanded), or undefined. */
197async function modFolder($: EngineInterface): Promise<string | undefined> {
198  const dir = ((await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? '').split(':').find((path) => /flow-session\/?$/.test(path))
199  return dir?.replace(/^~/, (await $.env.get('HOME')) ?? '~')
200}
201
202/** Reads `band-settings.json` in the mod folder, so an edit shows from the next turn. */
203async function readLook($: EngineInterface): Promise<void> {
204  const dir = await modFolder($)
205  look = parseLook(dir ? await readText($, `${dir}/band-settings.json`) : undefined)
206}
207
208/** The scenarios in `band-scenarios.json` beside the mod's manifest, read afresh. */
209async function readScenarios($: EngineInterface): Promise<{ scenarios: Record<string, Scenario>; error?: string }> {
210  const dir = await modFolder($)
211  if (!dir) return { scenarios: {}, error: 'The mod folder is not in CLAUDE_CODE_PLUGIN_DIRS, so band-scenarios.json cannot be found.' }
212  const file = `${dir}/band-scenarios.json`
213  const text = await readText($, file)
214  return text === undefined ? { scenarios: {}, error: `No scenarios file at ${file}.` } : parseScenarios(text)
215}
216
217/** `/band`: shows a test scenario in place of the real band, or puts the real one back. `clear` empties the real band. Returns the reply. */
218export async function bandMock($: EngineInterface, args: string): Promise<string> {
219  const word = args.trim().toLowerCase()
220  if (word === 'off') {
221    await clearMock($)
222    return 'Band shows the real tickets.'
223  }
224  if (word === 'clear') {
225    // The saved record is emptied too, or a reopen would bring the badges back.
226    await update($, band, () => [])
227    await clearMock($)
228    await save($).catch((error) => lost($, 'save', error))
229    return 'Band emptied. `flow start <id>` lights a ticket again.'
230  }
231  const { scenarios, error } = await readScenarios($)
232  if (error) return error
233  const names = Object.keys(scenarios)
234  const command = parseMock(args, names, mock?.name, lastMock)
235  if ('help' in command) return usage(names)
236  if ('list' in command) return names.map((name, i) => describeScenario(`${i + 1} ${name}`, scenarios[name] as Scenario)).join('\n')
237  if ('acting' in command) {
238    if (!names.length) return usage(names)
239    if (mock) mockActing = !mockActing
240    else {
241      // After a reopen no scenario is shown: `act` shows the last one, or the first, with the signs on.
242      const name = lastMock && scenarios[lastMock] ? lastMock : (names[0] as string)
243      mock = { name, scenario: scenarios[name] as Scenario }
244      lastMock = name
245      mockActing = true
246    }
247    await readLook($)
248  } else if ('show' in command) {
249    mock = { name: command.show, scenario: scenarios[command.show] as Scenario }
250    lastMock = command.show
251  } else {
252    await clearMock($)
253    return 'Band shows the real tickets.'
254  }
255  await runWave($)
256  await update($, frame, (n) => (n ?? 0) + 1)
257  return `Band shows scenario ${names.indexOf(mock?.name ?? '') + 1}, ${mock?.name}${mockActing ? ', act' : ''}.`
258}
259
260async function countIssues($: EngineInterface): Promise<void> {
261  const texts = await Promise.all(failureFiles(await flowHome($), new Date()).map((file) => readText($, file)))
262  await update($, issues, () => openIssues(texts.filter((text): text is string => text !== undefined)))
263}
264
265/** The session is ready: reads the settings, puts the saved band back and counts the open issues. */
266export async function bandStart($: EngineInterface): Promise<void> {
267  await readLook($).catch(() => undefined)
268  await restore($).catch((error) => lost($, 'restore', error))
269  await prune($).catch((error) => lost($, 'prune', error))
270  await countIssues($).catch(() => undefined)
271}
272
273/** A turn began: the main session's puts the signs on the badge it works, and ends a test band. */
274export async function bandTurnStart($: EngineInterface, e: TurnStartInput): Promise<void> {
275  if (e.agentId !== undefined) return
276  turnRunning = true
277  if (mock) await clearMock($).catch(() => undefined)
278  await readLook($).catch(() => undefined)
279  await pointWave($).catch(() => undefined)
280}
281
282/**
283 * Runs a tool call. `run` is the call itself; the tickets it names are read again after it.
284 * A `flow start <id>` makes that ticket the one the session works.
285 */
286export async function bandToolCall<R>($: EngineInterface, e: ToolCallInput, run: () => Promise<R>): Promise<R> {
287  const { ids, started } = await namedBy($, e.tool, e)
288  try {
289    return await run()
290  } finally {
291    // The call has run: the file says what it did.
292    await touched($, ids, started)
293  }
294}
295
296/** Tickets a command named were changed or looked at: reads them again, and one picked up by `flow start` goes leftmost and is the one the session works. */
297async function touched($: EngineInterface, ids: string[], started: string[]): Promise<void> {
298  for (const id of ids) await refresh($, id, started.includes(id)).catch(() => undefined)
299  if (!ids.length) return
300  await pointWave($).catch(() => undefined)
301  await save($).catch((error) => lost($, 'save', error))
302}
303
304// The command the person last ran with `!`. Its output row comes after the command has run, and that row is the cue.
305let shell: string | undefined
306// `flow [start|done|park|drop] [fw-]137`, or `fw` for `flow`: the person may type the number alone.
307const SHELL_FLOW = /\b(?:flow|fw)\s+(?:(start|done|park|drop)\s+)?(?:[a-z]+-)?(\d+)\b/
308
309/**
310 * A row was added to the conversation. The person's own `!` commands raise no tool call, so a
311 * `flow` command typed that way is read here: the `<bash-input>` row keeps the command, the
312 * `<bash-stdout>` row after it says the command is done and names the ticket in full.
313 */
314export async function bandShell($: EngineInterface, e: SessionAppendInput): Promise<void> {
315  if (e.agentId !== undefined) return
316  const text = e.message.content.map((block) => (typeof block.text === 'string' ? block.text : '')).join('')
317  const input = /<bash-input>([\s\S]*?)<\/bash-input>/.exec(text)
318  if (input) {
319    shell = input[1]
320    return
321  }
322  if (!shell || !text.includes('<bash-stdout>')) return
323  const command = shell
324  shell = undefined
325  const named = SHELL_FLOW.exec(command)
326  if (!named) return
327  const [, verb, number] = named
328  const ids = idsIn(text).filter((id) => id.endsWith(`-${number}`))
329  const tickets = ids.length ? ids : idsIn(command)
330  await touched($, tickets, verb === 'start' ? tickets : [])
331}
332
333/** A turn ended: every badge is read again from its file, the signs stop, and the issue count is read again. */
334export async function bandTurnComplete($: EngineInterface, e: TurnCompleteInput): Promise<void> {
335  if (e.agentId !== undefined) return
336  turnRunning = false
337  for (const entry of await read($, band).catch(() => [])) await refresh($, entry.id, false).catch(() => undefined)
338  await pointWave($).catch(() => undefined)
339  await save($).catch((error) => lost($, 'save', error))
340  await countIssues($).catch(() => undefined)
341}
342
343/**
344 * The band's rows, or what the engine drew where there is nothing to show. `restartSession`
345 * is what a press of the Restart button does; the button shows between turns from `LEAD` under the wrap-up limit.
346 */
347export async function drawBand(
348  $: EngineInterface,
349  e: RenderInput<'AbovePrompt'>,
350  next: (e: RenderInput<'AbovePrompt'>) => Promise<RenderElement>,
351  restartSession: () => void,
352): Promise<RenderElement> {
353  const scenario = mock?.scenario
354  const entries = scenario ? withActing(scenario.entries, mockActing) : await read($, band)
355  const open = scenario ? scenario.issues : await read($, issues)
356  const restart = scenario ? scenario.restart : await restartOffered($, turnRunning)
357  const tick = await read($, frame)
358  if (e.props.hasSurvey || (!entries.length && !open && !restart)) return next(e)
359
360  const elements = $.ui.resolve(e)
361  const { Box, Button, Text } = elements
362  const row = layout(entries, scenario?.columns ?? e.props.bodyColumns, open, look.spinner, restart)
363
364  // Each badge is one plain Button, so the engine's own hover and pointer zone apply.
365  const drawBadge = (badge: Badge, at: number) => {
366    const colour = badge.isDone ? GREEN : ORANGE
367    const dark = mix(TERMINAL, colour, 0.07)
368    const waves = badge.acting && look.sweep
369    const spins = badge.acting && look.spinner
370    const shares = waves ? waveShares(badge.name.length + badge.status.length + (spins ? SPIN_CELLS : 0), tick) : undefined
371    // While the wave runs each letter of the name has its own background. On the status and the
372    // spinner only the bar's centre cell shows, in the badge's colour with black text.
373    const lit = (at: number) => shares?.[at] === 1
374    const name = shares
375      ? [...badge.name].map((letter, i) => (
376          <Text key={`n${i}`} color={BLACK} backgroundColor={mix(colour, WHITE, (shares[i] ?? 0) * WASH)}>
377            {letter}
378          </Text>
379        ))
380      : [
381          <Text key="name" color={BLACK} backgroundColor={colour}>
382            {badge.name}
383          </Text>,
384        ]
385    const status = shares
386      ? [...badge.status].map((letter, i) => {
387          const on = lit(badge.name.length + i)
388          return (
389            <Text key={`s${i}`} color={on ? BLACK : colour} backgroundColor={on ? colour : dark}>
390              {letter}
391            </Text>
392          )
393        })
394      : [
395          <Text key="status" color={colour} backgroundColor={dark}>
396            {badge.status}
397          </Text>,
398        ]
399    const press = (atWord: boolean) => () => void openTicket($, badge.id, atWord).catch((error) => lost($, 'open', error))
400    // A badge shown as its id alone has no status, so its right corner takes the badge's own colour.
401    return [
402      at ? <Text key={`gap-${badge.id}`}>{' '.repeat(GAP)}</Text> : '',
403      <Button key={`badge-${badge.id}`} plain onPress={press(false)}>
404        <Text color={colour}>▐</Text>
405        {name}
406      </Button>,
407      <Button key={`word-${badge.id}`} plain onPress={press(true)}>
408        {status}
409        {spins
410          ? [' ', spinnerAt(tick)].map((cell, i) => {
411              const on = lit(badge.name.length + badge.status.length + i)
412              return (
413                <Text key={`p${i}`} color={on ? BLACK : colour} backgroundColor={on ? colour : dark}>
414                  {cell}
415                </Text>
416              )
417            })
418          : ''}
419        <Text color={badge.status ? dark : colour}>▌</Text>
420      </Button>,
421    ]
422  }
423
424  return (
425    <Box marginTop={look.topRow ? 1 : 0}>
426      {row.badges.flatMap((badge, at) => drawBadge(badge, at))}
427      {row.folded ? <Text dimColor> +{row.folded}</Text> : ''}
428      {row.restart || row.issues ? <Text>{' '.repeat(row.space)}</Text> : ''}
429      {row.restart ? (
430        <Button key="restart" plain onPress={restartSession}>
431          <Text color={GREEN}>▐</Text>
432          <Text color={BLACK} backgroundColor={GREEN}>
433            {RESTART}
434          </Text>
435          <Text color={GREEN}>▌</Text>
436        </Button>
437      ) : (
438        ''
439      )}
440      {row.restart && row.issues ? <Text>{' '.repeat(RESTART_GAP)}</Text> : ''}
441      {row.issues ? <Text color={RED}>{row.issues}</Text> : ''}
442    </Box>
443  )
444}
445
hooks/keepwarm.ts 283 lines
1/**
2 * keepwarm.ts: `/keepwarm`, a ping that renews the session's prompt cache while the user is away.
3 *
4 * The cache lives 1 hour (or 5 minutes) from the last request that read it. `$.model.fork` sends the
5 * main thread's last request again with one tiny question, so the API serves the same prefix from the
6 * cache and the clock starts over; the fork is not in the transcript. A ping is worth it where a miss
7 * (the whole prefix written again, about 30 times a ping's cost) would otherwise fall on the next message.
8 *
9 * Safety rests on 3 rules. `planOf` is the only place that decides, from facts read fresh each time
10 * (the switch, the transcript's end, the clock), and it never pings while a turn, a restart or a helper
11 * agent runs, nor a cache that has lapsed, nor one the model switch or a restart emptied. Every ping
12 * is judged by `cache_read_input_tokens`; the first one that is not a hit, or fails, turns the switch
13 * off, so a bug costs one miss and never repeats. And a timer only wakes `tick`, which reads the facts
14 * again: a user who kept working has moved the due time, so nothing is sent.
15 *
16 * register.ts calls `tick` at the session's start, when a main turn ends and on `/keepwarm`, and `cancel`
17 * when a main turn starts.
18 */
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, ModelForkResult, Timer } from 'claude-code'
21import type { Warm } from '../types'
22import { lastRequest, type Request } from './cache'
23import { kilo } from './wrap-up'
24
25const MINUTE = 60 * 1000
26const HOUR = 60 * MINUTE
27
28/** How long before the cache goes cold the ping is sent. */
29export const LEAD = 3 * MINUTE
30/** How long after the last request the pings stop. The next turn starts them again. */
31export const MAX_IDLE = 4 * HOUR
32/** Pings for one request. A cache that lives 5 minutes pings more often, and this ends it. */
33export const MAX_PINGS = 12
34/** Never ping a cache this close to cold: it is gone by the time the call lands. */
35export const MARGIN = 10 * 1000
36/** Between two pings. */
37export const SPACING = 60 * 1000
38/** The longest sleep. A longer wait wakes to read the facts again. */
39export const LOOK = 10 * MINUTE
40/** The share of the prompt the cache must serve for a ping to count as a hit. */
41export const HIT = 0.9
42const PROMPT = 'Reply with the single word ok.'
43// The end of the transcript read for the last request (as meters-row.tsx does).
44const TAIL_BYTES = ['400000', '100000']
45
46const parse = (text: string | undefined): Record<string, unknown> => {
47  try {
48    const value: unknown = JSON.parse(text ?? '')
49    return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
50  } catch {
51    return {}
52  }
53}
54
55/** `keepWarmLead` (minutes) and `keepWarmMaxIdle` (hours) from `~/.flow/settings.json` and `settings.local.json`, the local file winning, as milliseconds. */
56export function keepWarmSettings(globalText?: string, localText?: string): { lead: number; maxIdle: number } {
57  const both = { ...parse(globalText), ...parse(localText) }
58  const lead = Number(both.keepWarmLead)
59  const maxIdle = Number(both.keepWarmMaxIdle)
60  return { lead: lead > 0 ? lead * MINUTE : LEAD, maxIdle: maxIdle > 0 ? maxIdle * HOUR : MAX_IDLE }
61}
62
63/** What `planOf` decides from. */
64export type Facts = {
65  now: number
66  warm: Warm
67  /** A main turn, a restart or a helper agent runs, or nobody is at the prompt. */
68  busy: boolean
69  /** The newest request in the transcript. */
70  request?: Request
71  lead: number
72  maxIdle: number
73}
74
75export type Plan = { do: 'idle'; why: string } | { do: 'wait'; ms: number } | { do: 'ping' }
76
77const idle = (why: string): Plan => ({ do: 'idle', why })
78
79/**
80 * What to do now, by the first rule that holds. Idle sets no timer: the next turn's end, a restart of
81 * the mod or a command starts the plan again.
82 */
83export function planOf(f: Facts): Plan {
84  const { now, warm, request } = f
85  if (!warm.on) return idle('off')
86  if (f.busy) return idle('a turn, a restart or a helper agent is running')
87  if (!request || request.lifetime === undefined) return idle('no reply has written a cache yet')
88  const lastWork = Math.max(request.at, warm.activeAt ?? 0)
89  const renewed = Math.max(lastWork, warm.warmedAt ?? 0)
90  if ((warm.brokenAt ?? 0) >= renewed) return idle('a model switch or a restart emptied the cache')
91  if (now - lastWork >= f.maxIdle) return idle('idle past the limit')
92  const cold = renewed + request.lifetime
93  if (now >= cold - MARGIN) return idle('the cache has lapsed')
94  if ((warm.period === request.at ? warm.pings : 0) >= MAX_PINGS) return idle('the ping limit for this request is reached')
95  const due = cold - Math.min(f.lead, request.lifetime / 2)
96  if (now < due) return { do: 'wait', ms: Math.min(due - now, LOOK) }
97  const since = warm.attemptAt === undefined ? SPACING : now - warm.attemptAt
98  return since < SPACING ? { do: 'wait', ms: SPACING - since } : { do: 'ping' }
99}
100
101/** The result of one ping: a hit with the tokens the cache served, or why it was not one. */
102export type Verdict = { hit: true; read: number; total: number } | { hit: false; reason: string }
103
104/** A ping is a hit when the cache served nearly the whole prompt. Anything else, even a failure, is a miss. */
105export function judge(r: ModelForkResult): Verdict {
106  if (!('usage' in r)) return { hit: false, reason: 'there was nothing to fork yet' }
107  const read = r.usage.cache_read_input_tokens
108  const total = r.usage.input_tokens + read + r.usage.cache_creation_input_tokens
109  if (total > 0 && read / total >= HIT) return { hit: true, read, total }
110  if (!r.isAnswered && r.reason === 'api-error') return { hit: false, reason: `the API refused the ping (${r.error})` }
111  if (total === 0) return { hit: false, reason: `the ping got no reply (${r.isAnswered ? 'empty' : r.reason})` }
112  return { hit: false, reason: `the cache was cold: it served ${kilo(read)} of ${kilo(total)}` }
113}
114
115// The host holds the state, so a hot reload and an in-place restart keep the switch.
116const warm = atom({ plugin: 'flow-session', key: 'warm' } as const, { on: false, pings: 0 } as Warm)
117
118let timer: Timer | undefined
119// Ticks run one after another: two at once would plan from the same facts.
120let chain: Promise<void> = Promise.resolve()
121
122/** What `tick` needs from register.ts, which holds the session's turn state. */
123export type Deps = {
124  /** True while a main turn, a restart or a helper agent runs, or nobody is at the prompt. */
125  busy: () => Promise<boolean>
126  /** A ping hit: the cache goes cold at `coldAt`, which the meters row shows. */
127  warmed: (coldAt: number) => Promise<void>
128}
129
130const reasonOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
131
132/** Stops the timer. A main turn's start calls it: the user is back. */
133export function cancel(): void {
134  timer?.cancel()
135  timer = undefined
136}
137
138async function flowHome($: EngineInterface): Promise<string> {
139  return (await $.env.get('FLOW_HOME')) ?? `${await $.env.get('HOME')}/.flow`
140}
141
142/** The newest request at the transcript's end, or undefined. */
143async function requestOf($: EngineInterface, transcript: string): Promise<Request | undefined> {
144  for (const bytes of TAIL_BYTES) {
145    const tail = await $.process.run(['tail', '-c', bytes, transcript], { timeoutMs: 5000 })
146    if (tail.isStdoutTruncated) continue
147    return lastRequest(tail.stdout)
148  }
149  return undefined
150}
151
152async function factsOf($: EngineInterface, deps: Deps): Promise<Facts> {
153  const state = await read($, warm)
154  const home = await flowHome($)
155  const text = (file: string) => $.fs.read(file).catch(() => undefined)
156  const { lead, maxIdle } = keepWarmSettings(await text(`${home}/settings.json`), await text(`${home}/settings.local.json`))
157  const request = state.transcript ? await requestOf($, state.transcript).catch(() => undefined) : undefined
158  return { now: await $.clock.now(), warm: state, busy: await deps.busy(), request, lead, maxIdle }
159}
160
161/**
162 * Sends one ping and judges it. `period` is the request the ping belongs to: a counted ping adds to its
163 * count, and a manual one (`/keepwarm ping`) passes none. The ping is written down before the call, in
164 * one atomic update that refuses a second ping inside `SPACING`, so two ticks send one.
165 */
166async function fire($: EngineInterface, deps: Deps, started: number, lifetime: number | undefined, period?: number): Promise<Verdict | undefined> {
167  let claimed = false
168  await update($, warm, (w) => {
169    claimed = w.attemptAt === undefined || started - w.attemptAt >= SPACING
170    if (!claimed) return w
171    const counted = period === undefined ? {} : { pings: (w.period === period ? w.pings : 0) + 1, period }
172    return { ...w, ...counted, attemptAt: started }
173  })
174  if (!claimed) return undefined
175  const verdict = await $.model.fork({ prompt: PROMPT }).then(judge, (error): Verdict => ({ hit: false, reason: `the ping failed (${reasonOf(error)})` }))
176  if (verdict.hit) {
177    await update($, warm, (w) => ({ ...w, warmedAt: started }))
178    if (lifetime !== undefined) await deps.warmed(started + lifetime).catch(() => undefined)
179  }
180  return verdict
181}
182
183/** A ping that was not a hit turns the switch off and says why. */
184async function stop($: EngineInterface, why: string): Promise<void> {
185  await update($, warm, (w) => ({ ...w, on: false, stopped: why }))
186  $.ui.log(`keep-warm is off: ${why}`)
187  $.ui.toast(`Keep-warm is off: ${why}`, { timeoutMs: 10000 })
188}
189
190const minutes = (ms: number) => Math.max(1, Math.round(ms / MINUTE))
191
192async function step($: EngineInterface, deps: Deps): Promise<void> {
193  cancel()
194  // A ping that hits plans again once, to set the next timer. A second ping in a row would be a bug, so the loop ends there.
195  for (let round = 0; round < 2; round++) {
196    const f = await factsOf($, deps)
197    const plan = planOf(f)
198    if (plan.do === 'idle') return
199    if (plan.do === 'wait') {
200      timer = $.clock.after(plan.ms, () => void tick($, deps))
201      return
202    }
203    // The facts are a moment old: a turn that began since them ends the plan.
204    if (await deps.busy()) return
205    const verdict = await fire($, deps, f.now, f.request?.lifetime, f.request?.at)
206    if (!verdict) return
207    if (!verdict.hit) return stop($, verdict.reason)
208    $.ui.log(`keep-warm: cache read ${kilo(verdict.read)} of ${kilo(verdict.total)}, ping ${(await read($, warm)).pings}`)
209  }
210}
211
212/** Plans from fresh facts, then sends a ping, sets the one timer or does nothing. Never throws. */
213export function tick($: EngineInterface, deps: Deps): Promise<void> {
214  cancel()
215  const run = chain.then(() => step($, deps))
216  chain = run.catch(() => undefined)
217  return run.catch((error) => $.ui.log(`keep-warm could not plan: ${reasonOf(error)}`))
218}
219
220/** The session's transcript file: the pings read its end. */
221export async function noteTranscript($: EngineInterface, transcript: string): Promise<void> {
222  await update($, warm, (w) => (w.transcript === transcript ? w : { ...w, transcript }))
223}
224
225/** A main turn started or ended: the cache was renewed no earlier than now, whatever the transcript shows yet. */
226export async function noteActive($: EngineInterface): Promise<void> {
227  const at = await $.clock.now()
228  await update($, warm, (w) => ({ ...w, activeAt: at }))
229}
230
231/** The session ends (exit, /clear, resume): the timer stops and the switch goes off, so a session opened again starts with it off. */
232export async function closeWarm($: EngineInterface): Promise<void> {
233  cancel()
234  await update($, warm, (w) => ({ ...w, on: false, stopped: undefined }))
235}
236
237/** A model switch or a restart: the cached prefix is gone until a new request writes it. */
238export async function noteBroken($: EngineInterface): Promise<void> {
239  const at = await $.clock.now()
240  await update($, warm, (w) => ({ ...w, brokenAt: at }))
241}
242
243/** What `/keepwarm` and `/keepwarm status` say. */
244async function status($: EngineInterface, deps: Deps): Promise<string> {
245  const f = await factsOf($, deps)
246  const plan = planOf(f)
247  if (!f.warm.on) return f.warm.stopped ? `Keep-warm is off: ${f.warm.stopped}.` : 'Keep-warm is off.'
248  const pings = f.warm.period === f.request?.at ? f.warm.pings : 0
249  const sent = `${pings} ${pings === 1 ? 'ping' : 'pings'} sent`
250  if (plan.do === 'wait' && f.request?.lifetime !== undefined) {
251    const next = Math.max(f.request.at, f.warm.activeAt ?? 0, f.warm.warmedAt ?? 0) + f.request.lifetime - Math.min(f.lead, f.request.lifetime / 2)
252    return `Keep-warm is on: next ping in ${minutes(next - f.now)} min, ${sent}.`
253  }
254  return `Keep-warm is on, waiting: ${plan.do === 'idle' ? plan.why : 'about to ping'}. ${sent}.`
255}
256
257/** `/keepwarm on|off|status|ping`; no word toggles. Returns the line the command answers. */
258export async function keepWarmCommand($: EngineInterface, deps: Deps, args: string): Promise<string> {
259  const word = args.trim().toLowerCase()
260  const on = (await read($, warm)).on
261  if (word === 'status') return status($, deps)
262  if (word === 'ping') return manualPing($, deps)
263  if (word !== '' && word !== 'on' && word !== 'off') return 'Use /keepwarm on, off, status or ping.'
264  if (word === 'off' || (word === '' && on)) {
265    cancel()
266    await update($, warm, (w) => ({ ...w, on: false, stopped: undefined }))
267    return 'Keep-warm is off.'
268  }
269  await update($, warm, (w) => ({ ...w, on: true, stopped: undefined, pings: 0, period: undefined, attemptAt: undefined }))
270  await tick($, deps)
271  return status($, deps)
272}
273
274/** One fork now, whatever the plan says, and what it found. The probe for a lapsed cache. */
275async function manualPing($: EngineInterface, deps: Deps): Promise<string> {
276  if (await deps.busy()) return 'Not sent: a turn, a restart or a helper agent is running.'
277  const f = await factsOf($, deps)
278  const verdict = await fire($, deps, f.now, f.request?.lifetime)
279  if (!verdict) return 'Not sent: a ping went out less than a minute ago.'
280  if (f.warm.on) await tick($, deps)
281  return verdict.hit ? `Hit: the cache served ${kilo(verdict.read)} of ${kilo(verdict.total)} tokens.` : `Miss: ${verdict.reason}.`
282}
283
hooks/meters-row.tsx 145 lines
1/**
2 * meters-row.tsx: the meters row under the prompt, fed by the session's figures and the transcript's end.
3 *
4 * `session.measure` brings the context and the rate limits. The effort and cache lifetime are in no
5 * mod call, so each turn's end reads the transcript's tail (cache.ts), which also names the model
6 * exactly. Until a first reply the model comes from `$.session.model()`. A 30-second timer
7 * redraws the cache countdown. The pure part (which meters, which tone, what fits) is meters.ts.
8 *
9 * register.ts calls these from its own hooks: a mod's hooks on one event are one hook.
10 */
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, RenderElement, RenderInput, SessionMeasureInput } from 'claude-code'
13
14import type { Tracked } from '../types'
15
16import { busyWith } from './button'
17import { coldAt, lastRequest } from './cache'
18import { SEPARATOR, fit, meters, type Tone } from './meters'
19import { offersRestart, wrapUpSettings } from './wrap-up'
20
21const reading = atom({ plugin: 'flow-session', key: 'meters' } as const, { limit: 170000, rates: [], beat: 0 })
22// The restart's state (see register.ts, where it is set): the Restart button hides while it says a turn or a restart runs.
23const tracked = atom({ plugin: 'flow-session', key: 'tracked' } as const, { asked: [] } as Tracked)
24
25// Whether `wrapUp` is on in `~/.flow/settings.json`, read with the limit. Off, the band offers no restart.
26let wrapUpOn = true
27
28const TONES: Record<Tone, string | undefined> = { plain: undefined, ok: '#22c55e', warn: '#ffb703', bad: '#ef4444' }
29const BEAT_MS = 30 * 1000
30// The end of the transcript read for the last request. A cut-off first line is skipped by `lastRequest`.
31const TAIL_BYTES = ['400000', '100000']
32
33let beat: { cancel: () => void } | undefined
34
35async function flowHome($: EngineInterface): Promise<string> {
36  return (await $.env.get('FLOW_HOME')) ?? `${await $.env.get('HOME')}/.flow`
37}
38
39function readText($: EngineInterface, file: string): Promise<string | undefined> {
40  return $.fs.read(file).catch(() => undefined)
41}
42
43/** The session is ready: starts the 30-second redraw once, and reads the wrap-up limit. */
44export async function metersStart($: EngineInterface): Promise<void> {
45  if (!beat) beat = $.clock.every(BEAT_MS, () => void update($, reading, (r) => ({ ...r, beat: r.beat + 1 })).catch(() => undefined))
46  await readLimit($).catch(() => undefined)
47}
48
49async function readLimit($: EngineInterface): Promise<void> {
50  const home = await flowHome($)
51  const { wrapUp, limit } = wrapUpSettings(await readText($, `${home}/settings.json`), await readText($, `${home}/settings.local.json`))
52  wrapUpOn = wrapUp
53  await update($, reading, (r) => ({ ...r, limit }))
54}
55
56/**
57 * True when the band offers the Restart button (see `offersRestart`). `turnRunning` is the band's own
58 * flag. Reading the figures and the restart state here redraws the band as they move.
59 */
60export async function restartOffered($: EngineInterface, turnRunning: boolean): Promise<boolean> {
61  const { tokens, limit } = await read($, reading)
62  return offersRestart(tokens, limit, wrapUpOn, busyWith(await read($, tracked), turnRunning))
63}
64
65/** The context and the limits, pushed after each main turn and when a limit moves a point. */
66export async function metersMeasure($: EngineInterface, e: SessionMeasureInput): Promise<void> {
67  await update($, reading, (r) => ({ ...r, tokens: e.context.tokens, rates: e.rateLimits }))
68}
69
70/**
71 * The context as the session has it now. `session.measure` comes only after a main turn, so a long
72 * turn would show the old figure. No figure yet (just after a compaction) leaves the meter as it is.
73 */
74export async function metersRefresh($: EngineInterface): Promise<void> {
75  const tokens = (await $.session.usage().catch(() => undefined))?.context.tokens
76  if (tokens !== undefined) await update($, reading, (r) => (r.tokens === tokens ? r : { ...r, tokens }))
77}
78
79/** Hides the context meter until a response gives a figure: the old one belongs to a conversation that is gone. */
80export async function metersClear($: EngineInterface): Promise<void> {
81  await update($, reading, ({ tokens: _, ...r }) => r)
82}
83
84/** The model was switched: the name changes at once, and the cache of the old model is gone. The effort stays until the next request shows it. */
85export async function metersSwitch($: EngineInterface, model: string): Promise<void> {
86  await update($, reading, (r) => ({ ...r, model, coldAt: Date.now() }))
87}
88
89/** A keep-warm ping hit: the cache goes cold at `coldAt`. A fork is not in the transcript, so the countdown is set here. */
90export async function metersWarmed($: EngineInterface, coldAt: number): Promise<void> {
91  await update($, reading, (r) => ({ ...r, coldAt }))
92}
93
94/** The session started or a turn ended: the model, effort and cache countdown come from the transcript's end. */
95export async function metersRead($: EngineInterface, transcript: string): Promise<void> {
96  for (const bytes of TAIL_BYTES) {
97    const tail = await $.process.run(['tail', '-c', bytes, transcript], { timeoutMs: 5000 })
98    if (tail.isStdoutTruncated) continue
99    const request = lastRequest(tail.stdout)
100    // A session with no reply yet has no request, so the model comes from the session itself.
101    const model = request?.model ?? ((await $.session.model().catch(() => undefined)) || undefined)
102    await update($, reading, (r) => ({ ...r, model, effort: request?.effort, coldAt: coldAt(request) }))
103    return
104  }
105}
106
107// The footer's mode label (`⏵⏵ accept edits on`) and its gap, which the row shares.
108const MODE_COLUMNS = 26
109
110/** The meters in place of the engine's hint line, which keeps the mode label before it. The engine's line shows until a figure is known. */
111export async function drawMeters(
112  $: EngineInterface,
113  e: RenderInput<'PromptHint'>,
114  next: (e: RenderInput<'PromptHint'>) => Promise<RenderElement>,
115): Promise<RenderElement> {
116  const { beat: _beat, ...now } = await read($, reading)
117  const columns = (e.viewport?.columns ?? 100) - MODE_COLUMNS
118  const shown = fit(meters(now, Date.now()), columns)
119  if (!shown.length) return next(e)
120
121  const { Box, Text } = $.ui.resolve(e)
122  const cells = shown.flatMap((meter, at) => [
123    at ? (
124      <Text key={`bar-${meter.id}`} dimColor>
125        {SEPARATOR}
126      </Text>
127    ) : (
128      ''
129    ),
130    meter.label ? (
131      <Text key={`label-${meter.id}`} dimColor>
132        {meter.label}{' '}
133      </Text>
134    ) : (
135      ''
136    ),
137    ...(meter.parts ?? [{ text: meter.value }]).map((part, i) => (
138      <Text key={`value-${meter.id}-${i}`} color={(part.colour ?? TONES[meter.tone]) || undefined} dimColor={part.dim}>
139        {part.text}
140      </Text>
141    )),
142  ])
143  return <Box>{cells}</Box>
144}
145