ruflo's cockpit inside Claude Code and the one /ruflo command for every ruflo mod (function hooks, early access). Views: overview with health alerts, swarm…

ruflo's cockpit inside Claude Code: the /ruflo command, its pages, and a band above the prompt. It reads ruflo's own files; anything it cannot measure reads n/a. Design notes: ADR-407 (cockpit), ADR-448 (the Room), ADR-446 (plugin mods).
scripts/drive.sh runs the console through its own model tools (console_open, console_state, console_set, console_run) from a real headless Claude, prints what each tool answered, and can assert on it, so a dev loop or CI job can check that a UI shows something.
RUFLO_E2E_LIVE=1 bash plugins/ruflo-console/scripts/drive.sh \
--expect 'Waiting for a yes' \
"$PWD/plugins/ruflo-console" read "Open the Room and quote back what is waiting for a yes."
Output is one CALL <tool> <input> and one RESULT <tool> <text> line per console call (each result cut at 4000 characters), then COST <usd> CALLS <n>, then one EXPECT ok|FAIL /regex/ line per --expect.
| Exit | Meaning |
|---|---|
| 0 | a console call ran and every --expect matched (or the run was skipped, see below) |
| 1 | no console tool call ran |
| 2 | usage error: bad level or an invalid --expect regex |
| 3 | at least one --expect regex (case-insensitive, repeatable) matched no RESULT |
| 4 | CONSOLE_DRIVE_INSTALL was set and the marketplace add or install failed |
--max-budget-usd). Use one to three runs, not a sweep.CLAUDE_CONFIG_DIR seeded with the level you ask for and confirm mode auto (your login is copied 0600 and shredded on exit), because RUFLO_CONSOLE_CONTROL can only lower the saved setting (ADR-450 T12) and your own saved mode would otherwise decide what a drive does.read, write or manage; full is rejected (exit 2). The run happens in a scratch project (mktemp -d), with Bash, Write and Edit disallowed, so a drive can never spend money or delete anything.CONSOLE_DRIVE_SEED=<dir> copies that directory into the scratch .claude-flow/ first, for example a claims/claims.json to assert the claims view shows it.RUFLO_E2E_LIVE=1 or a claude binary (on PATH or $CLAUDE_BIN) it prints SKIP and exits 0, so it is safe to call from CI.--plugin-dir. That loads a plugin but does not install it, so claude plugin configure cannot see it.CONSOLE_DRIVE_INSTALL=<marketplace-dir>:<plugin>@<marketplace> registers that directory as a marketplace and installs the plugin (user scope) inside the throwaway config only, then runs the drive; the Settings view can then read the plugin's options. Your real ~/.claude/plugins and saved console settings are never touched, and claude plugin update is never run. Prints INSTALLED <id>; a bad value exits 2, a failed install exits 4.CONSOLE_DRIVE_INSTALL="$PWD:ruflo-mods@ruflo" RUFLO_E2E_LIVE=1 bash plugins/ruflo-console/scripts/drive.sh \
--expect 'Hide unused agent types' "$PWD/plugins/ruflo-console" read "Open view settings with chip mods, then console_state, and quote the ruflo-mods option rows."
TOOLS → ADRs manages the Architecture Decision Records of the project you run Claude Code in (not ruflo's own).
docs/adr, docs/adrs, doc/adr, adr, docs/architecture/decisions, docs/decisions and architecture/decisions, or the folder in Settings → ADR folder. A folder that is a link is never read. With none, initialise ADRs here asks first, then creates the folder and 0001-record-architecture-decisions.md; nothing is overwritten.## Status sections (Superseded by 5. …), ruflo-style Status: lines, log4brains and plain markdown with no status all parse; a file that does not parse still lists. Filter by status, words and scope; the detail shows links both ways, the decision, and the missions it is attached to; the health strip is the lint./ruflo run adr-propose <title>, adr-accept <n>, adr-supersede <old> <new>, adr-attach <n> and the rest are palette entries, so Claude reaches them through console_run at the write control level (reading the page is read); a change still waits for your Yes on the diff.ruflo tui (ADR-489)The same views the mod draws, on five pages (Overview, Cost, Swarm, Events, Missions), drawn by a small standalone terminal host instead of Claude Code, and a sixth, AI, that asks Codex or OpenRouter. The five are read-only; the AI page shows a card of exactly what would be sent and sends only after a y; npx ruflo tui (the ruflo package and @claude-flow/cli carry the bundle).
npx ruflo tui # interactive: 1-6 or o c s e m a pick a page, Tab/Shift-Tab cycle, j/k, PgUp/PgDn, g/G, r refresh, q quit
npx ruflo tui --view swarm # start on the Swarm page
npx ruflo tui --view ai --model openai/gpt-4o-mini --max-spend-usd 0.1 # ask OpenRouter on a cheap model, stopping at $0.10 reported (key in $OPENROUTER_API_KEY, never shown)
npx ruflo tui --no-ai # without the AI page
npx ruflo tui --plain | head -40 # one plain frame, exit 0: for scripts and CI
npx ruflo tui | cat # no terminal and no --plain: a one-line hint on stderr, exit 2
From a checkout, build the bundle first: node plugins/ruflo-console/scripts/build-tui.mjs. Room is not in the terminal (it is a message box and Claude sessions). AI page: Ctrl-T picks Codex or OpenRouter, Enter shows the card, y sends, n or Esc cancels, Ctrl-S stops a running answer, Esc hands the keys back to the page bar. Codex runs read-only and only as the pinned 0.162.0 release (never current or PATH); OpenRouter goes to openrouter.ai over https, or to a host a --convo-targets entry names (endpoint:<name>=<https-base-url>|<KEY_ENV>|<model>); a key is read by its variable name and never drawn. Details and what is not done yet: ADR-489 section 4. A terminal narrower than 60 columns gets a short page bar, and under 40 a one-line notice.
Settings → Interface and updates → Toasts sets what the ruflo plugins may show over the transcript: all, important (warnings and errors) or off, with a mute chip each for console, swarm, protector and mods. It is kept in the console's store and mirrored to .claude-flow/console/toast-prefs.json, which the other plugins read. Every toast, drawn or not, becomes an event on the Events page (toast <source> <glyph> <text> [off|muted|deduped|…]); the other plugins' digests are read from .claude-flow/console/toasts/<source>.jsonl, masked and capped. The console itself also toasts a mission that finishes (ok) or loses a task (error), and its update notes by level. Design and contract: ADR-477.
Open it with /ruflo room (menu: Safety → The Room).
sessionWorkspace and sessionPreview (both on)..claude-flow/<name>-mod/status.json. Press a line for its detail: what it guards (the file's own summary), guard, calls, blocked, the class of its last refusal (secret, destructive, path, network, policy, other; the refused text is never kept), modVersion, session start, last write, file age, and a stale marker when the last write was an earlier session.A status file is data, not instructions: it is size-capped, shape-checked (version: 1 only), and every string is stripped of control and bidi characters and cut to length before it is drawn. summary, modVersion and lastDenied are optional; a mod that does not write them shows "not reported".
An optional Project Anatole section on the Security & Doctor page (key u) lists, runs and edits the ruflo-protector mod (ADR-453). Without the plugin it is one line: claude plugin install ruflo-protector@ruflo.
.claude-flow/protector-mod/status.json, rules.json and the last 200 lines of alerts.jsonl through the bounded, regular-file-only reader (a file over 64 KB is refused; every field is whitelisted and cleaned). It is labelled reported by the mod, unauthenticated: any process can write those files.degraded; one row per rule (OWASP refs, severity, an off · notify · block chip, hits and acked share over the last 200 alerts, "changed from default"); the open alerts with ack and allow. The Findings meter counts open alerts, labelled as Anatole's./protector run and /protector replay into the page's Result panel./protector; every change asks first and its confirm row starts with Effect:. enforce is declared an install-class action and reset-baseline a delete-class one, so Claude's console tools always wait for you on both (palette ids anatole-mode, anatole-rule, anatole-ack, anatole-allow, anatole-reset, anatole-run, anatole-replay).An optional dark telemetry look inspired by compact system monitors: black panel background, square red borders, warm red headings, muted readable values and green health indicators. It uses the existing live views and command palette; no extra polling, simulated metrics or model calls. BBS remains the default.
Choose Settings → ruflo-console → Look → orbital, or run in your shell:
printf '%s\n' '{"look":"orbital"}' | claude plugin configure ruflo-console@ruflo --values-stdin
Reload plugins after changing the option. Select bbs or plain to restore those looks. A dark terminal background gives the closest match; terminal graphics and host controls keep their existing renderer. Narrow panes retain the existing responsive layout.
Orbital keeps the original Ruflo block mark visible after boot on every page, above navigation. Wide headers use an eight-row composition with a shaded block extrusion, moving highlight, rotating ASCII Earth and travelling scan. Smaller panes retain the original two-row block mark inside a compact header. The optional boot intro uses the same Earth rotation (one turn per 12 seconds), with actual self-check counts below it. It runs for 1.5 seconds when data is ready, at most 6 seconds while data is pending. Set fps to 0 for still headers and no boot intro. Existing frame scheduling stops motion in hidden or unfocused panes. The sweep and globe are decoration, not measured activity or a progress percentage.
Orbital adapts to the pane allocation. Below 48 drawing columns or 30 available rows, boot uses a compact check summary of at most nine rows. Failure totals remain near the top, with the first failing area shown when a third row fits. The persistent header uses its expanded layout from 78 drawing columns with eight available header rows. Short panes keep two rows; the native host's text-only mode below 44 pane columns retains a text Ruflo identity. Header width reserves the existing toolbar icon space and is capped at 120 columns. Resize dimensions are recorded before the header is mounted so the animation loop continues at the new size. The existing live topology remains in the Swarm page body; Theme Studio provides the decorative orchestration preview.
Open Main Menu → Tools → Theme Studio, the Tools navigation group, or /ruflo themes. Settings also links to the studio. Choose Classic BBS, Terminal plain or Orbital. Studio selections persist and override the plugin's configured look until Use configured look is selected.
Six bundled Color presets load with one click and persist across sessions. They retain the animated Ruflo header, globe, stars and topology at standard speed. Bundled presets do not consume the twelve custom theme slots. Every primary foreground role meets at least 4.5:1 contrast against its background; decorative shading may be dimmer.
| Preset | Palette |
|---|---|
| Arctic | Ice cyan, seafoam and deep navy |
| Synthwave | Orchid, mint and dark violet |
| Amber CRT | Warm amber, parchment and dark bronze |
| Emerald | Jade, pale green and forest black |
| Solar Flare | Peach, gold and dark terracotta |
| Moonlight | Periwinkle, silver and midnight blue |
Select a preset to load it immediately. Edit colors or motion and choose Save and apply to create a custom copy; bundled originals remain available. Portable version 1 JSON presets also load through AI → Import theme JSON, followed by Save and apply. Use configured look restores the plugin setting.
The demo drives actual console view handlers through boot, Main Menu, all six presets, custom color and motion, save, fresh state restoration from a local JSON store, portable JSON import, reduced motion, and 80/116 column layouts. It checks each transition and writes receipt.json. This is a renderer integration demo, not a native Claude host recording: other console actions are not exercised, the FPS setting is changed by the harness, and no AI provider is invoked.
From the repository root, with Node:
npx tsx plugins/ruflo-console/scripts/theme-demo.mts /tmp/ruflo-demo-new
Use a fresh output directory. The capture refuses to overwrite an existing demo-session.json; omitting the directory creates a unique temporary directory and prints its path. Outputs are frames.json (the captured terminal cells for every step, with a caption, palette and column count) and receipt.json (what was and was not exercised: no native host, no AI provider). Turning the frames into a GIF or video is left to your own renderer; the repository ships none, so no Python or image library is needed.
For a genuine animated vector export of those same captured frames, run (Node only):
node plugins/ruflo-console/scripts/render-theme-svg.mjs /tmp/ruflo-demo-new
ruflo-console-demo.svg contains text runs, block rectangles and reusable row symbols (the font is your system monospace), replayed with native SVG discrete animation over the same 38 seconds. It has no embedded images, video, scripts or other external assets. The complete 456-frame replay is approximately 20 MB. It scales through its viewBox; open the file in an SVG animation capable browser when a document preview shows only a still frame.
The terminal color picker provides swatches and exact #RRGGBB entry for headings, success, failure, warning, information and background. Preview the globe and swarm motion, adjust speed and decorative layers, then Save preview and apply. Up to twelve named custom presets are stored locally through the host; reusing a name replaces that preset. Failed saves leave the active theme unchanged. Settings fps: 0 still disables animation. Layouts retain the compact/narrow fallbacks.
Generate with Claude or Generate with Codex runs the bundled generate-console-theme skill through AI Terminal with its existing confirmation and model controls. Enter the design brief first, generate a preset, then paste the returned JSON into Import theme JSON and review before saving. The generator produces palette and motion variations of the existing Orbital renderer, not executable animation code. No additional API credentials are requested. Installed Claude plugins discover the bundled skill; Codex can read plugins/ruflo-console/skills/generate-console-theme/SKILL.md directly in this checkout.
Imports are limited to 4096 characters and a strict version 1 data schema. Status labels and actual check results remain unchanged. Saving requires at least 3:1 foreground/background contrast; the generator aims for 4.5:1.
Theme Studio uses focused Colors, Motion and AI tabs. At 100 content columns it places the editor beside a persistent globe and routing preview; narrower panes stack both panels with a compact routing strip. A single color role picker replaces the repeated swatch lists. Minimum contrast is shown before saving, and Discard draft changes restores the active palette without deleting saved themes.
hooks/register.ts 500 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { tolerantPress } from './press-guard'
3import { ANSWER_KEYS } from './views/attention'
4import { rootedPath } from './data/abs-path'
5import { createController, type Controller } from './controller'
6import { startSessions } from './sessions'
7import { record } from './data/events'
8import { plain } from './data/parse'
9import { dispatch } from './dispatch'
10import { markPicture } from './gfx/pictures'
11import type { Host } from './host'
12import { ownerLine, ownerOf } from './tool-owner'
13import { newState, PANE_ID, restore, restoreSessions, storeKeyOf, termStoreKeyOf } from './state'
14import { BAR_KEY, barView } from './views/bar'
15import { addNotice, dismissNotices } from './notices'
16import { setBootChecks } from './boot-checks'
17import { startWriteFlavorDetection } from './data/write-flavor'
18import { buildOf, isOurCheckout, setBuild } from './build'
19import { runUpdateCheck } from './update-flow'
20import { announceModelTools, parseControlEnv, serveModelTools } from './model-tools'
21import { loadAiPrefs, setControlCap } from './settings'
22import { hydrateWhatsNew } from './whatsnew'
23import { prefsFromStore, recordToast, saveToastPrefs, TOASTS_KEY } from './toasts'
24import { contextSection, onPromptSubmit, onTurnComplete } from './mission-claude'
25import { parseMode, RECHECK_EVERY_MS, UPDATES_KEY } from './updates'
26import { selfCheckResults } from './self-check'
27import { kitForSurface, type Kit } from './views/common'
28import { createToaster, type Digest, type ToastLevel, type ToastPrefs } from './toast-policy'
29import { picturesOf } from './views/frames'
30import { withClearing } from './views/clearing'
31import { NARROW, paneView } from './views/pane'
32
33const RUFLO_TOOL = /^mcp__(claude-flow|ruflo|plugin_ruflo[\w-]*)__/
34
35/**
36 * Binds a Host from `$`, every member spelled `$.noun.method(...)` here and nowhere else, so the engine reads what
37 * the module calls off this one place. Calls that answer nothing are wrapped: a refused draw is not a crashed hook.
38 */
39/** True while the console itself scrolls the pane to its top, so the terminal's own wheel handling does not take that for the person's wheel. */
40let isResettingScroll = false
41
42function hostOf($: EngineInterface, cwd: string, toasts: { prefs: () => ToastPrefs; record: (digest: Digest) => void }): Host {
43 const rooted = (path: string) => rootedPath(cwd, path)
44 const quietly = (fn: () => unknown) => {
45 try {
46 const result = fn()
47
48 if (result instanceof Promise) result.catch(() => undefined)
49 } catch {
50 // Refused: there is nothing to do about a draw nobody may make.
51 }
52 }
53
54 // ADR-477: every toast of the console goes through the shared policy (levels, one clean line, de-duplication, a rate limit, the person's
55 // Toasts setting); drawn or not, each is recorded for the Events page. A refused draw is a refused toast, never a crash.
56 const toaster = createToaster({
57 source: 'console',
58 now: () => Date.now(),
59 show: (line, options) => $.ui.toast(line, options),
60 after: (ms, fn) => $.clock.after(ms, fn),
61 prefs: toasts.prefs,
62 persist: toasts.record,
63 })
64
65 return {
66 fs: { read: async path => $.fs.read(rooted(path)), stat: async path => $.fs.stat(rooted(path)), list: async path => $.fs.list(rooted(path)) },
67 every: (ms, fn) => $.clock.every(ms, fn),
68 after: (ms, fn) => $.clock.after(ms, fn),
69 storeGet: async key => $.store.get(key),
70 storeSet: async (key, value) => $.store.set(key, value as never),
71 fetchText: async url => {
72 const response = await $.http.fetch(url)
73
74 return { ok: response.ok, status: response.status, text: response.text }
75 },
76 askChoice: async (question, options) => $.ui.ask(question, options),
77 toast: (text, timeoutMs, level: ToastLevel = 'info') => quietly(() => void toaster.toast({ level, text, ...(timeoutMs !== undefined && { timeoutMs }) })),
78 invalidate: () => quietly(() => $.ui.invalidate('ui.render')),
79 // Once now and once after the new page has drawn: a page taller than the one before keeps the old offset until it is moved.
80 scrollTop: () => {
81 const go = () =>
82 quietly(() => {
83 isResettingScroll = true
84
85 return Promise.resolve($.ui.scroll({ to: 'start', in: PANE_ID })).finally(() => {
86 isResettingScroll = false
87 })
88 })
89
90 go()
91 $.clock.after(80, go)
92 },
93 focus: async (paneId, key) => $.ui.focus({ requestId: paneId, key }),
94 blit: args => quietly(() => $.ui.blit(args)),
95 openPane: async pane => $.ui.open(pane),
96 closePane: async id => $.ui.close({ id }),
97 panes: async () => $.ui.panes(),
98 registerCommand: async spec => $.command.register(spec),
99 run: async (argv, timeoutMs, stdin) => $.process.run(argv, { cwd, timeoutMs, ...(stdin !== undefined && { stdin }) }),
100 spawn: (argv, input) => $.process.spawn({ argv, cwd, ...(input !== undefined && { input }) }),
101 usage: async () => {
102 const usage = await $.session.usage()
103
104 return { ...(usage.cost?.usd !== undefined && { costUsd: usage.cost.usd }), ...(usage.context?.percent !== undefined && { contextPercent: usage.context.percent }) }
105 },
106 rufloTools: async () => {
107 const names = (await $.tool.list()).flatMap(tool => RUFLO_TOOL.exec(tool.name)?.slice(1, 2) ?? [])
108
109 return { tools: names.length, servers: [...new Set(names)].sort() }
110 },
111 settings: async () => $.settings.read(),
112 home: async () => $.env.get('HOME'),
113 configDir: async () => $.env.get('CLAUDE_CONFIG_DIR'),
114 pluginRoot: $.plugin.root,
115 // `$.ruflo` exists only where ruflo-mods is seated; validate refuses feature-detecting a noun, so these are
116 // async: a missing noun throws inside the promise and every caller's catch sees a rejection.
117 rufloSnapshot: async () => $.ruflo.snapshot(),
118 rufloRoute: async () => $.ruflo.lastRoute(),
119 rufloSegment: async text => $.ruflo.segment({ id: 'console', text }),
120 // Both wait on the turn, so neither may be called from inside a command.run hook (`/ruflo yes` is one): they run from a clock
121 // tick, a later event of their own.
122 submitPrompt: text =>
123 new Promise<void>((resolve, reject) => {
124 $.clock.after(1, () => void $.prompt.submit({ text }).then(() => resolve(), reject))
125 }),
126 fillPrompt: async text => (await $.prompt.fill({ text, mode: 'replace' })).isFilled,
127 runSlash: (command, args) =>
128 new Promise((resolve, reject) => {
129 $.clock.after(1, () => void $.command.run({ command, args }).then(resolve, reject))
130 }),
131 listCommands: async () => (await $.command.list()).map(command => command.name),
132 // ADR-465. A tool call waits on the turn like submitPrompt, so it starts from a clock tick, never inside the hook that asked.
133 toolCall: input =>
134 new Promise((resolve, reject) => {
135 $.clock.after(1, () => void $.tool.call(input as never).then(reply => resolve(reply as never), reject))
136 }),
137 toolCheck: async (tool, input) => $.tool.check({ tool, input }),
138 httpSend: async (url, init) => {
139 const response = await $.http.fetch(url, init)
140
141 return { ok: response.ok, status: response.status, text: response.text }
142 },
143 }
144}
145
146/**
147 * ruflo-console: ruflo's cockpit inside Claude Code, and the home of `/ruflo`. A pane of views over ruflo's state on
148 * disk and the ruflo CLI's local answers, a band above the prompt, a command palette, and management views (agent
149 * drill-down, timeline, approvals, events). Every change goes through the ruflo CLI with fixed argv after a confirm.
150 */
151export const register: Register = (on, raw: PluginOptions) => {
152 // The boot log reports this check, so an [ OK ] on screen means the area's commands resolved. It spawns nothing and takes a
153 // moment; a failure of the check itself leaves the log drawing as it did, never stops the console.
154 try {
155 setBootChecks(selfCheckResults())
156 } catch {
157 setBootChecks(undefined)
158 }
159
160 const state = newState(raw)
161 let host: Host | null = null
162 let control: Controller | null = null
163
164 // Claude's console tools (ADR-444): answered only for their own names, and only when the person's setting lets them exist.
165 serveModelTools(on, () => (control === null ? null : { state, control }))
166
167 on('session.start', async ($, e, next) => {
168 control?.stop()
169 host = hostOf($, e.cwd, { prefs: () => state.toastPrefs, record: digest => recordToast(state, digest) })
170 // Which write argv the host takes (GNU dd/install on Linux, the constant sh scripts on macOS/BSD). Started before the controller (whose
171 // timers write) exists; every write awaits it (writeFlavorReady), so none is built with a guessed flavor.
172 const detecting = host
173 void startWriteFlavorDetection((argv, timeoutMs) => detecting.run(argv, timeoutMs))
174 state.cwd = e.cwd
175 state.nostrKeyVerifiedAtMs = null
176 state.isInteractive = e.isInteractive !== false
177 control = createController(state, host)
178
179 // Which build is this? Only a checkout of this plugin in its repository is read (an installed copy inside some other repo is not
180 // that repo's commit); read-only, $0, and any failure leaves the header at its version alone.
181 const here = host
182 const root = here.pluginRoot
183
184 const built = here
185 .run(['git', '-C', root, 'rev-parse', '--show-prefix'], 3_000)
186 .then(prefix => (prefix.exitCode === 0 && isOurCheckout(prefix.stdout) ? here.run(['git', '-C', root, '--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', 'describe', '--always', '--dirty', '--abbrev=7'], 3_000) : null))
187 .then(described => {
188 setBuild(described !== null && described.exitCode === 0 ? buildOf(described.stdout) : '')
189 here.invalidate()
190 })
191 .catch(() => undefined)
192
193 // The update mode is the person's, kept in the plugin's store; then, once the build is known (a development checkout is never offered
194 // an update) and the screen has settled, one check for a newer published version. It never throws and never blocks the console.
195 const moded = here.storeGet(UPDATES_KEY).then(
196 value => {
197 state.updates = parseMode(value)
198 here.invalidate()
199 },
200 () => undefined,
201 )
202
203 // The Toasts setting is the person's, kept in the plugin's store and mirrored to a file the other plugins read (ADR-477).
204 const toasted = here.storeGet(TOASTS_KEY).then(
205 value => {
206 state.toastPrefs = prefsFromStore(value)
207 if (state.toastPrefs.mode !== 'all' || state.toastPrefs.muted.length > 0) void saveToastPrefs(state, here)
208 },
209 () => undefined,
210 )
211
212 // What's new (ADR-478): the record of what was looked at, read once; the controller's first disk read then takes the baseline.
213 const looked = hydrateWhatsNew(state, here).catch(() => undefined)
214
215 void toasted
216 void looked
217 void Promise.all([built, moded]).then(() => {
218 if (!state.isInteractive) return
219
220 here.after(2_500, () => void runUpdateCheck(state, here))
221 // A session left open for days re-asks too, quietly (no dialog mid-work); the daily gate keeps the network to once a day.
222 state.timers.set('update-recheck', here.every(RECHECK_EVERY_MS, () => void runUpdateCheck(state, here, { quiet: true })))
223 })
224
225 const bound = host
226
227 state.home = (await bound.home().catch(() => undefined)) ?? null
228 state.configDir = (await bound.configDir().catch(() => undefined)) ?? (state.home === null ? null : `${state.home}/.claude`)
229 // A recording or a wide screen can ask for a wider dock: RUFLO_CONSOLE_COLUMNS, whole columns, 40 to 400.
230 const asked = Number(await (async () => $.env.get('RUFLO_CONSOLE_COLUMNS'))().catch(() => ''))
231
232 // RUFLO_CONSOLE_PANEL=command|off overrides the panel option for this session (a recording that shows /ruflo opening it).
233 const panel = await (async () => $.env.get('RUFLO_CONSOLE_PANEL'))().catch(() => undefined)
234
235 if (panel === 'command' || panel === 'off') state.options.panel = panel
236 state.dockColumns = Number.isInteger(asked) && asked >= 40 && asked <= 400 ? asked : 0
237 // The x.ruv.io board's admin rows: only whether the token is set is kept, never its value.
238 state.xruv.hasAdminToken = await (async () => $.env.get('RUFLO_X_ADMIN_TOKEN'))().then(
239 value => typeof value === 'string' && value !== '',
240 () => null,
241 )
242 await Promise.all([
243 bound
244 .registerCommand({ name: 'ruflo', description: 'ruflo: the cockpit (views, palette, agents, approvals) and every ruflo mod command — /ruflo help', argumentHint: '[view|palette|agent <id>|mods|swarm <sub>|help]' })
245 .catch(() => undefined),
246 // Kept for good (ADR-406: no command is removed or renamed): `/ruflo-console` is the same command as `/ruflo`.
247 bound.registerCommand({ name: 'ruflo-console', description: 'Same as /ruflo: the ruflo console', argumentHint: '[view|palette|help]' }).catch(() => undefined),
248 bound.storeGet(storeKeyOf(e.cwd)).then(value => restore(state, value), () => undefined),
249 bound.storeGet(termStoreKeyOf(e.cwd)).then(value => restoreSessions(state, value), () => undefined),
250 bound.rufloTools().then(counted => void (state.rufloTools = counted), () => undefined),
251 ])
252 control.start()
253 startSessions(state, bound)
254 await control.refresh()
255 control.autoOpen()
256
257 // Declare the console tools to the model when control is on: the saved setting, or this session's RUFLO_CONSOLE_CONTROL=<level>:<ask|auto>, which can only lower it.
258 await loadAiPrefs(state, bound).catch(() => undefined)
259
260 const forced = parseControlEnv(await (async () => $.env.get('RUFLO_CONSOLE_CONTROL'))().catch(() => undefined))
261
262 // The override may only lower what the person saved (ADR-450 T12): a project's settings env must not raise Claude's control.
263 // It is kept as session state and applied on every load and save of the preferences, so opening Settings cannot lift it (#3814).
264 setControlCap(state, forced === null ? null : { level: forced.level, confirm: forced.confirm })
265 await announceModelTools(tool => $.tool.register(tool), state).catch(() => 0)
266
267 return next(e)
268 })
269
270 on('session.end', async ($, e, next) => {
271 control?.stop()
272
273 return next(e)
274 })
275
276 /**
277 * `/ruflo`: the console's own subcommands are answered here; `mods` and `swarm <sub>` go to the plugins beneath that
278 * hook the same command (ruflo-mods, ruflo-swarm), and are answered with a hint when neither does.
279 */
280 on('command.run', { command: 'ruflo' }, async ($, e, next) => {
281 if (control === null) return next(e)
282
283 return dispatch(control, state, e.args, async () => (await next(e)) as { text?: string } | undefined)
284 })
285
286 /** `/ruflo-console` is the same command: ruflo-mods and ruflo-swarm hook it as they hook `/ruflo`. */
287 on('command.run', { command: 'ruflo-console' }, async ($, e, next) => {
288 if (control === null) return next(e)
289
290 return dispatch(control, state, e.args, async () => (await next(e)) as { text?: string } | undefined)
291 })
292
293 // Which element was pressed or submitted, before its own closure runs: the runner reads it as the origin of the ask that follows, so
294 // the page puts the confirm and the answer right under it (views/attention.ts). Answering a confirm never moves the origin.
295 on('ui.press', { component: 'Pane' }, ($, e, next) => {
296 if (!ANSWER_KEYS.has(e.element)) state.lastPressed = e.element
297
298 // A click that reaches the engine after its drawing was replaced (a resize) finds no handler: answered quietly (press-guard.ts).
299 return tolerantPress(() => next(e), { element: e.element })
300 })
301
302 on('ui.input', { component: 'Pane' }, ($, e, next) => {
303 if (e.kind === 'submit') state.lastPressed = e.element
304
305 return next(e)
306 })
307
308 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
309 if (control === null) {
310 return next(e)
311 }
312
313 const started = Date.now()
314 const table = kitForSurface($.ui.resolve(e) as unknown as Kit, e.surface)
315 const columns = Math.max(20, Math.floor(Number(e.props.bodyColumns) || 0) - 1)
316 const isNarrow = columns < NARROW
317 const kit: Kit = isNarrow ? { Box: table.Box, Text: table.Text, Button: table.Button, ...(table.Input !== undefined && { Input: table.Input }) } : table
318
319 if (!state.pane.isOpen) state.pane.bootAtMs = Date.now()
320 state.pane.isOpen = true
321 state.pane.isFocused = e.props.isFocused === true
322 state.pane.columns = columns
323 state.pane.placement = e.props.placement
324 // Height-dependent art must use this render's allocation before its Raster size is recorded.
325 state.pane.rows = Math.max(0, Math.floor(Number(e.props.scroll?.bodyRows) || 0))
326 // A reload while the pane stayed up: timers are gone, so resume them from here.
327 if (!state.timers.has('watch')) control.resume()
328
329 const pictures = isNarrow ? new Map() : picturesOf(state, columns, Date.now(), Date.now(), kit.Raster === undefined)
330
331 state.mounted = kit.Raster === undefined ? new Map() : new Map([...pictures].map(([key, grid]) => [key, { columns: grid.columns, rows: grid.rows }]))
332 state.pictured = pictures.size
333 control.animate()
334
335 const tree = paneView({ kit: withClearing(kit, state, control.actions.clearField, { columns, repaint: () => host?.invalidate() }), state, nowMs: Date.now(), columns, pictures, act: control.actions })
336
337 state.stats.renders.push(Date.now() - started)
338 if (state.stats.renders.length > 200) state.stats.renders.shift()
339
340 return tree
341 })
342
343 // The AI terminal's conversation is its own window: the wheel and the page keys over the pane move it, so the header,
344 // tabs and the field below stay where they are (the engine would scroll the whole pane).
345 on('ui.scroll', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
346 if (control === null || state.view !== 'terminal' || e.by === 0 || isResettingScroll) return next(e)
347
348 const lines = Math.abs(e.by) >= e.bodyRows ? Math.max(1, Math.round(e.bodyRows / 2)) : Math.abs(e.by) * 3
349
350 control.actions.term.scroll(e.by < 0 ? lines : -lines)
351
352 // The pane itself stays put: ask the engine for the offset it already has.
353 return next({ ...e, offset: e.offset - e.by })
354 })
355
356 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
357 const mode = state.bandMode ?? state.options.bar
358 const show = mode === 'on' || (mode === 'auto' && state.snapshot?.isRufloProject === true)
359
360 if (control === null || e.props.hasSurvey || !show) {
361 return next(e)
362 }
363
364 const table = kitForSurface($.ui.resolve(e) as unknown as Kit, e.surface)
365 const bound = control
366
367 state.barDrawnAtMs = Date.now()
368
369 const mark = table.Raster !== undefined ? table.Raster(markPicture(e.props.isWorking, Date.now()).toRaster(BAR_KEY)) : null
370
371 state.turnActive = e.props.isWorking === true
372 bound.markFrame(e.requestId, e.props.isWorking && mark !== null)
373
374 // A click on a part opens the console on its view, with the keys, so the person can act there at once.
375 return barView(table, state, Math.floor(Number(e.props.bodyColumns) || 80), mark, () => void bound.open(false), view => {
376 bound.setView(view)
377 void bound.open(true)
378 }, () => {
379 dismissNotices(state)
380 try {
381 $.ui.invalidate('ui.render')
382 } catch {
383 // A refused redraw leaves the notice showing until the next one.
384 }
385 })
386 })
387
388 // A tool row that ran while one mission task was running says which: one dim line under the engine's own row.
389 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
390 const owner = ownerOf(state, e.props.tool_use_id, e.props.isRunning === true)
391
392 if (owner === null) return next(e)
393
394 const table = $.ui.resolve(e) as unknown as Kit
395 const own = await next(e)
396
397 return table.Box({ key: `owner-${e.props.tool_use_id}`, flexDirection: 'column', children: [own, table.Text({ dimColor: true, children: ` ${ownerLine(owner)}` })] })
398 })
399
400 /** The band's mark pulses during a turn: a redraw at its start, and the loop stopped at its end, whatever redraws. */
401 on('turn.start', ($, e, next) => {
402 // A new turn: the per-turn cap on Claude's console actions starts over.
403 state.control.turnCalls = 0
404 if (e.agentId === undefined) state.turnStartedMs = Date.now()
405
406 try {
407 $.ui.invalidate('ui.render')
408 } catch {
409 // A refused redraw leaves the mark at rest.
410 }
411
412 return next(e)
413 })
414
415 on('turn.complete', ($, e, next) => {
416 if (e.agentId === undefined && state.turnStartedMs !== null) {
417 // A long turn that ends while nobody watches is worth saying: the band announces it (a short one is not news).
418 const took = Date.now() - state.turnStartedMs
419
420 if (took >= 30_000) addNotice(state, { level: 'ok', text: `✓ Claude finished a turn · ${took < 60_000 ? `${Math.round(took / 1000)}s` : `${Math.floor(took / 60_000)}m ${Math.round((took % 60_000) / 1000)}s`}`, key: 'turn-done' })
421 }
422 if (e.agentId === undefined) state.turnStartedMs = null
423 if (e.agentId === undefined) control?.markFrame('', false)
424 if (e.agentId === undefined && host !== null) {
425 try {
426 onTurnComplete(state, host, e.reason)
427 } catch {
428 // A note that could not be recorded never changes the turn.
429 }
430 }
431
432 return next(e)
433 })
434
435 // The mission Claude is working on rides in the system prompt (ADR-443); the text changes only when the task does.
436 on('prompt.compose', async ($, e, next) => {
437 const result = await next(e)
438 const section = contextSection(state)
439
440 return section === null ? result : { sections: [...result.sections, section] }
441 })
442
443 // A prompt carrying a mission's loop marker is that loop's tick.
444 on('prompt.submit', ($, e, next) => {
445 if (host !== null) {
446 try {
447 onPromptSubmit(state, host, e.text)
448 } catch {
449 // Counting a tick never blocks the prompt.
450 }
451 }
452
453 return next(e)
454 })
455
456 on('ui.close', async ($, e, next) => {
457 const result = await next(e)
458
459 if (e.id === PANE_ID && result.deny === undefined) {
460 state.pane.isOpen = false
461 state.pane.isShown = false
462 if (e.origin.kind === 'person') control?.closedByPerson()
463 control?.animate()
464 }
465
466 return result
467 })
468
469 /** Observes only: every call goes on unchanged; the count feeds the activity sparkline. */
470 on('tool.call', ($, e, next) => {
471 control?.noteToolCall(e.agentId, e.tool)
472
473 return next(e)
474 })
475
476 /** Observes only: a deny any verdict reached is listed in the approvals queue; the verdict passes on unchanged. */
477 on('tool.check', async ($, e, next) => {
478 const verdict = await next(e)
479
480 if (verdict.decision === 'deny') {
481 state.denied.push({ tool: plain(String(e.tool), 40), reason: plain(verdict.reason ?? 'no reason given', 160), atMs: Date.now() })
482 if (state.denied.length > 20) state.denied.shift()
483 record(state.events, [{ atMs: Date.now(), kind: 'mods', text: `${plain(String(e.tool), 40)} denied: ${plain(verdict.reason ?? '', 80)}` }])
484 }
485
486 return verdict
487 })
488
489 /** Observes only, never refuses: which mods the engine admitted or refused after the console, for the plugins view. */
490 on('plugin.register', async ($, e, next) => {
491 const result = await next(e)
492
493 state.mods.push({ name: plain(e.name, 40), provenance: plain(e.provenance, 80), isLoaded: result.refuse === undefined, ...(result.refuse !== undefined && { reason: plain(result.refuse, 120) }), atMs: Date.now() })
494 if (state.mods.length > 50) state.mods.shift()
495 record(state.events, [{ atMs: Date.now(), kind: 'mods', text: `${plain(e.name, 40)} ${result.refuse === undefined ? 'loaded' : 'REFUSED'} (${plain(e.provenance, 60)})` }])
496
497 return result
498 })
499}
500hooks/press-guard.ts 23 lines1/**
2 * A press that outlived its drawing (ADR-469). The engine holds a Button's `onPress` only for the life of the drawing that made it
3 * (types: "The host holds only a handle, for the drawing's life"). A click that was sent while the pane was redrawn (a resize, a refresh
4 * tick, a view switch) can reach the engine after the drawing it was aimed at is gone: the chain then ends at a handle nobody holds,
5 * and the engine logs "ui.press hook skipped… no handler is held under handle". Nothing was lost but that click, and it is not an
6 * error of ours, so the `ui.press` hook answers it quietly with the element it was aimed at instead of letting it throw into the log.
7 * Every other failure still propagates.
8 */
9export const STALE_HANDLE = /no handler is held/i
10
11const messageOf = (error: unknown): string => (error instanceof Error ? error.message : typeof error === 'string' ? error : '')
12
13/** `next()`'s answer, or `fallback` when it fails only because the press's handler is no longer held. */
14export async function tolerantPress<R>(next: () => R | Promise<R>, fallback: R): Promise<R> {
15 try {
16 return await next()
17 } catch (error) {
18 if (STALE_HANDLE.test(messageOf(error))) return fallback
19
20 throw error
21 }
22}
23hooks/views/attention.ts 139 lines1/**
2 * Answers open where they were asked, on every page. When a button or field raises an ask (a confirm) or an answer (an outcome),
3 * the runner records which element it was (`state.origin`). The page is then drawn through a kit whose column boxes watch for that
4 * element: the confirm and the outcome are placed right after the row that holds it, so the person never scrolls to the top to find
5 * what to click. If the element is not on screen (a hotkey, the palette, a folded section), the panel falls back to the top.
6 *
7 * Nothing here reads the engine's element shapes: each Button and Input the page builds is remembered by identity with its key, each
8 * Box inherits the keys of its children, and the first column Box that holds the key gets the panel after that child.
9 */
10import type { RenderElement } from 'claude-code'
11
12import type { State } from '../state'
13import { confirmInline, confirmRow, text, THEME, type Ctx } from './common'
14
15/** Presses that answer an ask rather than raise one: they never move the origin. */
16export const ANSWER_KEYS: ReadonlySet<string> = new Set(['confirm', 'cancel', 'remember', 'always'])
17
18export type Attention = {
19 /** The key of the element the ask or answer came from. */
20 key: string | null
21 /** What to place after it. */
22 panel: RenderElement[]
23 placed: boolean
24 keys: WeakMap<object, Set<string>>
25 /** `collect`: a lab's result block hands its rows over (`slot`); `hide`: the panel carries them, so the block draws nothing; `draw`: the block draws itself. */
26 mode: 'draw' | 'collect' | 'hide'
27 donated: RenderElement[]
28}
29
30const flat = (children: unknown): unknown[] => (Array.isArray(children) ? children.flatMap(flat) : children === null || children === undefined || typeof children === 'boolean' ? [] : [children])
31const isObject = (value: unknown): value is object => typeof value === 'object' && value !== null
32
33const donations = new WeakMap<State, { stamp: string; rows: RenderElement[] }>()
34
35/**
36 * The rows a lab view donated for this state, reused while nothing they show has changed (the view, the width, the result, its scroll
37 * and the running spinner), so the extra drawing that finds them happens once per result rather than on every frame.
38 */
39export function donated(ctx: Ctx, draw: () => RenderElement[]): RenderElement[] {
40 const { state, nowMs } = ctx
41 const result = state.lab.result
42 const running = state.lab.running
43 const stamp = [state.view, ctx.columns, result?.atMs ?? 0, result?.id ?? '', state.select.item, running === null ? 0 : Math.floor(nowMs / 500) + 1, state.origin].join('|')
44 const kept = donations.get(state)
45
46 if (kept !== undefined && kept.stamp === stamp) return kept.rows
47
48 const rows = draw()
49
50 donations.set(state, { stamp, rows })
51
52 return rows
53}
54
55export const newAttention = (key: string | null, panel: RenderElement[]): Attention => ({ key, panel, placed: false, keys: new WeakMap(), mode: 'draw', donated: [] })
56
57const keyOfElement = (element: unknown): string | null => {
58 const key = (element as { key?: unknown }).key ?? (element as { props?: { key?: unknown } }).props?.key
59
60 return typeof key === 'string' && key !== '' ? key : null
61}
62
63/**
64 * The kit for this frame. While an ask or answer is waiting for its place (an origin is set), every column Box is watched: the first
65 * one that holds the origin's element gets the panel after that child. Otherwise the kit is returned as it is, so a quiet page costs
66 * nothing extra. Which element was pressed is recorded by the `ui.press` and `ui.input` hooks (register.ts), not here.
67 */
68export function wrapKit(kit: Ctx['kit'], state: State, attention: Attention): Ctx['kit'] {
69 // A headless ask still needs its inline confirm tracked, so the pane can fall back when that section is folded.
70 if (attention.key === null && state.pending === null) return kit
71
72 const Box: Ctx['kit']['Box'] = props => {
73 const kids = flat((props as { children?: unknown }).children)
74 let next = props
75
76 if (!attention.placed && (props as { flexDirection?: string }).flexDirection === 'column' && attention.panel.length > 0) {
77 const at = kids.findIndex(kid => isObject(kid) && (attention.keys.get(kid)?.has(attention.key as string) === true || keyOfElement(kid) === attention.key))
78
79 if (at >= 0) {
80 attention.placed = true
81 kids.splice(at + 1, 0, ...attention.panel)
82 next = { ...props, children: kids } as typeof props
83 }
84 }
85
86 const element = kit.Box(next)
87 const keys = new Set<string>()
88
89 for (const kid of kids) {
90 if (!isObject(kid)) continue
91
92 const own = keyOfElement(kid)
93
94 if (own !== null) keys.add(own)
95 for (const key of attention.keys.get(kid) ?? []) keys.add(key)
96 }
97
98 if (keys.size > 0 && isObject(element)) attention.keys.set(element, keys)
99
100 return element
101 }
102
103 return { ...kit, Box }
104}
105
106/**
107 * Where a lab view draws its result block: normally the rows themselves (at the foot of the lab); while the page is being drawn to
108 * find them (`collect`) they are handed to the panel instead, which places them under the row that was clicked.
109 */
110export function slot(ctx: Ctx, rows: RenderElement[]): RenderElement[] {
111 const attention = ctx.attention
112
113 if (attention?.mode === 'collect') attention.donated.push(...rows)
114
115 return attention === undefined || attention.mode === 'draw' ? rows : []
116}
117
118/** The outcome of the last action as rows: what ran, whether it worked, and its first lines. Nothing when it is old. */
119export function outcomeRows(ctx: Ctx): RenderElement[] {
120 const { state, nowMs } = ctx
121 const outcome = state.outcome
122
123 if (outcome === null || nowMs - outcome.atMs >= 90_000) return []
124
125 return [
126 text(ctx, `${outcome.ok ? '✓' : '✗'} ${outcome.label}${outcome.verified === 'yes' ? ' · on disk' : outcome.verified === 'no' ? ' · not on disk yet' : ''}: ${outcome.detail}`, { color: outcome.ok ? THEME.ok : THEME.bad }),
127 ...(outcome.lines ?? []).slice(0, 8).map(line => text(ctx, ` ${line}`, { dimColor: true })),
128 ]
129}
130
131/** The panel for this frame: the confirm (unless the page draws its own) and the outcome. Empty when the ask came from nowhere on screen. */
132export function panelOf(ctx: Ctx, donated: readonly RenderElement[] = []): RenderElement[] {
133 const confirm = confirmRow(ctx)
134 const own = ctx.state.pending !== null && confirmInline(ctx.state.view, ctx.state.pending.scope)
135 const rows = [...(confirm !== null && !own ? [confirm] : []), ...outcomeRows(ctx), ...donated]
136
137 return rows.length === 0 ? [] : [ctx.kit.Box({ key: 'attention', flexDirection: 'column', paddingX: 1, children: rows })]
138}
139hooks/data/abs-path.ts 6 lines1/** Absolute on either family: `/posix`, a drive path (`C:\x` or `C:/x`) or a UNC share (`\\server\share`). A bare `C:` or `C:x` is drive-relative, so it is not. */
2export const isAbsolutePath = (path: string): boolean => path.startsWith('/') || path.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(path)
3
4/** The path under the working directory unless it already is absolute (`under()` builds `C:\dev\app/.claude-flow/...` on Windows: prefixing that again was #4175). */
5export const rootedPath = (cwd: string, path: string): string => (isAbsolutePath(path) ? path : `${cwd.replace(/[\\/]+$/, '')}/${path}`)
6hooks/controller.ts 500 lines1/**
2 * Everything the console does over time, as plain functions over a Host: the disk refresh and the event diff, the CLI
3 * probes, the pane's lifecycle (auto-open without taking the keys), and the animation loop. Actions go through
4 * ./runner. Nothing here reaches `$` but through the Host.
5 */
6import { actionsOf } from './bindings'
7import type { Catalog } from './data/catalog'
8import { PROBES, probeArgv, probeError, probeReady, type ProbeResult } from './data/cli'
9import { ALL_COST_PROBES as COST_PROBES } from './data/cost-probes'
10import { memmapProbe } from './data/memmap'
11import { memoryHealthProbe } from './data/memory-health'
12import { X_PROBES } from './data/xruv'
13import { diffEvents, record } from './data/events'
14import { agentName, announceChanges, factsOf, segmentOf, TOASTED_KEYS } from './notices'
15import { plain } from './data/parse'
16import { readSnapshot } from './data/snapshot'
17import { sayGroundingHint } from './grounding'
18import { markPicture } from './gfx/pictures'
19import type { Host } from './host'
20import { agentLogs } from './ops'
21import { landingRefusal } from './model-tools'
22import { createRunner, EXPIRY_TIMER, type Runner } from './runner'
23import { advance, mcOf } from './mission-control'
24import { hasLiveWork } from './mission-list'
25import { loadSaved } from './startup-loads'
26import { openLoaders } from './view-open'
27import { listSkills } from './skills'
28import { readDrillLogs } from './drill-logs'
29import { entryAge } from './menu-entry'
30import { BOOT_MIN_MS, CLI_PREFIXES, isBooting, NAV_KEY, NAV_STYLES, PANE_ID, push, rowsOf, storeKeyOf, type State } from './state'
31import type { Actions } from './views/common'
32import { picturesOf } from './views/frames'
33import { pulseDue } from './pulse'
34import { refreshWorkflows } from './wf-live'
35import { syncWhatsNew } from './whatsnew'
36import { syncAdrDigest } from './adr-mission'
37
38const ACTIVITY_BUCKET_MS = 5_000
39const PANE_WATCH_MS = 1_000
40const MAX_PARALLEL_PROBES = 2
41const ALL_PROBES = [...PROBES, ...X_PROBES, ...COST_PROBES, memmapProbe, memoryHealthProbe] // CLI probes, the x.ruv.io board's network reads, cost, the memory map's list: one cadence and option gate
42const BAR_FRESH_MS = 10_000
43const IDLE_REFRESH_MS = 30_000
44const TOOLS_RECOUNT_MS = 30_000
45
46export type Controller = {
47 refresh: () => Promise<void>
48 probe: (force?: boolean) => Promise<void>
49 start: () => void
50 /** Restarts the pane's watch after a reload left the pane up and the timers gone. */
51 resume: () => void
52 stop: () => void
53 open: (focus?: boolean) => Promise<{ isPlaced: boolean; reason: string }>
54 /** At session start, with `panel: auto`: opens where it docks, never taking the keys; else leaves a hint. */
55 autoOpen: () => void
56 /** The person closed the pane (Esc, its mark): auto-open stands down until /ruflo opens it again. */
57 closedByPerson: () => void
58 close: () => Promise<void>
59 setView: (view: State['view']) => void
60 drill: (agentId: string) => void
61 animate: () => void
62 noteToolCall: (agentId: string | undefined, tool: string) => void
63 actions: Actions
64 runner: Runner
65 host: Host
66 /** The command catalog, once `/ruflo commands` has read it. */
67 catalog?: Promise<Catalog>
68 /** Blits the band's mark while Claude works; the band calls it with its requestId. */
69 markFrame: (requestId: string, isWorking: boolean) => void
70}
71
72export function createController(state: State, host: Host): Controller {
73 let activityCount = 0
74 let markRequest: string | null = null
75 let lastSegment: string | null | undefined
76 let lastSpend: number | undefined
77 let hasDrawn = false
78 let toolsCountedAt = 0
79 let inflight: Promise<void> | null = null
80 const lastAttempt = new Map<string, number>()
81
82 const persist = () => void host.storeSet(storeKeyOf(state.cwd), { view: state.view === 'agent' ? state.back : state.view, isClosedByPerson: state.pane.isClosedByPerson }).catch(() => undefined)
83 const isVisible = () => state.pane.isOpen && state.pane.isShown
84
85 function refresh(): Promise<void> {
86 inflight ??= readAll().finally(() => {
87 inflight = null
88 })
89
90 return inflight
91 }
92
93 /** A read that starts after this call: what an action checks, since a read already running may predate its write. */
94 async function freshRead(): Promise<void> {
95 await inflight?.catch(() => undefined)
96
97 // A file the action just created must not wait out the missing-file backoff.
98 for (const [path, held] of state.cache) {
99 if ('missingUntilMs' in held) state.cache.delete(path)
100 }
101
102 await refresh()
103 }
104
105 async function readAll(): Promise<void> {
106 state.isRefreshing = true
107
108 const started = Date.now()
109
110 try {
111 // Claude Code connects MCP servers after the session starts: count the ruflo tools again now and then.
112 if (Date.now() - toolsCountedAt >= TOOLS_RECOUNT_MS) {
113 toolsCountedAt = Date.now()
114 void host.rufloTools().then(counted => void (state.rufloTools = counted), () => undefined)
115 }
116
117 const [settings, usage, ruflo, route] = await Promise.all([
118 host.settings().catch(() => null),
119 host.usage().catch(() => null),
120 host.rufloSnapshot().catch((error: unknown) => {
121 state.ruflo.error = plain(String(error), 120)
122
123 return null
124 }),
125 host.rufloRoute().catch(() => null),
126 ])
127 const previous = state.snapshot
128 const now = Date.now()
129 const snapshot = await readSnapshot(host.fs, state.cache, state.cwd, state.home, settings, now, state.configDir, state.options.federationNetwork)
130 if (snapshot.hasNostrKey === false) state.nostrKeyVerifiedAtMs = null
131 // What changed since the last read is announced on the band (the first read announces nothing).
132 const before = previous === null ? null : factsOf(state, now)
133
134 state.snapshot = snapshot
135 syncWhatsNew(state, host)
136 void syncAdrDigest(state, host).catch(() => undefined)
137 sayGroundingHint(state, host)
138 record(state.events, diffEvents(previous, snapshot, now))
139
140 // A mission that finished or lost a task is said in a toast too: the band's notice row reaches only a person looking at the console.
141 if (before !== null) for (const draft of announceChanges(state, before, now)) if (TOASTED_KEYS.has(draft.key)) host.toast(draft.text.slice(0, 120), 8000, draft.level === 'bad' ? 'error' : draft.level)
142
143 if (route !== null && route.agent !== state.ruflo.route?.agent) record(state.events, [{ atMs: now, kind: 'learning', text: `router picked ${route.agent} (${Math.round(route.confidence * 100)}%)` }])
144
145 state.usage = usage
146 state.ruflo.snapshot = ruflo
147 state.ruflo.route = route ?? ruflo?.lastRoute ?? null
148 push(state.writes, snapshot.changed)
149 for (const agent of snapshot.agents.slice(0, 200)) {
150 const log = state.statusLog.get(agent.id) ?? []
151
152 if (log.at(-1)?.status !== agent.status) push(log, { atMs: now, status: agent.status }, 100)
153 state.statusLog.set(agent.id, log)
154 }
155
156 const patterns = snapshot.neural?.patterns
157 if (patterns !== undefined && state.history.patterns.at(-1)?.value !== patterns) push(state.history.patterns, { atMs: now, value: patterns })
158 if (usage?.costUsd !== undefined && state.history.spend.at(-1)?.value !== usage.costUsd) push(state.history.spend, { atMs: now, value: usage.costUsd })
159 if ((snapshot.outcomes?.total ?? 0) > state.history.outcomes) {
160 if (state.history.outcomes > 0) state.curveGrewAtMs = now
161 state.history.outcomes = snapshot.outcomes?.total ?? 0
162 }
163
164 if (state.options.bar === 'off') {
165 const text = segmentOf(state)
166
167 if (text !== lastSegment) {
168 lastSegment = text
169 void host.rufloSegment(text).catch(() => undefined)
170 }
171 }
172 } catch (error) {
173 state.ruflo.error = plain(String(error), 120)
174 } finally {
175 state.isRefreshing = false
176 push(state.stats.refreshes, Date.now() - started, 200)
177
178 // Redraw only for something new while the pane is closed: the band need not repaint an unchanged line.
179 const spend = state.usage?.costUsd
180
181 if (state.pane.isOpen || (state.snapshot?.changed ?? 0) > 0 || spend !== lastSpend || !hasDrawn) {
182 hasDrawn = true
183 lastSpend = spend
184 host.invalidate()
185 }
186 }
187 }
188
189 const probesInFlight = new Map<string, Promise<void>>()
190
191 /** One probe run at a time per probe: a second ask while it runs joins it. */
192 function runProbe(probe: (typeof ALL_PROBES)[number]): Promise<void> {
193 const held = probesInFlight.get(probe.id)
194
195 if (held !== undefined) return held
196
197 const run = runProbeOnce(probe).finally(() => probesInFlight.delete(probe.id))
198
199 probesInFlight.set(probe.id, run)
200
201 return run
202 }
203
204 async function runProbeOnce(probe: (typeof ALL_PROBES)[number]): Promise<void> {
205 const held: ProbeResult = state.probes.get(probe.id) ?? { value: null, okAtMs: null, error: null, errorAtMs: null, isRunning: false }
206
207 state.probes.set(probe.id, { ...held, isRunning: true })
208 lastAttempt.set(probe.id, Date.now())
209
210 try {
211 const argv = probeArgv(probe, state.options.cli, state)
212 const result = await host.run(argv, probe.timeoutMs)
213 const value = result.exitCode === 0 ? (probe.parse(result.stdout) as unknown) : null
214 state.probes.set(
215 probe.id,
216 value !== null
217 ? { value, okAtMs: Date.now(), error: null, errorAtMs: held.errorAtMs, isRunning: false }
218 : {
219 ...held,
220 isRunning: false,
221 errorAtMs: Date.now(),
222 error: probeError(argv, result),
223 },
224 )
225 } catch (error) {
226 state.probes.set(probe.id, { ...held, isRunning: false, errorAtMs: Date.now(), error: plain(error instanceof Error ? error.message : String(error), 100) || 'refused' })
227 } finally {
228 host.invalidate()
229 }
230 }
231
232 /** Runs the probes the view in front draws, each no more often than its cadence; `force` ignores the cadence. */
233 async function probe(force = false): Promise<void> {
234 const now = Date.now()
235 const due = ALL_PROBES.filter(
236 entry =>
237 (isVisible() || force) &&
238 entry.views.includes(state.view) &&
239 (!entry.isNetwork || state.options.federationNetwork) && probeReady(entry, state) &&
240 (force || (state.probes.get(entry.id)?.isRunning !== true && now - (lastAttempt.get(entry.id) ?? 0) >= entry.everyMs)),
241 )
242
243 for (let i = 0; i < due.length; i += MAX_PARALLEL_PROBES) {
244 await Promise.all(due.slice(i, i + MAX_PARALLEL_PROBES).map(entry => runProbe(entry)))
245 }
246 }
247
248 function every(name: string, ms: number, fn: () => void): void {
249 if (!state.timers.has(name)) state.timers.set(name, host.every(ms, fn))
250 }
251
252 function cancel(name: string): void {
253 state.timers.get(name)?.cancel()
254 state.timers.delete(name)
255 }
256
257 // Whether the last frame drew the boot screen: when it ends the whole pane redraws once, and an unfocused pane's loop stops again.
258 let wasBooting = false
259
260 /** One frame of every picture of the view in front, each blitted only at the size it was mounted. */
261 function frame(): void {
262 const started = Date.now()
263 const booting = isBooting(state, started)
264
265 if (wasBooting && !booting) {
266 wasBooting = false
267 state.pane.menuAtMs = Date.now()
268 host.invalidate()
269 animate()
270
271 return
272 }
273
274 wasBooting = booting
275
276 if (pulseDue(state.view, started) || (state.view === 'menu' && entryAge({ look: state.options.look, boot: state.options.boot, ...state.pane }, started, BOOT_MIN_MS) !== null)) host.invalidate()
277
278 // Only a mounted Raster is blitted, so with none (a surface that draws Svg, which redraws with the pane) nothing is rebuilt here.
279 for (const [key, grid] of state.mounted.size === 0 ? [] : picturesOf(state, state.pane.columns, Date.now(), Date.now())) {
280 const mounted = state.mounted.get(key)
281
282 if (mounted !== undefined && mounted.columns === grid.columns && mounted.rows === grid.rows) {
283 host.blit({ requestId: PANE_ID, key, cells: grid.encode(), columns: grid.columns, rows: grid.rows })
284 }
285 }
286
287 push(state.stats.frames, Date.now() - started, 200)
288 }
289
290 /** Runs the frame loop while the pane is shown and holds the keys (or plays the boot screen), at `fps`; stops it otherwise. */
291 function animate(): void {
292 // Something in progress moves its spinner, pictured or not: a lab action in flight, or a live mission, task or guidance run on the Missions page.
293 const moving = state.lab.running !== null || (state.view === 'missions' && hasLiveWork(state.snapshot?.missions?.missions ?? [], mcOf(state).guidance?.status === 'running'))
294
295 if (!(state.options.fps > 0 && isVisible() && (state.pane.isFocused || isBooting(state, Date.now())) && (state.pictured > 0 || moving))) return cancel('frames')
296
297 every('frames', Math.round(1000 / state.options.fps), frame)
298 }
299
300 /** Watches whether the pane is shown and focused, so the loop stops behind another tab and resumes in front. */
301 async function watchPane(): Promise<void> {
302 const panes = await host.panes().catch(() => null)
303 const mine = panes?.find(pane => pane.id === PANE_ID)
304
305 if (panes !== null) {
306 state.pane.isOpen = mine !== undefined
307 state.pane.isShown = mine?.isShown === true
308 state.pane.isFocused = mine?.isFocused === true
309 }
310
311 if (!state.pane.isOpen) cancel('watch')
312
313 animate()
314 }
315
316 function start(): void {
317 let lastIdleMs = 0
318
319 // The AI terminal's saved model and budget apply from the first turn, not only once Settings was opened.
320 loadSaved(state, host)
321 void host.storeGet(NAV_KEY).then(saved => {
322 const style = NAV_STYLES.find(candidate => candidate === saved)
323
324 if (style !== undefined) state.nav = style
325 }, () => undefined)
326
327 every('refresh', state.options.refreshSeconds * 1000, () => {
328 const now = Date.now()
329 const isSeen = state.pane.isOpen || now - state.barDrawnAtMs < BAR_FRESH_MS
330
331 // Nothing on screen reads the disk: re-read only on the idle cadence, so a closed console costs nearly nothing.
332 if (isSeen || now - lastIdleMs >= IDLE_REFRESH_MS) {
333 lastIdleMs = now
334 void refresh().then(() => {
335 void probe()
336 void refreshWorkflows(state, host)
337 advance(state, host)
338 })
339 }
340 })
341 every('activity', ACTIVITY_BUCKET_MS, () => {
342 push(state.activity, activityCount)
343 activityCount = 0
344 })
345 }
346
347 const resume = () => every('watch', PANE_WATCH_MS, () => void watchPane())
348
349 function stop(): void {
350 for (const timer of state.timers.values()) timer.cancel()
351 state.timers.clear()
352 }
353
354 /** `closeOnEscape` false: take the keys but leave Esc handing them back, as an auto-opened pane does. */
355 async function open(focus = true, closeOnEscape = focus): Promise<{ isPlaced: boolean; reason: string }> {
356 try {
357 const result = await host.openPane({ id: PANE_ID, title: 'ruflo', rows: rowsOf(state.view), ...(state.dockColumns > 0 && { columns: state.dockColumns }), ...(focus && { focus: true, holdToasts: true }), ...(closeOnEscape && { closeOnEscape: true }) })
358 const isPlaced = result === undefined || result.isPlaced !== false
359
360 if (isPlaced && !state.pane.isOpen) state.pane.bootAtMs = Date.now()
361 state.pane.isOpen = isPlaced
362 state.pane.isShown = isPlaced
363 if (focus) state.pane.isClosedByPerson = false
364 if (isPlaced) persist()
365 resume()
366 void refresh().then(() => probe(true))
367
368 return { isPlaced, reason: result?.reason ?? '' }
369 } catch (error) {
370 return { isPlaced: false, reason: plain(error instanceof Error ? error.message : String(error), 160) }
371 }
372 }
373
374 function autoOpen(): void {
375 if (state.options.panel !== 'auto' || state.snapshot?.isRufloProject !== true || state.pane.isOpen || state.pane.isClosedByPerson || state.pane.autoTried) return
376
377 state.pane.autoTried = true
378 // From a timer, never a render hook; without `focus`, so the prompt keeps the keys. The engine seats an unasked pane
379 // only where it docks (144 columns and up) and answers why not otherwise: the band then says "/ruflo to open".
380 state.timers.set(
381 'auto-open',
382 host.after(50, () => {
383 state.timers.delete('auto-open')
384 // /ruflo <view> may have opened it in the meantime: that choice stands.
385 if (state.pane.isOpen) return
386 // The BBS look opens on its main menu, as a board does after login.
387 if (state.options.look === 'bbs') state.view = 'menu'
388 void open(false).then(result => {
389 state.pane.autoReason = result.isPlaced ? '' : result.reason || 'not placed'
390 host.invalidate()
391 })
392 }),
393 )
394 }
395
396 async function close(): Promise<void> {
397 state.pane.isOpen = false
398 state.pane.isShown = false
399 state.pane.isClosedByPerson = true
400 persist()
401 // A waiting card's expiry timer does not outlive the pane either: past its window it is cleared at the next ask or tool call instead.
402 for (const name of ['frames', 'watch', EXPIRY_TIMER]) cancel(name)
403 await host.closePane(PANE_ID).catch(() => undefined)
404 }
405
406 function setView(view: State['view']): void {
407 state.isHelp = false
408 state.palette.isOpen = false
409
410 if (view !== state.view) {
411 if (view === 'agent' || state.view !== 'agent') state.back = state.view === 'agent' ? state.back : state.view
412 state.view = view
413 // A group picked on one page (the menu's pages row) does not follow you to the next, or back to this one.
414 state.navPick = null
415 state.pane.viewAtMs = Date.now()
416 state.select.item = 0
417 state.mounted.clear()
418 state.pictured = 0
419 persist()
420 // A new view asks for its own height inline; the dock ignores it.
421 if (state.pane.isOpen) void host.openPane({ id: PANE_ID, title: 'ruflo', rows: rowsOf(view), ...(state.dockColumns > 0 && { columns: state.dockColumns }) }).catch(() => undefined)
422 void probe(true)
423 host.scrollTop()
424 }
425
426 host.invalidate()
427 // The terminal is for typing: its field takes the keys as it opens, so letters reach it, not the pane's hotkeys.
428 if (view === 'terminal') focusField('term-input')
429 // Opening the skills view is the person asking for its lists (npx skills reaches the network, so never unasked).
430 if (view === 'skills') {
431 void listSkills(state, host)
432 focusField('skills-search')
433 }
434 openLoaders(state, host, view)
435 }
436
437 /**
438 * Puts the keys in one of the pane's fields. A pane that opened by itself (panel=auto) does not hold the keys, and a
439 * mouse click on a tab does not give them, so a person who clicked their way to the terminal would type into
440 * Claude's prompt instead. Here the pane takes the keys first (an open with focus), then the ring moves to the field.
441 */
442 function focusField(key: string): void {
443 if (!state.pane.isOpen) return
444
445 const toField = () => void host.focus(PANE_ID, key).catch(() => undefined)
446
447 if (state.pane.isFocused) toField()
448 else void open(true, false).then(result => result.isPlaced && toField())
449 }
450
451 function drill(agentId: string): void {
452 const agent = state.snapshot?.agents.find(entry => entry.id === agentId)
453
454 state.drill = { agentId, logs: null, logsAtMs: 0 }
455 setView('agent')
456
457 const spec = agent === undefined ? null : agentLogs(agent)
458
459 if (spec !== null) readDrillLogs(state, host, agentId, spec.args)
460 }
461
462 const runner = createRunner(state, host, {
463 freshRead, setView, drill,
464 command: name => (name === 'refresh' ? actions.refresh() : name === 'help' ? actions.help() : actions.close()),
465 landingRefusal: pending => landingRefusal(state, pending),
466 })
467 const actions: Actions = actionsOf(state, host, runner, { freshRead, probe, setView, drill, close, animate })
468
469 function markFrame(requestId: string, isWorking: boolean): void {
470 markRequest = requestId
471
472 if (isWorking && state.options.fps > 0) {
473 every('mark', Math.round(1000 / state.options.fps), () => {
474 if (markRequest !== null) host.blit({ requestId: markRequest, key: 'mark', cells: markPicture(true, Date.now()).encode(), columns: 2, rows: 1 })
475 })
476 } else {
477 cancel('mark')
478 }
479 }
480
481 function noteToolCall(agentId: string | undefined, tool: string): void {
482 activityCount += 1
483
484 const who = agentId ?? 'main'
485 const list = state.toolsByAgent.get(who) ?? []
486
487 push(list, { atMs: Date.now(), tool: plain(tool, 40) }, 200)
488 state.toolsByAgent.set(who, list)
489 if (state.toolsByAgent.size > 50) state.toolsByAgent.delete(state.toolsByAgent.keys().next().value as string)
490 record(state.events, [{ atMs: Date.now(), kind: 'tools', text: `${agentName(state, agentId)}: ${plain(tool, 40)}` }])
491 }
492
493 const closedByPerson = () => {
494 state.pane.isClosedByPerson = true
495 persist()
496 }
497
498 return { refresh, probe, start, resume, stop, open, autoOpen, closedByPerson, close, setView, drill, animate, noteToolCall, actions, runner, markFrame, host }
499}
500hooks/sessions.ts 295 lines1/**
2 * The session workspace's runtime (ADR-486): one Workspace per console state (a side table, so nothing a transcript said can reach the state
3 * that is snapshotted, exported or shown to a model), the scan that fills it, the selection, and the "read means viewed" commit.
4 *
5 * Scans are stat-first and incremental (data/harness-track.ts): a fast pass once a second while the pane is shown, a full relist every 15 s,
6 * and almost nothing while it is closed. Nothing here starts a process, makes a model call, or touches a session it lists.
7 */
8import { buildIndex, type Index, type Scanned } from './data/sessions-index'
9import { attentionOf, countsOf, markViewed, needsRewrite, viewedOf, type AttentionItem, type Viewed } from './data/sessions-attention'
10import { claudeAdapter } from './data/harness-claude'
11import { codexAdapter } from './data/harness-codex'
12import { passBudget } from './data/harness-track'
13import { rufloAdapter, type LedgerFacts, type RufloInput } from './data/harness-ruflo'
14import type { HarnessAdapter, SessionFs } from './data/harness'
15import { mcOf } from './mission-control'
16import { replaceFile } from './activity-io'
17import type { Host } from './host'
18import type { State } from './state'
19
20/** The fold key of the Sessions section (views/common.ts `section`: present means flipped from its default, which is closed). */
21export const SESSIONS_FOLD = 'room/sessions'
22export const VIEWED_KEY = 'ruflo-console/sessions-viewed'
23const FAST_MS = 1000
24const FULL_MS = 15_000
25const CLOSED_MS = 30_000
26const SHOWN_TTL_MS = 10_000
27
28export type Workspace = {
29 adapters: HarnessAdapter[]
30 index: Index | null
31 selected: string | null
32 /** Which attention row the cursor is on (an index into the queue). */
33 cursor: number
34 viewed: Viewed | null
35 scanning: boolean
36 lastScanMs: number
37 lastFullMs: number
38 scanTimes: number[]
39 /** Rows whose preview text a frame actually drew, with when: the page's evidence that content was displayed (data/sessions-attention.ts). */
40 shown: Map<string, number>
41 /** A line about the last action that could not run, with its reason. */
42 note: string
43 signature: string
44 /** The counts last written to the summary file, so it is rewritten only when one changes or the heartbeat is due. */
45 summary: string
46 /** When the summary file was last written. */
47 summaryAtMs: number
48 /** After a failed summary write: how long the current wait is, and when the next attempt may run. */
49 writeBackoffMs: number
50 writeRetryAtMs: number
51 timer: { cancel: () => void } | null
52}
53
54const spaces = new WeakMap<State, Workspace>()
55
56export function rufloInputOf(state: State, nowMs: number): RufloInput {
57 const ledger = new Map<string, LedgerFacts>()
58
59 for (const mission of mcOf(state).missions.values()) ledger.set(mission.id, { ...(mission.adrs !== undefined && { adrs: mission.adrs }), tasks: mission.tasks.map(task => ({ id: task.id, dependsOn: task.dependsOn, ...(task.rufloTaskId !== undefined && { rufloTaskId: task.rufloTaskId }) })) })
60
61 return { cwd: state.cwd, missions: state.snapshot?.missions ?? null, agents: state.snapshot?.agents ?? [], nowMs, ledger, claimedIssues: new Set((state.snapshot?.claims ?? []).filter(claim => claim.status !== 'released').map(claim => claim.issueId)) }
62}
63
64export function workspaceOf(state: State): Workspace {
65 let found = spaces.get(state)
66
67 if (found === undefined) {
68 found = { adapters: [claudeAdapter(), codexAdapter(), rufloAdapter(() => rufloInputOf(state, Date.now()))], index: null, selected: null, cursor: 0, viewed: null, scanning: false, lastScanMs: 0, lastFullMs: 0, scanTimes: [], shown: new Map(), note: '', signature: '', summary: '', summaryAtMs: 0, writeBackoffMs: 0, writeRetryAtMs: 0, timer: null }
69 spaces.set(state, found)
70 }
71
72 return found
73}
74
75/** The native ids of the sessions the console itself started (the terminal's saved conversations): everything else is outside Ruflo. */
76export const ownIds = (state: State): ReadonlySet<string> => new Set(Object.values(state.terminal.sessions).filter((id): id is string => typeof id === 'string'))
77
78const codexDirOf = (state: State): string | null => (state.home === null ? null : `${state.home.replace(/\/+$/, '')}/.codex`)
79
80/** One pass over every adapter. Never rejects; a failing adapter leaves its report saying so and keeps its earlier rows. */
81export async function scanSessions(state: State, host: Pick<Host, 'fs' | 'invalidate'>, full: boolean, nowMs: number = Date.now()): Promise<void> {
82 const ws = workspaceOf(state)
83
84 if (ws.scanning) return
85
86 ws.scanning = true
87
88 const started = performance.now()
89
90 try {
91 // One read budget for the whole pass, shared by every adapter (ADR-486 §2.4).
92 const env = { fs: host.fs as SessionFs, claudeDir: state.configDir, codexDir: codexDirOf(state), nowMs, full, budget: passBudget() }
93 const scans: Scanned[] = await Promise.all(
94 ws.adapters.map(async adapter => ({
95 id: adapter.id,
96 label: adapter.label,
97 capabilities: adapter.capabilities,
98 result: await adapter.scan(env).catch(() => ({ state: 'failed' as const, rows: [], note: 'the scan threw; nothing was read' })),
99 })),
100 )
101
102 ws.index = buildIndex(scans, ownIds(state), nowMs)
103 ws.lastScanMs = nowMs
104 if (full) ws.lastFullMs = nowMs
105 if (ws.selected !== null && !ws.index.rows.some(row => row.key === ws.selected)) ws.selected = null
106 ws.scanTimes.push(performance.now() - started)
107 if (ws.scanTimes.length > 50) ws.scanTimes.shift()
108
109 const signature = `${ws.index.rows.map(row => `${row.key}:${row.updatedMs}:${row.status}:${row.stale ?? ''}`).join(',')}|${state.pending?.label ?? ''}`
110
111 if (signature !== ws.signature) {
112 ws.signature = signature
113 if (state.view === 'room' || state.view === 'overview') host.invalidate()
114 }
115 } finally {
116 ws.scanning = false
117 }
118}
119
120/** The queue as the page draws it (the console's own pending confirm included). Empty until the viewed state has loaded. */
121export function queueOf(state: State): AttentionItem[] {
122 const ws = workspaceOf(state)
123
124 if (ws.index === null || ws.viewed === null) return state.pending === null ? [] : attentionOf([], { baselineMs: Date.now(), seen: {} }, { label: state.pending.label, askedAtMs: state.pending.askedAtMs })
125
126 return attentionOf(ws.index.rows, ws.viewed, state.pending === null ? null : { label: state.pending.label, askedAtMs: state.pending.askedAtMs })
127}
128
129export const attentionCounts = (state: State) => countsOf(queueOf(state))
130
131/** Selecting a row changes only which row is looked at: no read, no process, no wake. */
132export function selectRow(state: State, key: string | null): void {
133 const ws = workspaceOf(state)
134
135 ws.selected = key !== null && ws.index?.rows.some(row => row.key === key) === true ? key : null
136 ws.note = ''
137}
138
139/** The view calls this when it drew a row's preview text. It only notes the fact; `commitViewed` acts on it. */
140export function noteShown(state: State, key: string, atMs: number = Date.now()): void {
141 workspaceOf(state).shown.set(key, atMs)
142}
143
144/**
145 * Read means viewed: an unread completion or a failure clears only for a row whose preview text a frame drew in the last few seconds while
146 * the Room was the page in front. A placeholder, an error line, a hidden pane or another page clears nothing. Returns whether anything cleared.
147 */
148export function commitViewed(state: State, host: Pick<Host, 'storeSet' | 'invalidate'>, nowMs: number = Date.now()): boolean {
149 const ws = workspaceOf(state)
150
151 for (const [key, at] of ws.shown) if (nowMs - at > SHOWN_TTL_MS) ws.shown.delete(key)
152
153 if (ws.viewed === null || state.view !== 'room' || !state.pane.isShown || ws.shown.size === 0) return false
154
155 const items = queueOf(state).filter(item => item.rowKey !== null && ws.shown.has(item.rowKey))
156 const next = markViewed(ws.viewed, items)
157
158 ws.shown.clear()
159
160 if (next === ws.viewed) return false
161
162 ws.viewed = next
163 void host.storeSet(VIEWED_KEY, next).catch(() => undefined)
164 host.invalidate()
165
166 return true
167}
168
169/** Where the counts-only summary ruflo-mods can show lives: the console's own state folder, in a ruflo project, rewritten when a count changes or the heartbeat is due. */
170export const ATTENTION_FILE = '.claude-flow/console/attention.json'
171/** The file is rewritten at least this often while the workspace runs, so a reader can tell a quiet queue from a console that stopped (ruflo-mods hooks/attention.ts ATTENTION_STALE_MS). */
172export const ATTENTION_HEARTBEAT_MS = 5 * 60_000
173/** The first wait after a failed write of the summary; it doubles up to ATTENTION_HEARTBEAT_MS. */
174export const WRITE_RETRY_MS = 30_000
175
176export async function mirrorAttention(state: State, host: Pick<Host, 'fs' | 'run'>, nowMs: number = Date.now()): Promise<void> {
177 const ws = workspaceOf(state)
178
179 if (ws.viewed === null || ws.index === null || state.snapshot?.isRufloProject !== true) return
180
181 const counts = attentionCounts(state)
182 const key = `${counts['needs-approval']}/${counts.question}/${counts.failed}/${counts['completed-unread']}`
183
184 if (key === ws.summary && nowMs - ws.summaryAtMs < ATTENTION_HEARTBEAT_MS) return
185 if (nowMs < ws.writeRetryAtMs) return
186
187 ws.summary = key
188 ws.summaryAtMs = nowMs
189 // Counts only: no title, path or transcript text is in this file.
190 const text = `${JSON.stringify({ schema: 'ruflo-console.attention/1', approve: counts['needs-approval'], question: counts.question, failed: counts.failed, unread: counts['completed-unread'], atMs: nowMs })}\n`
191
192 if ((await replaceFile(host, state.cwd, `${state.cwd.replace(/\/+$/, '')}/${ATTENTION_FILE}`, text)) === null) {
193 ws.writeBackoffMs = 0
194 ws.writeRetryAtMs = 0
195
196 return
197 }
198
199 // A failed write is retried, but not every tick: 30 s, doubling to 5 minutes, until one succeeds.
200 ws.summary = ''
201 ws.writeBackoffMs = ws.writeBackoffMs === 0 ? WRITE_RETRY_MS : Math.min(ATTENTION_HEARTBEAT_MS, ws.writeBackoffMs * 2)
202 ws.writeRetryAtMs = nowMs + ws.writeBackoffMs
203}
204
205export type SessionActions = {
206 select: (key: string) => void
207 /** Moves the selection through the rows in the order the page lists them. */
208 move: (by: number) => void
209 /** Selects the session an attention item is about (the console's own confirm has none). */
210 jump: (itemId: string) => void
211 refresh: () => void
212 /** Enter on a row: opens it natively only where the harness declared it can; otherwise says why not. */
213 open: (key: string) => void
214}
215
216export function sessionActions(state: State, host: Pick<Host, 'fs' | 'invalidate'>): SessionActions {
217 const ws = workspaceOf(state)
218 const order = () => ws.index?.groups.flatMap(group => group.rows) ?? []
219
220 return {
221 select: key => {
222 selectRow(state, key)
223 host.invalidate()
224 },
225 move: by => {
226 const rows = order()
227 const at = rows.findIndex(row => row.key === ws.selected)
228
229 if (rows.length > 0) selectRow(state, rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? (by > 0 ? -1 : rows.length) : at) + by))]?.key ?? null)
230 host.invalidate()
231 },
232 jump: id => {
233 const item = queueOf(state).find(candidate => candidate.id === id)
234
235 if (item?.rowKey != null) {
236 selectRow(state, item.rowKey)
237 // The browser starts folded so the Room's own sections stay in view; jumping to a session opens it.
238 state.sections.add(SESSIONS_FOLD)
239 }
240 host.invalidate()
241 },
242 refresh: () => void scanSessions(state, host, true),
243 open: key => {
244 const row = ws.index?.rows.find(candidate => candidate.key === key)
245 const caps = ws.index?.reports.find(report => report.id === row?.harness)?.capabilities
246
247 ws.note = row === undefined ? 'that session is gone' : caps?.resume.supported === true ? 'this harness can open sessions, but phase 1 wires no opener' : `open is unavailable for ${row.harness}: ${caps?.resume.why ?? 'not declared'}`
248 host.invalidate()
249 },
250 }
251}
252
253/**
254 * Loads the viewed map. The baseline is the first time this queue ever ran: older completions are history, not unread. It is stored so a
255 * restart keeps it. A stored value that was not a Viewed, or held a stamp or baseline in the future, is written back repaired, once.
256 */
257export async function loadViewed(state: State, host: Pick<Host, 'storeGet' | 'storeSet'>, nowMs: number = Date.now()): Promise<void> {
258 const ws = workspaceOf(state)
259 let raw: unknown
260
261 try {
262 raw = await host.storeGet(VIEWED_KEY)
263 } catch {
264 ws.viewed = viewedOf(null, nowMs)
265
266 return
267 }
268
269 ws.viewed = viewedOf(raw, nowMs)
270 if (needsRewrite(raw, ws.viewed)) await host.storeSet(VIEWED_KEY, ws.viewed).catch(() => undefined)
271}
272
273/** Starts the watcher once. A closed pane scans rarely; a shown one every second (stat-only unless something changed). */
274export function startSessions(state: State, host: Host): void {
275 const ws = workspaceOf(state)
276
277 if (ws.timer !== null) return
278
279 void loadViewed(state, host)
280
281 ws.timer = host.every(FAST_MS, () => {
282 if (!state.options.sessionWorkspace) return
283
284 const now = Date.now()
285 const isSeen = state.pane.isOpen && state.pane.isShown
286
287 if (!isSeen && now - ws.lastScanMs < CLOSED_MS) return
288
289 void scanSessions(state, host, now - ws.lastFullMs >= FULL_MS, now).then(() => {
290 commitViewed(state, host)
291 void mirrorAttention(state, host, now)
292 })
293 })
294}
295hooks/data/events.ts 165 lines1/**
2 * The event stream: what changed between two reads of ruflo's state, plus what the console observed itself (tool calls,
3 * routes, mod admissions, permission denies). Every event is something that happened on disk or in this session, with
4 * the time the console saw it: the stream never synthesises activity.
5 */
6import type { Snapshot } from './snapshot'
7
8export type EventKind = 'swarm' | 'claims' | 'federation' | 'learning' | 'tools' | 'mods' | 'missions' | 'workflows' | 'autopilot' | 'anatole' | 'notices' | 'other'
9
10export type ConsoleEvent = {
11 atMs: number
12 kind: EventKind
13 text: string
14 /** The ruflo agent the event concerns, when one does: the topology pulses along that agent's edge. */
15 agentId?: string
16 /** What the event is about, as `agent:<id>`, `claim:<id>`, `run:<id>`, `task:<id>`, `mission:<id>` or `step:<id>`; derived by `refOf` when absent. */
17 ref?: string
18 /** Which part of the console saw it, when not the diff of two reads: `autopilot`, `workflows`, `anatole`, `notices`, `session`. */
19 src?: string
20}
21
22export const EVENT_KINDS: readonly EventKind[] = ['swarm', 'claims', 'federation', 'learning', 'tools', 'mods', 'missions', 'workflows', 'autopilot', 'anatole', 'notices', 'other']
23
24export const isEventKind = (value: unknown): value is EventKind => typeof value === 'string' && (EVENT_KINDS as readonly string[]).includes(value)
25
26/** What an event is about: its own `ref`, else the agent it names, else the first issue or mission id in the words of a claims or missions event. */
27export function refOf(event: ConsoleEvent): string | undefined {
28 if (event.ref !== undefined) return event.ref
29 if (event.agentId !== undefined) return `agent:${event.agentId}`
30
31 const word = /^(?:task |mission |proposal )?([A-Za-z0-9][\w.:-]{2,60})/.exec(event.text)?.[1]
32
33 if (word === undefined) return undefined
34 if (event.kind === 'claims') return `${event.text.startsWith('task ') ? 'task' : 'claim'}:${word}`
35 if (event.kind === 'missions') return `mission:${word}`
36
37 return undefined
38}
39export const MAX_EVENTS = 300
40
41const ev = (atMs: number, kind: EventKind, text: string, agentId?: string): ConsoleEvent => ({ atMs, kind, text, ...(agentId !== undefined && { agentId }) })
42
43/** What changed from `prev` to `next`. The first read (no `prev`) is a baseline and yields nothing. */
44export function diffEvents(prev: Snapshot | null, next: Snapshot, atMs: number): ConsoleEvent[] {
45 if (prev === null) {
46 return []
47 }
48
49 const out: ConsoleEvent[] = []
50 const before = new Map(prev.agents.map(agent => [agent.id, agent]))
51 const after = new Map(next.agents.map(agent => [agent.id, agent]))
52
53 if (prev.swarm?.id !== next.swarm?.id && next.swarm !== null) out.push(ev(atMs, 'swarm', `swarm ${next.swarm.id} (${next.swarm.topology}) appeared`))
54 if (prev.swarm !== null && next.swarm !== null && prev.swarm.id === next.swarm.id && prev.swarm.status !== next.swarm.status) {
55 out.push(ev(atMs, 'swarm', `swarm ${next.swarm.status} (was ${prev.swarm.status})`))
56 }
57
58 for (const [id, agent] of after) {
59 const old = before.get(id)
60
61 if (old === undefined) out.push(ev(atMs, 'swarm', `agent ${agent.name ?? agent.type} spawned (${agent.type})`, id))
62 else if (old.status !== agent.status) out.push(ev(atMs, 'swarm', `agent ${agent.name ?? agent.type}: ${old.status} → ${agent.status}`, id))
63 }
64
65 for (const [id, agent] of before) {
66 if (!after.has(id)) out.push(ev(atMs, 'swarm', `agent ${agent.name ?? agent.type} left the store`, id))
67 }
68
69 const claimsBefore = new Map(prev.claims.map(claim => [claim.issueId, claim]))
70 const claimsAfter = new Map(next.claims.map(claim => [claim.issueId, claim]))
71
72 for (const [id, claim] of claimsAfter) {
73 const old = claimsBefore.get(id)
74 const owner = claim.claimant.kind === 'agent' ? claim.claimant.id : undefined
75
76 if (old === undefined) out.push(ev(atMs, 'claims', `${id} claimed by ${claim.claimant.agentType ?? claim.claimant.name ?? claim.claimant.id}`, owner))
77 else if (old.claimant.id !== claim.claimant.id) out.push(ev(atMs, 'claims', `${id} now held by ${claim.claimant.agentType ?? claim.claimant.id}`, owner))
78 else if (old.status !== claim.status) out.push(ev(atMs, 'claims', `${id}: ${old.status} → ${claim.status}${claim.handoffTo !== undefined ? ` (to ${claim.handoffTo})` : ''}`, owner))
79 else if ((old.progress ?? 0) !== (claim.progress ?? 0)) out.push(ev(atMs, 'claims', `${id} progress ${claim.progress ?? 0}%`, owner))
80 }
81
82 for (const [id, claim] of claimsBefore) {
83 if (!claimsAfter.has(id)) out.push(ev(atMs, 'claims', `${id} released`, claim.claimant.kind === 'agent' ? claim.claimant.id : undefined))
84 }
85
86 const tasksBefore = new Set(prev.tasks.map(task => task.id))
87
88 for (const task of next.tasks) {
89 if (!tasksBefore.has(task.id)) out.push(ev(atMs, 'claims', `task ${task.id} created: ${task.description.slice(0, 60)}`))
90 }
91
92 const proposals = new Set(prev.hive?.pending.map(entry => entry.id) ?? [])
93 const decided = new Set(prev.hive?.history.map(entry => entry.id) ?? [])
94
95 for (const proposal of next.hive?.pending ?? []) {
96 if (!proposals.has(proposal.id)) out.push(ev(atMs, 'swarm', `proposal ${proposal.type} (${proposal.strategy}) opened`, next.hive?.queen))
97 }
98
99 for (const decision of next.hive?.history ?? []) {
100 if (!decided.has(decision.id)) out.push(ev(atMs, 'swarm', `proposal ${decision.type} decided: ${decision.result} (${decision.votesFor}/${decision.votesAgainst})`, next.hive?.queen))
101 }
102
103 // Each ballot new since the last read, tagged with its voter: the hive's honeycomb pulses that worker's cell.
104 const ballotsBefore = new Map(prev.hive?.pending.map(entry => [entry.id, new Set(entry.ballots.map(ballot => ballot.voter))]) ?? [])
105
106 for (const proposal of next.hive?.pending ?? []) {
107 const seen = ballotsBefore.get(proposal.id) ?? new Set<string>()
108
109 for (const ballot of proposal.ballots) {
110 if (!seen.has(ballot.voter)) out.push(ev(atMs, 'swarm', `${ballot.voter} voted ${ballot.isFor ? 'for' : 'against'} ${proposal.type} (${proposal.id})`, ballot.voter))
111 }
112 }
113
114 const workersBefore = new Set(prev.hive?.workers ?? [])
115 const workersAfter = new Set(next.hive?.workers ?? [])
116
117 for (const worker of workersAfter) if (!workersBefore.has(worker)) out.push(ev(atMs, 'swarm', `${worker} joined the hive`, worker))
118 for (const worker of workersBefore) if (!workersAfter.has(worker)) out.push(ev(atMs, 'swarm', `${worker} left the hive`, worker))
119
120 const patterns = (next.neural?.patterns ?? 0) - (prev.neural?.patterns ?? 0)
121
122 if (prev.neural !== null && next.neural !== null && patterns > 0) out.push(ev(atMs, 'learning', `+${patterns} pattern${patterns === 1 ? '' : 's'} learned`))
123
124 const outcomes = (next.outcomes?.total ?? 0) - (prev.outcomes?.total ?? 0)
125
126 if (prev.outcomes !== null && outcomes > 0) out.push(ev(atMs, 'learning', `+${outcomes} routed outcome${outcomes === 1 ? '' : 's'} judged`))
127 if ((prev.federationNodes?.length ?? 0) !== (next.federationNodes?.length ?? 0)) out.push(ev(atMs, 'federation', `federation keys: ${next.federationNodes?.length ?? 0} node ids on disk`))
128 const missionsBefore = new Map((prev.missions?.missions ?? []).map(mission => [mission.id, mission]))
129
130 for (const mission of next.missions?.missions ?? []) {
131 const old = missionsBefore.get(mission.id)
132
133 if (old === undefined) out.push(ev(atMs, 'missions', `mission ${mission.id} (${mission.state}): ${mission.objective.slice(0, 60)}`))
134 else if (old.state !== mission.state) out.push(ev(atMs, 'missions', `mission ${mission.id}: ${old.state} → ${mission.state}`))
135 else if (old.evidence.verified !== mission.evidence.verified) out.push(ev(atMs, 'missions', `mission ${mission.id}: ${mission.evidence.verified}/${mission.evidence.count} evidence verified`))
136 }
137
138 if (prev.hasNostrKey !== next.hasNostrKey && next.hasNostrKey === true) out.push(ev(atMs, 'federation', 'a nostr identity appeared (~/.ruflo/nostr.key)'))
139
140 return out
141}
142
143/** Appends events, newest last, keeping at most MAX_EVENTS. */
144export function record(events: ConsoleEvent[], fresh: readonly ConsoleEvent[]): void {
145 if (fresh.length === 0) return
146
147 events.push(...fresh)
148
149 if (events.length > MAX_EVENTS) events.splice(0, events.length - MAX_EVENTS)
150}
151
152/** The agents an event touched in the last `windowMs`, newest event per agent: what the topology pulses for. */
153export function recentByAgent(events: readonly ConsoleEvent[], nowMs: number, windowMs: number): Map<string, number> {
154 const out = new Map<string, number>()
155
156 for (let i = events.length - 1; i >= 0; i--) {
157 const event = events[i] as ConsoleEvent
158
159 if (nowMs - event.atMs > windowMs) break
160 if (event.agentId !== undefined && !out.has(event.agentId)) out.set(event.agentId, event.atMs)
161 }
162
163 return out
164}
165hooks/data/parse.ts 501 lines1/**
2 * Readers for the swarm files ruflo writes under `.claude-flow/` and `.swarm/`.
3 *
4 * Vendored from plugins/ruflo-swarm/hooks/reader/parse.ts (a mod may import only its own files), cut to what the console
5 * draws, with the claim record extended by its timestamps and context. Every one takes text another process wrote, so
6 * each tolerates any shape: what it cannot read is left out, never guessed. Nothing here keeps the hive's `hiveToken`.
7 */
8import { countOf, ratioOf, timeOf } from './safe'
9
10/** Text longer than this is not parsed: a store that size is not one the CLI wrote, and parsing it would stall a hook. */
11export const MAX_TEXT = 4_000_000
12/** At most this many records of one kind are kept; the rest are counted, not drawn. */
13export const MAX_RECORDS = 1_000
14
15const ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/
16
17// Whole escape sequences go first (the CLI colours its output; a hostile file may carry a hyperlink or a title): stripping only the ESC byte
18// would leave `[1m` or `]8;;https://…` in the text. Written as \u escapes so no invisible character sits in this source.
19export const ESCAPES = new RegExp('\\u001b\\][^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\|(?=\\u001b)|$)|\\u009d[^\\u0007\\u009c\\u009d]*(?:[\\u0007\\u009c]|(?=\\u009d)|$)|(?:\\u001b\\[|\\u009b)[0-9;?]*[ -/]*[@-~]', 'g')
20// Controls, DEL, C1, soft hyphen, combining grapheme joiner, Arabic letter mark, zero-width and bidi characters, invisible operators,
21// variation selectors, Hangul fillers and BOM: nothing a person could read, all of them fit for hiding or reordering text.
22export const HIDDEN = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180f\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2060-\\u206f\\u3164\\ufe00-\\ufe0d\\ufeff\\uffa0\\ufff9-\\ufffb]|[\\u{e0000}-\\u{e0fff}]', 'gu')
23
24/** The zero-width and format characters that can split a credential or a keyword without being seen: removed (not spaced) before any mask or pattern runs. */
25export const INVISIBLE = new RegExp('[\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180f\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u206f\\u3164\\ufe00-\\ufe0f\\ufeff\\uffa0\\ufff9-\\ufffb]|[\\u{e0000}-\\u{e0fff}]', 'gu')
26
27/** Plain printable text of at most `max` characters: no escape sequence, control, hidden or bidi-override character reaches the terminal. */
28export function plain(value: unknown, max = 200): string {
29 if (typeof value !== 'string') {
30 return ''
31 }
32
33 const cleaned = value.replace(ESCAPES, '').replace(INVISIBLE, '').replace(HIDDEN, ' ').replace(/\s+/g, ' ').trim()
34
35 return cleaned.length <= max ? cleaned : `${cleaned.slice(0, Math.max(0, max - 1))}…`
36}
37
38/** An id as ruflo mints them (`agent-…`, `swarm-…`, `proposal-…`), or null: only such a string ever reaches an argv. */
39export function idOf(value: unknown): string | null {
40 return typeof value === 'string' && ID.test(value) ? value : null
41}
42
43export const numberOf = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
44export const stringOf = (value: unknown, max = 80): string | undefined => (typeof value === 'string' && value !== '' ? plain(value, max) || undefined : undefined)
45export const recordOf = (value: unknown): Record<string, unknown> | null =>
46 value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
47
48/** JSON text to a plain object, or null for anything else (too long, malformed, an array, a scalar). */
49export function jsonObject(text: string | null | undefined): Record<string, unknown> | null {
50 if (typeof text !== 'string' || text.length > MAX_TEXT) {
51 return null
52 }
53
54 try {
55 return recordOf(JSON.parse(text))
56 } catch {
57 return null
58 }
59}
60
61export const valuesOf = (value: unknown): unknown[] => {
62 const record = recordOf(value)
63
64 return record === null ? [] : Object.values(record).slice(0, MAX_RECORDS)
65}
66
67/** An ISO time to epoch milliseconds, or undefined. */
68export const msOf = (value: unknown): number | undefined => {
69 if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
70 return timeOf(value)
71 }
72
73 const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN
74
75 return Number.isFinite(parsed) ? parsed : undefined
76}
77
78export type SwarmInfo = { id: string; topology: string; status: string; maxAgents?: number; strategy?: string; agentIds: string[]; updatedAt?: string }
79export type AgentRecord = { id: string; type: string; name?: string; status: string; health?: number; taskCount?: number; createdAtMs?: number }
80export type TaskRecord = {
81 id: string
82 type: string
83 description: string
84 status: string
85 assignedTo: string[]
86 createdAtMs?: number
87 /** `mission:<id>` / `task:<id>` style labels (plain words only), as `task_create` stored them. */
88 tags?: string[]
89 startedAtMs?: number
90 completedAtMs?: number
91 /** What `task_complete` / `task_update` recorded as the result, flattened to `key: value` text (bounded). */
92 resultText?: string
93}
94export type Claimant = { kind: 'agent' | 'human'; id: string; agentType?: string; name?: string }
95export type ClaimRecord = {
96 issueId: string
97 status: string
98 claimant: Claimant
99 progress?: number
100 handoffTo?: string
101 isStealable: boolean
102 claimedAtMs?: number
103 changedAtMs?: number
104 /** ruflo's claim type declares `expiresAt`, but no claims tool sets it today: absent means no TTL, not an expired one. */
105 expiresAtMs?: number
106 context?: string
107}
108/** One worker's vote on a proposal, as `votes` records it: the voter's id and whether it voted for. */
109export type Ballot = { voter: string; isFor: boolean }
110export type Proposal = {
111 id: string
112 type: string
113 status: string
114 strategy: string
115 votesFor: number
116 votesAgainst: number
117 /** Who voted which way, in the order the store lists them. */
118 ballots: Ballot[]
119 /** Voters the CLI excluded as Byzantine (bft proposals only). */
120 byzantine: string[]
121 value?: string
122 proposedBy?: string
123 proposedAtMs?: number
124 /** Raft: the term the proposal belongs to, and when it may be re-proposed in the next one. */
125 term?: number
126 timeoutAtMs?: number
127 /** Quorum: unanimous, majority or supermajority. */
128 quorumPreset?: string
129}
130export type Decision = { id: string; type: string; result: string; votesFor: number; votesAgainst: number; strategy?: string; term?: number; decidedAtMs?: number; byzantine: number }
131/** A message `hive-mind broadcast` left in the hive's shared memory. */
132export type Broadcast = { id: string; message: string; priority: string; from: string; atMs?: number }
133export type HiveInfo = {
134 topology: string
135 strategy?: string
136 queen?: string
137 queenTerm?: number
138 queenElectedAtMs?: number
139 workers: string[]
140 pending: Proposal[]
141 history: Decision[]
142 /** The newest broadcasts, oldest first, and every shared-memory key (values are not kept: they are anyone's JSON). */
143 broadcasts: Broadcast[]
144 memoryKeys: string[]
145 createdAtMs?: number
146 updatedAtMs?: number
147}
148/** A worker `hive-mind spawn` wrote to `.claude-flow/agents.json`, with the role it was given in the hive. */
149export type HiveAgentRecord = AgentRecord & { role?: string }
150
151/** `.claude-flow/swarm/swarm-state.json`: the running swarm, else the one updated last. */
152export function parseSwarmStore(text: string | null): SwarmInfo | null {
153 const swarms = valuesOf(jsonObject(text)?.swarms).flatMap(entry => {
154 const swarm = recordOf(entry)
155 const id = idOf(swarm?.swarmId)
156
157 if (swarm === null || id === null) {
158 return []
159 }
160
161 const config = recordOf(swarm.config)
162 const info: SwarmInfo = {
163 id,
164 topology: stringOf(swarm.topology, 40) ?? 'unknown',
165 status: stringOf(swarm.status, 40) ?? 'unknown',
166 agentIds: (Array.isArray(swarm.agents) ? swarm.agents : []).slice(0, MAX_RECORDS).flatMap(agent => {
167 const agentId = idOf(agent) ?? idOf(recordOf(agent)?.agentId) ?? idOf(recordOf(agent)?.id)
168
169 return agentId !== null ? [agentId] : []
170 }),
171 }
172 const maxAgents = countOf(swarm.maxAgents)
173 const strategy = stringOf(config?.strategy, 40)
174 const updatedAt = stringOf(swarm.updatedAt, 40)
175
176 if (maxAgents !== undefined) info.maxAgents = maxAgents
177 if (strategy !== undefined) info.strategy = strategy
178 if (updatedAt !== undefined) info.updatedAt = updatedAt
179
180 return [info]
181 })
182 // By instant, not text: "…T10:00+02:00" is older than "…T09:00Z" though it sorts after it as a string. Unparseable sorts last.
183 const byRecency = (a: SwarmInfo, b: SwarmInfo) => (msOf(b.updatedAt) ?? -Infinity) - (msOf(a.updatedAt) ?? -Infinity) || 0
184
185 return [...swarms.filter(swarm => swarm.status === 'running')].sort(byRecency)[0] ?? [...swarms].sort(byRecency)[0] ?? null
186}
187
188/** `.swarm/state.json`: the pointer `swarm init` (or `swarm start`) leaves. */
189export function parseSwarmPointer(text: string | null): { id: string; topology?: string; strategy?: string; status?: string } | null {
190 const value = jsonObject(text)
191 const id = idOf(value?.id) ?? idOf(value?.swarmId)
192
193 if (value === null || id === null) {
194 return null
195 }
196
197 const pointer: { id: string; topology?: string; strategy?: string; status?: string } = { id }
198 const topology = stringOf(value.topology, 40)
199 const strategy = stringOf(value.strategy, 40)
200 const status = stringOf(value.status, 40)
201
202 if (topology !== undefined) pointer.topology = topology
203 if (strategy !== undefined) pointer.strategy = strategy
204 if (status !== undefined) pointer.status = status
205
206 return pointer
207}
208
209/** `.claude-flow/agents/store.json`. */
210export function parseAgents(text: string | null): AgentRecord[] {
211 return valuesOf(jsonObject(text)?.agents).flatMap(entry => {
212 const agent = recordOf(entry)
213 const id = idOf(agent?.agentId)
214
215 if (agent === null || id === null) {
216 return []
217 }
218
219 const record: AgentRecord = { id, type: stringOf(agent.agentType, 40) ?? 'agent', status: stringOf(agent.status, 20) ?? 'unknown' }
220 const name = stringOf(agent.name, 40)
221 const health = ratioOf(agent.health)
222 const taskCount = countOf(agent.taskCount)
223 const createdAtMs = msOf(agent.createdAt)
224
225 if (name !== undefined) record.name = name
226 if (health !== undefined) record.health = health
227 if (taskCount !== undefined) record.taskCount = taskCount
228 if (createdAtMs !== undefined) record.createdAtMs = createdAtMs
229
230 return [record]
231 })
232}
233
234/** `.claude-flow/tasks/store.json`. */
235/** A task's result object as one bounded line of `key: value` pairs (strings and numbers only), or undefined. */
236function resultTextOf(value: unknown): string | undefined {
237 const result = recordOf(value)
238
239 if (result === null) return typeof value === 'string' ? plain(value, 400) || undefined : undefined
240
241 const text = Object.entries(result)
242 .slice(0, 8)
243 .flatMap(([key, v]) => (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? [`${plain(key, 24)}: ${plain(String(v), 160)}`] : []))
244 .join(' · ')
245
246 return text === '' ? undefined : text.slice(0, 500)
247}
248
249export function parseTasks(text: string | null): TaskRecord[] {
250 return valuesOf(jsonObject(text)?.tasks).flatMap(entry => {
251 const task = recordOf(entry)
252 const id = idOf(task?.taskId)
253
254 return task === null || id === null
255 ? []
256 : [
257 {
258 id,
259 type: stringOf(task.type, 40) ?? 'task',
260 description: plain(task.description, 200),
261 status: stringOf(task.status, 20) ?? 'unknown',
262 assignedTo: (Array.isArray(task.assignedTo) ? task.assignedTo : []).slice(0, 50).flatMap(agent => (idOf(agent) !== null ? [agent as string] : [])),
263 ...(msOf(task.createdAt) !== undefined && { createdAtMs: msOf(task.createdAt) }),
264 tags: (Array.isArray(task.tags) ? task.tags : []).slice(0, 12).flatMap(tag => (typeof tag === 'string' && /^[A-Za-z0-9_.:-]{1,90}$/.test(tag) ? [tag] : [])),
265 ...(msOf(task.startedAt) !== undefined && { startedAtMs: msOf(task.startedAt) }),
266 ...(msOf(task.completedAt) !== undefined && { completedAtMs: msOf(task.completedAt) }),
267 ...(resultTextOf(task.result) !== undefined && { resultText: resultTextOf(task.result) }),
268 },
269 ]
270 })
271}
272
273function claimantOf(value: unknown): Claimant | null {
274 const claimant = recordOf(value)
275
276 if (claimant === null) {
277 return null
278 }
279
280 const isAgent = claimant.type === 'agent'
281 const id = idOf(isAgent ? claimant.agentId : claimant.userId)
282
283 if (id === null) {
284 return null
285 }
286
287 const who: Claimant = { kind: isAgent ? 'agent' : 'human', id }
288 const agentType = stringOf(claimant.agentType, 40)
289 const name = stringOf(claimant.name, 40)
290
291 if (agentType !== undefined) who.agentType = agentType
292 if (name !== undefined) who.name = name
293
294 return who
295}
296
297/** `.claude-flow/claims/claims.json`: issue claims (who works on what), not the authorization file `.claude-flow/claims.json`. */
298export function parseClaims(text: string | null): ClaimRecord[] {
299 const store = jsonObject(text)
300 const stealable = recordOf(store?.stealable) ?? {}
301
302 return valuesOf(store?.claims).flatMap(entry => {
303 const claim = recordOf(entry)
304 const issueId = idOf(claim?.issueId)
305 const claimant = claimantOf(claim?.claimant)
306
307 if (claim === null || issueId === null || claimant === null) {
308 return []
309 }
310
311 const handoff = recordOf(claim.handoffTo)
312 const handoffTo = idOf(handoff?.agentId ?? handoff?.userId)
313 const record: ClaimRecord = {
314 issueId,
315 status: stringOf(claim.status, 30) ?? 'unknown',
316 claimant,
317 isStealable: claim.status === 'stealable' || Object.hasOwn(stealable, issueId),
318 }
319 const progress = numberOf(claim.progress)
320 const claimedAtMs = msOf(claim.claimedAt)
321 const changedAtMs = msOf(claim.statusChangedAt)
322 const expiresAtMs = msOf(claim.expiresAt)
323 const context = stringOf(claim.context, 120)
324
325 if (progress !== undefined) record.progress = Math.max(0, Math.min(100, progress))
326 if (handoffTo !== null) record.handoffTo = handoffTo
327 if (claimedAtMs !== undefined) record.claimedAtMs = claimedAtMs
328 if (changedAtMs !== undefined) record.changedAtMs = changedAtMs
329 if (expiresAtMs !== undefined) record.expiresAtMs = expiresAtMs
330 if (context !== undefined) record.context = context
331
332 return [record]
333 })
334}
335
336const votesOf = (value: unknown): { votesFor: number; votesAgainst: number; ballots: Ballot[] } => {
337 const ballots = Object.entries(recordOf(value) ?? {})
338 .slice(0, MAX_RECORDS)
339 .flatMap(([voter, vote]) => (idOf(voter) !== null && typeof vote === 'boolean' ? [{ voter, isFor: vote }] : []))
340
341 return { votesFor: ballots.filter(ballot => ballot.isFor).length, votesAgainst: ballots.filter(ballot => !ballot.isFor).length, ballots }
342}
343
344const idsOf = (value: unknown, max = 50): string[] => (Array.isArray(value) ? value : []).slice(0, max).flatMap(entry => (idOf(entry) !== null ? [entry as string] : []))
345
346/** A proposal's value as one bounded line: a string as it is, anything else as its JSON. */
347function valueText(value: unknown): string | undefined {
348 if (typeof value === 'string') return stringOf(value, 120)
349 if (value === undefined || value === null) return undefined
350
351 try {
352 return stringOf(JSON.stringify(value).slice(0, 400), 120)
353 } catch {
354 return undefined
355 }
356}
357
358function proposalOf(entry: unknown): Proposal | null {
359 const proposal = recordOf(entry)
360 const id = idOf(proposal?.proposalId)
361
362 if (proposal === null || id === null) return null
363
364 const out: Proposal = {
365 id,
366 type: stringOf(proposal.type, 40) ?? 'proposal',
367 status: stringOf(proposal.status, 20) ?? 'pending',
368 strategy: stringOf(proposal.strategy, 20) ?? 'unknown',
369 ...votesOf(proposal.votes),
370 byzantine: idsOf(proposal.byzantineVoters),
371 }
372 const value = valueText(proposal.value)
373 const proposedBy = idOf(proposal.proposedBy)
374 const proposedAtMs = msOf(proposal.proposedAt)
375 const term = numberOf(proposal.term)
376 const timeoutAtMs = msOf(proposal.timeoutAt)
377 const quorumPreset = stringOf(proposal.quorumPreset, 20)
378
379 if (value !== undefined) out.value = value
380 if (proposedBy !== null) out.proposedBy = proposedBy
381 if (proposedAtMs !== undefined) out.proposedAtMs = proposedAtMs
382 if (term !== undefined) out.term = term
383 if (timeoutAtMs !== undefined) out.timeoutAtMs = timeoutAtMs
384 if (quorumPreset !== undefined) out.quorumPreset = quorumPreset
385
386 return out
387}
388
389function decisionOf(entry: unknown): Decision | null {
390 const decision = recordOf(entry)
391 const id = idOf(decision?.proposalId)
392 const votes = recordOf(decision?.votes)
393
394 if (decision === null || id === null) return null
395
396 const out: Decision = {
397 id,
398 type: stringOf(decision.type, 40) ?? 'proposal',
399 result: stringOf(decision.result, 20) ?? 'unknown',
400 votesFor: countOf(votes?.for) ?? 0,
401 votesAgainst: countOf(votes?.against) ?? 0,
402 byzantine: idsOf(decision.byzantineDetected).length,
403 }
404 const strategy = stringOf(decision.strategy, 20)
405 const term = numberOf(decision.term)
406 const decidedAtMs = msOf(decision.decidedAt)
407
408 if (strategy !== undefined) out.strategy = strategy
409 if (term !== undefined) out.term = term
410 if (decidedAtMs !== undefined) out.decidedAtMs = decidedAtMs
411
412 return out
413}
414
415/** `sharedMemory.broadcasts`, the last 20, each field bounded: the message is free text another process wrote. */
416function broadcastsOf(value: unknown): Broadcast[] {
417 return (Array.isArray(value) ? value : []).slice(-20).flatMap(entry => {
418 const message = recordOf(entry)
419 const id = idOf(message?.messageId)
420 const text = stringOf(message?.message, 160)
421
422 if (message === null || id === null || text === undefined) return []
423
424 const atMs = msOf(message.timestamp)
425
426 return [{ id, message: text, priority: stringOf(message.priority, 12) ?? 'normal', from: idOf(message.fromId) ?? 'system', ...(atMs !== undefined && { atMs }) }]
427 })
428}
429
430/** `.claude-flow/hive-mind/state.json`, without its capability token. */
431export function parseHive(text: string | null): HiveInfo | null {
432 const hive = jsonObject(text)
433
434 if (hive === null || hive.initialized !== true) {
435 return null
436 }
437
438 const queen = recordOf(hive.queen)
439 const queenId = idOf(queen?.agentId)
440 const consensus = recordOf(hive.consensus)
441 const shared = recordOf(hive.sharedMemory)
442 const info: HiveInfo = {
443 topology: stringOf(hive.topology, 40) ?? 'unknown',
444 workers: idsOf(hive.workers, MAX_RECORDS),
445 pending: (Array.isArray(consensus?.pending) ? consensus.pending : []).slice(-50).flatMap(entry => proposalOf(entry) ?? []),
446 history: (Array.isArray(consensus?.history) ? consensus.history : []).slice(-50).flatMap(entry => decisionOf(entry) ?? []),
447 broadcasts: broadcastsOf(shared?.broadcasts),
448 memoryKeys: Object.keys(shared ?? {}).slice(0, 200).flatMap(key => (idOf(key) !== null ? [key] : [])),
449 }
450 const strategy = stringOf(hive.consensusStrategy, 30)
451 const term = numberOf(queen?.term)
452 const electedAtMs = msOf(queen?.electedAt)
453 const createdAtMs = msOf(hive.createdAt)
454 const updatedAtMs = msOf(hive.updatedAt)
455
456 if (strategy !== undefined) info.strategy = strategy
457 if (queenId !== null) info.queen = queenId
458 if (term !== undefined) info.queenTerm = term
459 if (electedAtMs !== undefined) info.queenElectedAtMs = electedAtMs
460 if (createdAtMs !== undefined) info.createdAtMs = createdAtMs
461 if (updatedAtMs !== undefined) info.updatedAtMs = updatedAtMs
462
463 return info
464}
465
466/** `.claude-flow/agents.json`: the workers `hive-mind spawn` writes (the other agent tools use agents/store.json), with their hive role. */
467export function parseHiveAgents(text: string | null): HiveAgentRecord[] {
468 const roles = new Map(
469 valuesOf(jsonObject(text)?.agents).flatMap(entry => {
470 const agent = recordOf(entry)
471 const id = idOf(agent?.agentId)
472 const role = stringOf(recordOf(agent?.config)?.hiveRole, 20)
473
474 return id !== null && role !== undefined ? [[id, role] as const] : []
475 }),
476 )
477
478 return parseAgents(text).map(agent => {
479 const role = roles.get(agent.id)
480
481 return role === undefined ? agent : { ...agent, role }
482 })
483}
484
485/** The tail of an id a person can tell apart at a glance: `agent-1790954653916-od014i` → `od014i`. */
486export function shortId(id: string): string {
487 const tail = id.split(/[-_:]/).pop() ?? id
488
489 return tail.length >= 4 ? tail.slice(-6) : id.slice(-6)
490}
491
492/** One readable label per agent: its name, else its type, with a short id only where two would read the same. */
493export function agentLabels(agents: readonly { id: string; name?: string; type: string }[]): Map<string, string> {
494 const base = (agent: { name?: string; type: string }) => agent.name ?? agent.type
495 const counts = new Map<string, number>()
496
497 for (const agent of agents) counts.set(base(agent), (counts.get(base(agent)) ?? 0) + 1)
498
499 return new Map(agents.map(agent => [agent.id, (counts.get(base(agent)) ?? 0) > 1 ? `${base(agent)}·${shortId(agent.id).slice(-4)}` : base(agent)]))
500}
501hooks/dispatch.ts 209 lines1/**
2 * Carries out a `/ruflo` intent (./commands) and answers the command's row. The pane's keys all have an intent here,
3 * so everything works without focus. `mods` and `swarm <sub>` are passed on to the plugins that own them; when neither
4 * answers, the row says which plugin to load rather than pretending.
5 */
6import { HELP, parseRuflo, type Intent } from './commands'
7import { CATALOG_PATH, commandsText, FALLBACK, parseCatalog, type Catalog } from './data/catalog'
8import type { Controller } from './controller'
9import { plain } from './data/parse'
10import { loadEvolve } from './evolve'
11import { refreshWorkflows } from './wf-live'
12import { labAnswer } from './mh-lab'
13import { skillsAnswer } from './skills-lab'
14import { VIEWS, type State } from './state'
15import { missionAnswer } from './mission-text'
16import { xruvAnswer } from './xruv'
17import { barText } from './views/bar'
18import { viewText } from './views/pane'
19import { autopilotCommand } from './views/ap-panel'
20import { hostOf } from './ap-live'
21import { watchCommand } from './watch-command'
22import { bandReply, noticesReply, quietReply, median, p95 } from './notices'
23
24/** The engine's words when a registered command reaches it with no hook answering (Claude Code 2.1.287). */
25const NO_HOOK_ANSWERED = /registered \/ruflo but no command\.run hook answered/
26
27export type Delegate = () => Promise<{ text?: string } | undefined>
28
29/** The one-line answer of `/ruflo status`, with the measured render, refresh and frame costs. */
30export function statusLine(state: State): string {
31 const stat = (name: string, values: readonly number[]) => (values.length === 0 ? `${name} n/a` : `${name} median ${median(values)}ms p95 ${p95(values)}ms (n=${values.length})`)
32
33 return [barText(state), stat('render', state.stats.renders), stat('refresh', state.stats.refreshes), stat('frame', state.stats.frames)].join(' · ')
34}
35
36const OWNER_HINT = {
37 'ruflo-mods': 'ruflo-mods is not loaded in this session, so nothing answered `/ruflo mods`. Install it with `npx ruflo mods install` (or enable ruflo-mods@ruflo); its old `/ruflo-mods` command is the same report.',
38 'ruflo-swarm': 'ruflo-swarm is not loaded in this session, so nothing answered `/ruflo swarm …`. Enable ruflo-swarm@ruflo; the Swarm view (/ruflo swarm) is the console\'s own.',
39} as const
40
41async function open(control: Controller, state: State, label: string): Promise<{ text: string }> {
42 const opened = await control.open()
43
44 return { text: opened.isPlaced ? `ruflo console: ${label}` : `The ruflo console could not be shown: ${opened.reason}` }
45}
46
47const DUMP_WAIT_MS = 8_000
48
49/** The catalog this plugin ships (read once per session), or the built-in mod list when it is missing or another contract. */
50async function loadCatalog(control: Controller): Promise<Catalog> {
51 control.catalog ??= control.host.fs
52 .read(`${control.host.pluginRoot}/${CATALOG_PATH}`)
53 .then(text => parseCatalog(text) ?? FALLBACK, () => FALLBACK)
54
55 return control.catalog
56}
57
58/**
59 * A view as text, with its CLI probes run first and waited for (at most DUMP_WAIT_MS: a probe still running then reads
60 * "asking the ruflo CLI…", as it would on screen). The pane's own view is put back afterwards.
61 */
62async function dumpOf(control: Controller, state: State, view: State['view']): Promise<string> {
63 const shown = state.view
64
65 state.view = view
66
67 try {
68 await control.refresh()
69 await Promise.race([control.probe(true), new Promise(resolve => setTimeout(resolve, DUMP_WAIT_MS))])
70 // Self-Evolution draws from its own file read, which opening the view starts: a dump waits for it too.
71 if (view === 'evolve') await loadEvolve(state, control.host)
72 if (view === 'workflows') await refreshWorkflows(state, control.host, true)
73
74 return viewText({ state, nowMs: Date.now(), columns: 100, act: control.actions }, view)
75 } finally {
76 state.view = shown
77 }
78}
79
80export async function dispatch(control: Controller, state: State, args: string, delegate: Delegate): Promise<{ text: string }> {
81 const intent: Intent = parseRuflo(args)
82
83 switch (intent.kind) {
84 case 'open':
85 // Without a pane to show (claude -p, an SDK host), the view is answered as text in the command's row instead.
86 if (!state.isInteractive) return { text: await dumpOf(control, state, intent.view ?? state.view) }
87 // The BBS look lands on its main menu when the cockpit opens with no view named, like a board after login.
88 if (intent.view !== null) control.setView(intent.view)
89 else if (!state.pane.isOpen && state.options.look === 'bbs') control.setView('menu')
90
91 return open(control, state, VIEWS.find(view => view.id === state.view)?.label ?? 'Agent')
92 case 'help':
93 return { text: HELP }
94 case 'close':
95 await control.close()
96
97 return { text: 'ruflo console closed (/ruflo opens it again)' }
98 case 'status':
99 await control.refresh()
100
101 return { text: statusLine(state) }
102 case 'delegate': {
103 try {
104 const answer = await delegate()
105
106 // With nothing beneath, the engine answers in its own words that no hook answered: that is no answer either.
107 if (typeof answer?.text === 'string' && answer.text.trim() !== '' && !NO_HOOK_ANSWERED.test(answer.text)) return { text: answer.text }
108 } catch {
109 // Nothing beneath answers this command: fall through to the hint.
110 }
111
112 return { text: OWNER_HINT[intent.owner] }
113 }
114 case 'palette':
115 state.palette = { isOpen: true, query: plain(intent.query, 200), index: 0, context: 'all' }
116
117 return open(control, state, 'palette')
118 case 'run': {
119 // A headless budget ask checks the installed CLI's help before building a setter spec.
120 if (intent.paletteId === 'cost-budget' || intent.paletteId.startsWith('cost-budget-')) await dumpOf(control, state, 'cost')
121 const askedAtMs = Date.now()
122 const isRun = control.actions.run(intent.paletteId, intent.text)
123
124 if (!isRun) return { text: `No palette entry "${plain(intent.paletteId, 40)}" right now. /ruflo palette lists them; ids look like spawn-coder, claim-release, worker-audit, route.` }
125
126 await control.open()
127 // A lab read answers with what it printed, so `/ruflo run mh-genome` works headless.
128 if (state.pending === null) await control.runner.settled()
129
130 if (state.pending !== null) {
131 const pending = state.pending
132
133 // The answer puts this card in front of the person, so a bare /ruflo yes may answer it (and only it).
134 state.shownCard = pending.id ?? null
135
136 return { text: [`Asked: ${pending.label}. Confirm with /ruflo yes (or y in the pane), cancel with /ruflo no.`, ...(pending.shows === undefined ? [] : [`runs: ${pending.shows}`, pending.note ?? ''])].filter(Boolean).join('\n') }
137 }
138
139 return { text: (missionAnswer(state, intent.paletteId) ?? xruvAnswer(state, intent.paletteId, askedAtMs) ?? labAnswer(state, intent.paletteId, askedAtMs) ?? skillsAnswer(state, intent.paletteId, askedAtMs)) ?? (state.outcome !== null && !state.outcome.ok ? `${state.outcome.label}: ${state.outcome.detail}` : (state.outcome?.label ?? 'done')) }
140 }
141 case 'confirm':
142 if (state.pending === null) return { text: 'Nothing is waiting for a confirm.' }
143
144 if (!intent.isYes) {
145 control.runner.cancel()
146
147 return { text: 'Cancelled.' }
148 }
149
150 const confirmedAtMs = Date.now()
151
152 // A typed yes names no card: the runner refuses it while the card is too new or just replaced one (press the card's own Yes then).
153 await control.runner.confirmUnbound(intent.card)
154
155 return { text: xruvAnswer(state, null, confirmedAtMs) ?? labAnswer(state, null, confirmedAtMs) ?? (state.outcome === null ? 'Ran.' : `${state.outcome.ok ? '✓' : '✗'} ${state.outcome.label}: ${state.outcome.detail}${state.outcome.verified === 'yes' ? ' (on disk)' : state.outcome.verified === 'no' ? ' (not on disk yet)' : ''}`) }
156 case 'agent': {
157 const who = intent.who.toLowerCase()
158 const agent = state.snapshot?.agents.find(entry => entry.id.toLowerCase() === who || entry.name?.toLowerCase() === who) ?? state.snapshot?.agents.find(entry => entry.id.toLowerCase().endsWith(who))
159
160 if (agent === undefined) return { text: `No agent "${plain(intent.who, 40)}" in .claude-flow/agents/store.json.` }
161
162 control.drill(agent.id)
163
164 return open(control, state, `agent ${agent.name ?? agent.type}`)
165 }
166 case 'back':
167 control.actions.back()
168
169 return open(control, state, VIEWS.find(view => view.id === state.view)?.label ?? 'back')
170 case 'select':
171 control.actions.select(intent.by)
172
173 return { text: `selection moved (${intent.by > 0 ? 'next' : 'prev'}) on ${state.view}` }
174 case 'filter':
175 state.eventFilter = intent.filter
176 control.setView('events')
177
178 return open(control, state, `events · ${intent.filter}`)
179 case 'dump': {
180 const view = intent.view ?? state.view
181 const shown = state.view
182
183 return { text: await dumpOf(control, state, view) }
184 }
185 case 'band':
186 control.host.invalidate()
187
188 return { text: bandReply(state, intent.arg) }
189 case 'notices':
190 return { text: noticesReply(state, Date.now(), intent.isClear) }
191 case 'quiet':
192 control.host.invalidate()
193
194 return { text: quietReply(state, Date.now(), intent.arg) }
195 case 'autopilot': {
196 const apHost = hostOf(state)
197
198 return { text: apHost === undefined ? 'autopilot is not wired into this console yet' : await autopilotCommand(state, apHost, intent.arg) }
199 }
200 case 'commands':
201 return { text: commandsText(await loadCatalog(control), intent.query) }
202 case 'events':
203 case 'timeline':
204 return watchCommand(control, state, intent)
205 case 'unknown':
206 return { text: `Unknown: "${plain(intent.word, 30)}". /ruflo help lists the views (${VIEWS.map(view => view.id).join(', ')}) and commands.` }
207 }
208}
209hooks/gfx/pictures.ts 458 lines1/**
2 * The animated pictures of the overview, swarm and learning views, each a pure function of its data, its size and the
3 * real clock `t` (ms). The render and every `$.ui.blit` frame call the same function with the same size, so a frame
4 * always fits the mounted Raster. What motion means is said beside each picture: data where it is data, decoration
5 * where it is not.
6 */
7import type { ConsoleTheme } from '../themes'
8import { Braille, COLOR, Grid, mix, ramp, sparkline } from './raster'
9import { bigText } from './font'
10import { hash } from './boot-cyber'
11import { getBuild } from '../build'
12import { CONSOLE_VERSION } from '../version'
13
14export { bootPicture, BOOT_ROWS } from './boot'
15
16export type TopoNode = { id: string; label: string; status: string; isLeader: boolean; /** When the console last saw an event about it. */ pulseAtMs?: number }
17export type TopoModel = { topology: string; nodes: TopoNode[] }
18
19export const PULSE_MS = 1_400
20/** How long a work-in-flight dot takes from the leader to a busy agent. */
21export const FLIGHT_MS = 1400
22const isBusy = (status: string) => /busy|active|running|working/i.test(status)
23const isDown = (status: string) => /stop|terminat|offline|dead|error|fail/i.test(status)
24
25export function nodeColor(node: TopoNode, colors = COLOR): number {
26 if (node.isLeader) return colors.accent
27 if (isDown(node.status)) return /error|fail/i.test(node.status) ? colors.bad : colors.dim
28 if (isBusy(node.status)) return colors.warn
29
30 return colors.info
31}
32
33/**
34 * Where each node sits, in braille dots, by topology: a tree (rows of workers under the leader) for hierarchical and
35 * star, a circle for mesh and ring. Large swarms wrap into more rows rather than overprinting.
36 */
37export function layout(model: TopoModel, width: number, height: number): { x: number; y: number }[] {
38 const n = model.nodes.length
39 const topology = model.topology.toLowerCase()
40 const isCircle = (topology.includes('mesh') && !topology.includes('hierarchical')) || topology.includes('ring')
41
42 if (n === 0) return []
43
44 if (!isCircle) {
45 const workers = n - 1
46 const perRow = Math.max(1, Math.min(workers, Math.floor(width / 10)))
47 const tiers = Math.max(1, Math.ceil(workers / perRow))
48 const top = 3
49 const span = Math.max(4, height - 6 - top)
50
51 return model.nodes.map((_, i) => {
52 if (i === 0) return { x: width / 2, y: top }
53
54 const k = i - 1
55 const tier = Math.floor(k / perRow)
56 const inTier = Math.min(perRow, workers - tier * perRow)
57 const slot = k % perRow
58
59 return { x: ((slot + 0.5) / inTier) * (width - 8) + 4, y: top + 6 + (tiers === 1 ? span - 2 : (tier / Math.max(1, tiers - 1)) * (span - 2)) }
60 })
61 }
62
63 const cx = width / 2
64 const cy = height / 2
65 const r = Math.max(4, Math.min(width / 2 - 6, height / 2 - 3))
66
67 return model.nodes.map((_, i) => {
68 const angle = -Math.PI / 2 + (i / n) * Math.PI * 2
69
70 return { x: cx + Math.cos(angle) * r * 1.6, y: cy + Math.sin(angle) * r }
71 })
72}
73
74/** The edges a topology draws between node indexes (capped: a 100-agent mesh draws its first 300). */
75export function edges(model: TopoModel): [number, number][] {
76 const n = model.nodes.length
77 const out: [number, number][] = []
78 const topology = model.topology.toLowerCase()
79
80 if (n < 2) return out
81
82 if (topology.includes('mesh') && !topology.includes('hierarchical')) {
83 for (let a = 0; a < n && out.length < 300; a++) for (let b = a + 1; b < n && out.length < 300; b++) out.push([a, b])
84 } else if (topology.includes('ring')) {
85 for (let a = 0; a < n; a++) out.push([a, (a + 1) % n])
86 } else {
87 for (let b = 1; b < n; b++) out.push([0, b])
88 if (topology.includes('hierarchical-mesh')) for (let a = 1; a < n - 1; a++) out.push([a, a + 1])
89 }
90
91 return out
92}
93
94/**
95 * The swarm graph: nodes coloured by the status ruflo wrote (busy amber, idle blue, stopped grey), the leader starred.
96 * A dot runs from the leader to a node once each time the console sees an event about that agent (data); the leader's
97 * slow heartbeat is decoration.
98 */
99export function topologyPicture(model: TopoModel, columns: number, rows: number, t: number, depth = false, motionTime = t, palette?: ConsoleTheme['palette']): Grid {
100 const rgb = (hex: string) => parseInt(hex.slice(1), 16)
101 const colors = palette ? { ...COLOR, accent: rgb(palette.head), info: rgb(palette.info), ok: rgb(palette.ok), bad: rgb(palette.bad), warn: rgb(palette.warn), dim: mix(rgb(palette.background), rgb(palette.info), 0.45), line: mix(rgb(palette.background), rgb(palette.info), 0.3) } : COLOR
102 const grid = new Grid(columns, rows)
103 const canvas = new Braille(columns, rows)
104 const points = layout(model, canvas.width, canvas.height)
105 // Gentle perspective rocking preserves the model's real edges and labels.
106 if (depth) {
107 const yaw = Math.sin(motionTime / 2400) * 0.42
108 for (const point of points) {
109 const nx = (point.x - canvas.width / 2) / (canvas.width / 2)
110 const ny = (point.y - canvas.height / 2) / (canvas.height / 2)
111 const z = nx * Math.sin(yaw) + ny * 0.18
112 const scale = 2.5 / (2.5 - z)
113 point.x = canvas.width / 2 + nx * Math.cos(yaw) * scale * canvas.width * 0.4
114 point.y = canvas.height / 2 + ny * scale * canvas.height * 0.4
115 }
116 }
117 const links = edges(model)
118 const leader = points[0]
119
120 for (const [a, b] of links) {
121 const p = points[a]
122 const q = points[b]
123
124 if (p !== undefined && q !== undefined) canvas.line(p.x, p.y, q.x, q.y, colors.line)
125 }
126
127 // Work in flight: while ruflo has an agent busy, a dim amber dot keeps travelling down its edge from the leader.
128 // It runs only for as long as the status says busy, so it is data, not decoration; each agent has its own phase.
129 model.nodes.forEach((node, i) => {
130 const q = points[i]
131
132 if (i === 0 || leader === undefined || q === undefined || !isBusy(node.status)) return
133
134 const k = (((t / FLIGHT_MS + i * 0.37) % 1) + 1) % 1
135 const x = leader.x + (q.x - leader.x) * k
136 const y = leader.y + (q.y - leader.y) * k
137
138 canvas.dot(x, y, colors.warn)
139 canvas.dot(x + 1, y, colors.warn)
140 })
141
142 model.nodes.forEach((node, i) => {
143 const q = points[i]
144 const k = node.pulseAtMs === undefined ? -1 : (t - node.pulseAtMs) / PULSE_MS
145
146 if (i === 0 || leader === undefined || q === undefined || k < 0 || k > 1) return
147
148 const x = leader.x + (q.x - leader.x) * k
149 const y = leader.y + (q.y - leader.y) * k
150
151 // Two dots wide, so a pulse on a vertical edge stands out of the line rather than sitting on its dots.
152 canvas.dot(x, y, 0xffffff)
153 canvas.dot(x + 1, y, 0xffffff)
154 })
155
156 canvas.blitInto(grid, 0, 0)
157
158 const room = Math.floor(columns / Math.max(2, Math.min(model.nodes.length, Math.floor(canvas.width / 10))))
159
160 points.forEach((point, i) => {
161 const node = model.nodes[i]
162
163 if (node === undefined) return
164
165 const cx = Math.floor(point.x / 2)
166 const cy = Math.floor(point.y / 4)
167 const heartbeat = node.isLeader ? Math.max(0, Math.sin(t / 260)) ** 6 : 0
168 const flash = node.pulseAtMs !== undefined && t - node.pulseAtMs >= 0 && t - node.pulseAtMs < PULSE_MS + 600
169 // A busy agent breathes (brighter and back, about once every 2 s) so it reads as working, not just coloured.
170 const breath = !node.isLeader && isBusy(node.status) ? 0.45 * Math.sin(t / 330 + i) ** 2 : 0
171 const color = flash ? 0xffffff : node.isLeader ? mix(colors.accent, 0xffffff, heartbeat) : mix(nodeColor(node, colors), 0xffffff, breath)
172
173 if (depth) grid.set(cx + 1, cy + 1, '░', 0x49332c)
174 grid.set(cx, cy, depth ? (node.isLeader ? '◈' : isDown(node.status) ? '×' : isBusy(node.status) ? '◉' : '◇') : node.isLeader ? '★' : isBusy(node.status) ? '◉' : '●', color)
175
176 if (room >= 5 || node.isLeader) {
177 const label = node.label.slice(0, Math.max(3, room - 1))
178
179 grid.text(Math.max(0, Math.min(columns - label.length, cx - Math.floor(label.length / 2))), Math.min(rows - 1, cy + 1), label, node.isLeader ? colors.accent : nodeColor(node, colors))
180 }
181 })
182
183 if (palette) for (let i = 2; i < grid.cells.length; i += 3) grid.cells[i] = rgb(palette.background)
184 return grid
185}
186
187/**
188 * Two measured series as sparklines with their labels: tool calls the console saw per 5 s, and ruflo state files that
189 * changed per refresh. The newest bar glows while the pane animates: decoration over measured bars.
190 */
191export function activityPicture(series: readonly { label: string; values: readonly number[] }[], columns: number, t: number): Grid {
192 const grid = new Grid(columns, Math.max(1, series.length))
193 const labelWidth = Math.min(18, Math.max(8, ...series.map(entry => entry.label.length + 1)))
194 const width = Math.max(4, columns - labelWidth)
195
196 series.forEach((entry, row) => {
197 grid.text(0, row, entry.label.slice(0, labelWidth - 1), COLOR.dim)
198 sparkline(grid, labelWidth, row, width, entry.values, v => ramp(0.3 + v * 0.7))
199
200 const glow = 0.5 + 0.5 * Math.sin(t / 300)
201 const last = labelWidth + width - 1
202
203 if ((entry.values[entry.values.length - 1] ?? 0) > 0) grid.set(last, row, grid.glyph(last, row), mix(COLOR.info, 0xffffff, glow * 0.6))
204 })
205
206 return grid
207}
208
209/**
210 * The running success rate of routed tasks (routing-outcomes.json), oldest left, as a braille line over a 0-100% frame.
211 * When new outcomes arrive the newest stretch draws in over 900 ms from `grewAtMs`: that motion is data arriving.
212 */
213export function curvePicture(points: readonly boolean[], columns: number, rows: number, t: number, grewAtMs = 0): Grid {
214 const grid = new Grid(columns, rows)
215 const canvas = new Braille(Math.max(1, columns - 5), rows)
216 const n = points.length
217
218 for (let r = 0; r < rows; r++) grid.text(0, r, r === 0 ? '100%' : r === rows - 1 ? ' 0%' : ' ', COLOR.dim)
219
220 if (n === 0) {
221 grid.text(6, Math.floor(rows / 2), 'no routed outcomes on disk yet', COLOR.dim)
222
223 return grid
224 }
225
226 let ok = 0
227 const rates = points.map((point, i) => {
228 ok += point ? 1 : 0
229
230 return ok / (i + 1)
231 })
232 const xOf = (i: number) => (n === 1 ? canvas.width / 2 : (i / (n - 1)) * (canvas.width - 1))
233 const yOf = (rate: number) => (1 - rate) * (canvas.height - 1)
234 const drawIn = grewAtMs > 0 ? Math.max(0, Math.min(1, (t - grewAtMs) / 900)) : 1
235 const shown = Math.max(1, Math.round(n * (0.8 + 0.2 * drawIn)))
236
237 for (let x = 0; x < canvas.width; x += 4) canvas.dot(x, yOf(0.5), COLOR.line)
238 for (let i = 1; i < shown; i++) canvas.line(xOf(i - 1), yOf(rates[i - 1] as number), xOf(i), yOf(rates[i] as number), ramp(rates[i] as number))
239 if (n === 1) canvas.dot(xOf(0), yOf(rates[0] as number), ramp(rates[0] as number))
240
241 canvas.blitInto(grid, 5, 0)
242
243 return grid
244}
245
246/** The band's mark: a diamond that pulses while Claude works and rests otherwise. */
247export function markPicture(isWorking: boolean, t: number): Grid {
248 const grid = new Grid(2, 1)
249 const k = isWorking ? 0.5 + 0.5 * Math.sin(t / 220) : 1
250
251 grid.set(0, 0, '◆', isWorking ? mix(COLOR.line, COLOR.accent, k) : COLOR.accent)
252
253 return grid
254}
255
256/** The pane's title strip: a highlight sweeps across it every few seconds while the pane is focused. Decoration only. */
257/** RUFLO in a two-row half-block font, the way a BBS splash spelled its name. */
258const LOGO = ['█▀█ █ █ █▀▀ █ █▀█', '█▀▄ █▄█ █▀ █▄▄ █▄█'] as const
259const NEON_MAGENTA = 0xff2a6d
260const NEON_CYAN = 0x05d9e8
261
262/** A header strikes in over this long when its page is switched to, and when the menu enters. */
263export const TITLE_ENTRY_MS = 1_200
264const GLITCH = '#%&@/\\|<>=+*'
265
266/**
267 * The strike-in shared by the page titles and the menu banner: from `from`, the letters appear left to right, a bright edge leading
268 * and block noise ahead of it; behind the edge a few settled cells flip for a frame to an ASCII character (pink or cyan, fading to none),
269 * and now and then a row slips one cell sideways. Hash-driven, so a frame is reproducible; nothing once `age` reaches TITLE_ENTRY_MS.
270 */
271function strikeIn(grid: Grid, from: number, age: number, t = 0): void {
272 if (age >= TITLE_ENTRY_MS) return occasionalGlitch(grid, from, t)
273
274 let last = from
275
276 for (let i = 0; i < grid.columns * grid.rows; i++) if (grid.cells[i * 3] !== 0x20) last = Math.max(last, i % grid.columns)
277
278 const span = last - from + 1
279 const lead = from + (age / TITLE_ENTRY_MS) * (span + 1)
280
281 for (let y = 0; y < grid.rows; y++) {
282 for (let x = from; x <= last; x++) {
283 if (grid.glyph(x, y) === 0x20) continue
284
285 if (x > lead + 1) grid.set(x, y, '░▒▓█'[hash(x * 7 + y + Math.floor(age / 60)) % 4] as string, mix(0x3a0f2e, NEON_CYAN, 0.35))
286 else if (x > lead - 1.5) grid.set(x, y, grid.glyph(x, y), 0xffffff)
287 else if (hash(x * 13 + y * 7 + Math.floor(age / 50)) % 100 < 4 * (1 - age / TITLE_ENTRY_MS)) grid.set(x, y, GLITCH[hash(x + y + Math.floor(age / 50)) % GLITCH.length] as string, hash(x + Math.floor(age / 50)) % 2 === 0 ? 0xff2a6d : 0x05d9e8)
288 }
289
290 const slip = hash(y * 5 + Math.floor(age / 80))
291
292 if (slip % 16 === 0 && age < TITLE_ENTRY_MS - 150) {
293 const by = (slip >>> 4) % 2 === 0 ? 1 : -1
294 const row = grid.cells.slice(y * grid.columns * 3, (y + 1) * grid.columns * 3)
295
296 for (let x = from; x <= last; x++) grid.cells.set(row.slice(Math.max(0, x - by) * 3, Math.max(0, x - by) * 3 + 3), (y * grid.columns + x) * 3)
297 }
298 }
299}
300
301/** One burst every BURST_EVERY_MS at a hash-chosen moment in its slot, lasting BURST_MS. */
302const BURST_EVERY_MS = 8_000
303const BURST_MS = 260
304
305/**
306 * After the entry, a header glitches now and then: for a quarter second, every eight seconds or so, a few of its cells flip to an ASCII
307 * character and a row may slip a cell. Quieter than the entry, and a function of the animation clock `t` alone, so a still frame (t = 0,
308 * fps 0) is never glitched.
309 */
310function occasionalGlitch(grid: Grid, from: number, t: number): void {
311 if (t <= 0) return
312
313 const slot = Math.floor(t / BURST_EVERY_MS)
314 const at = t - (slot * BURST_EVERY_MS + (hash(slot + 977) % (BURST_EVERY_MS - 1_000)))
315
316 if (at < 0 || at >= BURST_MS) return
317
318 const frame = Math.floor(at / 45)
319
320 for (let y = 0; y < grid.rows; y++) {
321 for (let x = from; x < grid.columns; x++) {
322 if (grid.glyph(x, y) === 0x20) continue
323 if (hash(x * 11 + y * 5 + frame * 31 + slot) % 100 < 3) grid.set(x, y, GLITCH[hash(x + y + frame) % GLITCH.length] as string, hash(x + frame) % 2 === 0 ? 0xff2a6d : 0x05d9e8)
324 }
325
326 if (hash(y * 3 + frame + slot) % 5 === 0) {
327 const row = grid.cells.slice(y * grid.columns * 3, (y + 1) * grid.columns * 3)
328
329 for (let x = from; x < grid.columns; x++) grid.cells.set(row.slice(Math.max(0, x - 1) * 3, Math.max(0, x - 1) * 3 + 3), (y * grid.columns + x) * 3)
330 }
331 }
332}
333
334/**
335 * The BBS banner: the logo in a magenta-to-cyan gradient with a scanline sweeping across it (decoration), a tag line,
336 * the project, and a blinking block cursor. Two rows.
337 */
338export function bannerPicture(project: string, columns: number, t: number, age = Infinity): Grid {
339 const grid = new Grid(columns, 2)
340 const width = LOGO[0].length
341 const sweep = ((t / 28) % (columns + 40)) - 20
342
343 LOGO.forEach((line, y) => {
344 ;[...line].forEach((ch, x) => {
345 if (ch === ' ' || x >= columns) return
346
347 const base = mix(NEON_MAGENTA, NEON_CYAN, x / Math.max(1, width - 1))
348 const glow = Math.max(0, 1 - Math.abs(x - sweep) / 4)
349
350 grid.set(x, y, ch, mix(base, 0xffffff, glow * 0.7))
351 })
352 })
353
354 const x0 = width + 2
355
356 if (columns > x0 + 4) {
357 // The version, and the git revision when the session knows it: the revision changes with every commit, so it shows which build is loaded.
358 // The title, with the version and build when they fit beside the logo; when they do not, the title whole rather than cut mid-word.
359 const title = '░▒▓ AGENT SWARM CONSOLE'
360 const full = `${title} v${CONSOLE_VERSION}${getBuild() === '' ? '' : ` · ${getBuild()}`}`
361 // The longest that fits, never cut mid-word: version and build, the title, a shorter title, the shortest.
362 const room = columns - x0
363 const shown = [full, title, '░▒▓ SWARM CONSOLE', '░▒▓ CONSOLE'].find(text => text.length <= room) ?? '░▒▓ CONSOLE'
364
365 grid.text(x0, 0, shown.slice(0, room), NEON_MAGENTA)
366
367 const line = `▸ npx ruflo · ${project}`
368 const node = line.length <= room - 2 ? line : `${line.slice(0, Math.max(1, room - 3))}…`
369
370 grid.text(x0, 1, node, NEON_CYAN)
371 if (Math.floor(t / 530) % 2 === 0 && x0 + node.length + 1 < columns) grid.set(x0 + node.length + 1, 1, '█', NEON_CYAN)
372 }
373
374 strikeIn(grid, 0, age, t)
375
376 return grid
377}
378
379const NEON_CORAL = 0xff7a59
380
381/**
382 * A view's BBS title: its name in the two-row half-block font, magenta to coral like the ANSI art boards, framed by
383 * dithered ░▒▓ ramps, with a slow shimmer down the letters (decoration). Two rows.
384 */
385export function titlePicture(name: string, columns: number, t: number, age = Infinity): Grid {
386 const grid = new Grid(columns, 2)
387 const [top, bottom] = bigText(name)
388 const edge = '░▒▓'
389 const x0 = edge.length + 1
390 const width = Math.max(top.length, bottom.length)
391 const shimmer = ((t / 40) % (width + 30)) - 15
392 // `RUFLO | PAGE`: the RUFLO letters move like the banner on the menu (a white glow sweeping a magenta to cyan ramp); the page's name keeps its slower coral shimmer.
393 const logo = name.toLowerCase().startsWith('ruflo |') ? bigText('ruflo')[0].length : 0
394 const sweep = ((t / 28) % (logo + 40)) - 20
395
396 for (let y = 0; y < 2; y++) {
397 ;[...edge].forEach((ch, i) => grid.set(i, y, ch, mix(0x3a0f2e, NEON_MAGENTA, (i + 1) / edge.length)))
398 ;[...(y === 0 ? top : bottom)].forEach((ch, i) => {
399 if (ch === ' ' || x0 + i >= columns) return
400
401 if (i < logo) {
402 const lit = Math.max(0, 1 - Math.abs(i - sweep) / 4)
403
404 grid.set(x0 + i, y, ch, mix(mix(NEON_MAGENTA, NEON_CYAN, i / Math.max(1, logo - 1)), 0xffffff, lit * 0.7))
405
406 return
407 }
408
409 const glow = Math.max(0, 1 - Math.abs(i - shimmer) / 3)
410
411 grid.set(x0 + i, y, ch, mix(mix(NEON_MAGENTA, NEON_CORAL, i / Math.max(1, width - 1)), 0xffffff, glow * 0.6))
412 })
413 // The line closes on the ramp the other way round, ░▒▓, mirroring how the dark ▓▒░ edge opened it.
414 ;[...'░▒▓'].forEach((ch, i) => {
415 const x = x0 + width + 1 + i
416
417 if (x < columns) grid.set(x, y, ch, mix(0x3a0f2e, NEON_MAGENTA, (i + 1) / edge.length))
418 })
419 }
420
421 strikeIn(grid, x0, age, t)
422
423 return grid
424}
425
426/**
427 * The menu's palette strip: one block of each colour across the width, and a band of light that sweeps along it and starts again,
428 * brightening the cells it passes (about 28 cells a second: three cells a frame at the default 8 fps, so it reads as motion, not a
429 * jump). At `t` = 0 the light is off the strip and the cells are exactly the colours, so a still frame (fps 0) is the plain strip.
430 * Decoration, like the boot's sign: it carries no data. A pure function of its size and the clock, as every picture here.
431 */
432export function palettePicture(columns: number, t: number, colors: readonly number[]): Grid {
433 const grid = new Grid(columns, 1)
434 const at = ((t / 36) % (columns + 24)) - 12
435
436 for (let x = 0; x < columns; x++) {
437 const base = colors[Math.min(colors.length - 1, Math.floor((x * colors.length) / columns))] ?? 0xffffff
438 const glow = Math.max(0, 1 - Math.abs(x - at) / 7)
439
440 grid.set(x, 0, '▀', mix(base, 0xffffff, glow * 0.8))
441 }
442
443 return grid
444}
445
446export function headerPicture(title: string, columns: number, t: number): Grid {
447 const grid = new Grid(columns, 1)
448 const at = ((t / 22) % (columns + 60)) - 20
449
450 ;[...title.slice(0, columns)].forEach((ch, x) => {
451 const glow = Math.max(0, 1 - Math.abs(x - at) / 6)
452
453 grid.set(x, 0, ch, mix(x < 2 ? COLOR.accent : COLOR.dim, 0xffffff, glow * 0.8))
454 })
455
456 return grid
457}
458hooks/host.ts 74 lines1import type { CommandSpec, HookStream, PaneOpenArgs, ProcessRunResult, ProcessSpawnChunk, ProcessSpawnResult, Timer, UiBlitArgs } from 'claude-code'
2
3import type { ReaderFs } from './data/files'
4import type { ToastLevel } from './toast-policy'
5import type { RufloRoute, RufloSnapshot } from '../types'
6
7/** What `$.ui.open` answers: drawn, or held back with the reason. A build that answers nothing has drawn it. */
8export type OpenResult = { isPlaced: boolean; reason?: string } | void
9
10/**
11 * The engine as `session.start` bound it. Every later hook, timer and button reaches the engine through this, so the
12 * controller is plain functions over an interface a test can stand in for. Any member may be refused (an administrator
13 * removed the affordance, a policy mod said no): every caller catches, and a refusal is a missing fact, never a crash.
14 */
15export type Host = {
16 fs: ReaderFs
17 every: (ms: number, fn: () => void) => Timer
18 after: (ms: number, fn: () => void) => Timer
19 storeGet: (key: string) => Promise<unknown>
20 storeSet: (key: string, value: unknown) => Promise<void>
21 /** A GET through the host (never the plugin's own network; an administrator's policy may refuse it): the status and the body text. */
22 fetchText: (url: string) => Promise<{ ok: boolean; status: number; text: string }>
23 /** The engine's own choice dialog: the label chosen. Rejects when dismissed, and when nobody can be asked (a -p run). */
24 askChoice: (question: string, options: readonly string[]) => Promise<string>
25 /** A short note over the transcript's corner; it leaves the transcript and the model untouched. */
26 toast: (text: string, timeoutMs?: number, level?: ToastLevel) => void
27 invalidate: () => void
28 /** Scrolls the pane back to its first row: a page that was switched to (or opened over this one) starts at its top, not where the last one was left. */
29 scrollTop: () => void
30 /** Moves a pane's focus ring onto an element it drew (a field), while the pane holds the keys. */
31 focus: (paneId: string, key: string) => Promise<unknown>
32 /** Fire and forget: a blit resolves only once painted, and blits between frames fold anyway. */
33 blit: (args: UiBlitArgs) => void
34 openPane: (pane: PaneOpenArgs) => Promise<OpenResult>
35 closePane: (id: string) => Promise<void>
36 panes: () => Promise<readonly { id: string; isShown: boolean; isFocused: boolean }[]>
37 registerCommand: (spec: CommandSpec) => Promise<unknown>
38 run: (argv: readonly string[], timeoutMs: number, stdin?: string) => Promise<ProcessRunResult>
39 /** Starts a command and streams what it writes; `input` goes to its stdin, which is then closed. */
40 spawn: (argv: readonly string[], input?: string) => HookStream<ProcessSpawnChunk, ProcessSpawnResult>
41 usage: () => Promise<{ costUsd?: number; contextPercent?: number }>
42 /** The ruflo / claude-flow MCP tools the model can call now, and the servers they come from. */
43 rufloTools: () => Promise<{ tools: number; servers: string[] }>
44 settings: () => Promise<unknown>
45 home: () => Promise<string | undefined>
46 configDir: () => Promise<string | undefined>
47 /** This plugin's folder: where its own files (the command catalog) are. */
48 pluginRoot: string
49 rufloSnapshot: () => Promise<RufloSnapshot>
50 rufloRoute: () => Promise<RufloRoute | null>
51 rufloSegment: (text: string | null) => Promise<void>
52 /** Submits a prompt to the primary Claude session as a visible turn of its own (once idle). */
53 submitPrompt: (text: string) => Promise<void>
54 /** Puts text in the prompt box as the draft (the person presses Enter); false where there is no box. */
55 fillPrompt: (text: string) => Promise<boolean>
56 /** The names of the slash commands the session offers now (built-in, plugin and MCP alike). */
57 listCommands: () => Promise<string[]>
58 /** Runs a slash command as if typed (built-in, plugin or MCP); queued until the session is idle. */
59 runSlash: (command: string, args: string) => Promise<{ text?: string } | void>
60 /**
61 * Calls an engine tool as the session would ($.tool.call: every hook, the permission check and its dialog, then the tool), from a clock tick
62 * (a tool call waits on the turn). A refusal comes back as `deny`, a failure as `isError`; a missing tool or an aborted call rejects.
63 * Optional: a build that does not bind it makes the control actions fall back to a prompt-box prefill (ADR-465).
64 */
65 toolCall?: (input: { tool: string } & Record<string, unknown>) => Promise<ToolReply>
66 /** The engine's permission verdict for a tool call now, with nothing run and no dialog ($.tool.check). */
67 toolCheck?: (tool: string, input: unknown) => Promise<{ decision: 'allow' | 'ask' | 'deny'; reason?: string }>
68 /** A request with a method, headers and a text body through the host (the OpenAI-compatible targets); the key rides a header and is never logged. */
69 httpSend?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; status: number; text: string }>
70}
71
72/** What a tool call answered: the refusal, or the output (text and structure) and whether it was an error. */
73export type ToolReply = { deny?: string; text?: string; result?: unknown; isError?: boolean }
74hooks/tool-owner.ts 49 lines1/**
2 * Which mission task a tool row belongs to. A call is attributed the first time its row is drawn while it runs: if the
3 * active mission is neither paused nor cancelled and exactly one of its tasks is running (the one handed to the
4 * session), that task owns the call, and keeps it after the task finishes. A call first seen already finished, or
5 * while no task or several tasks run, belongs to no task: the row says nothing rather than guess.
6 */
7import { activeMission, derive } from './mission-control'
8import type { State } from './state'
9
10export type Owner = { taskId: string; title: string; phase: string; missionId: string }
11
12const MAX_REMEMBERED = 500
13const owners = new WeakMap<State, Map<string, Owner | null>>()
14
15/** The one running task of the active mission, or null when none or more than one run (or the mission is paused or cancelled). */
16export function runningOwner(state: State): Owner | null {
17 const mission = activeMission(state)
18
19 if (mission === null || mission.paused || mission.cancelled) return null
20
21 const status = derive(mission, state.snapshot?.tasks ?? [])
22 const running = mission.tasks.filter(task => status.get(task.id) === 'running')
23 const [only] = running
24
25 return running.length === 1 && only !== undefined ? { taskId: only.id, title: only.title, phase: only.phase, missionId: mission.id } : null
26}
27
28/** The owner of one call by its tool-use id: decided at first sight and remembered. */
29export function ownerOf(state: State, toolUseId: string, isRunning: boolean): Owner | null {
30 let known = owners.get(state)
31
32 if (known === undefined) {
33 known = new Map()
34 owners.set(state, known)
35 }
36
37 if (known.has(toolUseId)) return known.get(toolUseId) ?? null
38
39 const owner = isRunning ? runningOwner(state) : null
40
41 known.set(toolUseId, owner)
42 if (known.size > MAX_REMEMBERED) known.delete(known.keys().next().value as string)
43
44 return owner
45}
46
47/** The one dim line a tool row carries under it, e.g. `↳ mission task: Write the tests (test)`. */
48export const ownerLine = (owner: Owner): string => `↳ mission task: ${owner.title} (${owner.phase})`
49