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…

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.
home/: the templates for the global rule file and Claude Code's settingsskills/: the /flow:* skills, one per phase and toolscripts/: the flow command and its hooks, in Node and bashmods/flow-session/: Flow's Claude Code mod, in TypeScript: the ticket badges, the meters under the prompt and the restart toolproject-template/: the files flow init writes into a projectdocs/spec/: what each part of Flow does, and whydocs/manual/: every command, file, setting and skill, and how a session restartslab/: the maintainers' workspace, with the archive of retired codecurl -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.
hooks/register.ts 636 lines1/**
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}
636hooks/ask.ts 15 lines1/**
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}
15hooks/restart-guide.ts 31 lines1/**
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}
31hooks/button.ts 48 lines1/**
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}
48hooks/copy.ts 80 lines1/**
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}
80hooks/dialogue.ts 37 lines1/**
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}
37hooks/first-message.ts 103 lines1/**
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}
103hooks/window.ts 197 lines1/**
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}
197hooks/drag.ts 57 lines1/**
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}`)
57hooks/band.tsx 445 lines1/**
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}
445hooks/keepwarm.ts 283 lines1/**
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}
283hooks/meters-row.tsx 145 lines1/**
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