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.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).
.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).hooks/register.ts 492 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { tolerantPress } from './press-guard'
3import { ANSWER_KEYS } from './views/attention'
4
5import { createController, type Controller } from './controller'
6import { record } from './data/events'
7import { plain } from './data/parse'
8import { dispatch } from './dispatch'
9import { markPicture } from './gfx/pictures'
10import type { Host } from './host'
11import { ownerLine, ownerOf } from './tool-owner'
12import { newState, PANE_ID, restore, restoreSessions, storeKeyOf, termStoreKeyOf } from './state'
13import { BAR_KEY, barView } from './views/bar'
14import { addNotice, dismissNotices } from './notices'
15import { setBootChecks } from './boot-checks'
16import { buildOf, isOurCheckout, setBuild } from './build'
17import { runUpdateCheck } from './update-flow'
18import { announceModelTools, parseControlEnv, serveModelTools } from './model-tools'
19import { loadAiPrefs, setControlCap } from './settings'
20import { hydrateWhatsNew } from './whatsnew'
21import { prefsFromStore, recordToast, saveToastPrefs, TOASTS_KEY } from './toasts'
22import { contextSection, onPromptSubmit, onTurnComplete } from './mission-claude'
23import { parseMode, RECHECK_EVERY_MS, UPDATES_KEY } from './updates'
24import { selfCheckResults } from './self-check'
25import type { Kit } from './views/common'
26import { createToaster, type Digest, type ToastLevel, type ToastPrefs } from './toast-policy'
27import { picturesOf } from './views/frames'
28import { withClearing } from './views/clearing'
29import { NARROW, paneView } from './views/pane'
30
31const RUFLO_TOOL = /^mcp__(claude-flow|ruflo|plugin_ruflo[\w-]*)__/
32
33/**
34 * Binds a Host from `$`, every member spelled `$.noun.method(...)` here and nowhere else, so the engine reads what
35 * the module calls off this one place. Calls that answer nothing are wrapped: a refused draw is not a crashed hook.
36 */
37/** 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. */
38let isResettingScroll = false
39
40function hostOf($: EngineInterface, cwd: string, toasts: { prefs: () => ToastPrefs; record: (digest: Digest) => void }): Host {
41 const rooted = (path: string) => (path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`)
42 const quietly = (fn: () => unknown) => {
43 try {
44 const result = fn()
45
46 if (result instanceof Promise) result.catch(() => undefined)
47 } catch {
48 // Refused: there is nothing to do about a draw nobody may make.
49 }
50 }
51
52 // ADR-477: every toast of the console goes through the shared policy (levels, one clean line, de-duplication, a rate limit, the person's
53 // Toasts setting); drawn or not, each is recorded for the Events page. A refused draw is a refused toast, never a crash.
54 const toaster = createToaster({
55 source: 'console',
56 now: () => Date.now(),
57 show: (line, options) => $.ui.toast(line, options),
58 after: (ms, fn) => $.clock.after(ms, fn),
59 prefs: toasts.prefs,
60 persist: toasts.record,
61 })
62
63 return {
64 fs: { read: async path => $.fs.read(rooted(path)), stat: async path => $.fs.stat(rooted(path)), list: async path => $.fs.list(rooted(path)) },
65 every: (ms, fn) => $.clock.every(ms, fn),
66 after: (ms, fn) => $.clock.after(ms, fn),
67 storeGet: async key => $.store.get(key),
68 storeSet: async (key, value) => $.store.set(key, value as never),
69 fetchText: async url => {
70 const response = await $.http.fetch(url)
71
72 return { ok: response.ok, status: response.status, text: response.text }
73 },
74 askChoice: async (question, options) => $.ui.ask(question, options),
75 toast: (text, timeoutMs, level: ToastLevel = 'info') => quietly(() => void toaster.toast({ level, text, ...(timeoutMs !== undefined && { timeoutMs }) })),
76 invalidate: () => quietly(() => $.ui.invalidate('ui.render')),
77 // 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.
78 scrollTop: () => {
79 const go = () =>
80 quietly(() => {
81 isResettingScroll = true
82
83 return Promise.resolve($.ui.scroll({ to: 'start', in: PANE_ID })).finally(() => {
84 isResettingScroll = false
85 })
86 })
87
88 go()
89 $.clock.after(80, go)
90 },
91 focus: async (paneId, key) => $.ui.focus({ requestId: paneId, key }),
92 blit: args => quietly(() => $.ui.blit(args)),
93 openPane: async pane => $.ui.open(pane),
94 closePane: async id => $.ui.close({ id }),
95 panes: async () => $.ui.panes(),
96 registerCommand: async spec => $.command.register(spec),
97 run: async (argv, timeoutMs, stdin) => $.process.run(argv, { cwd, timeoutMs, ...(stdin !== undefined && { stdin }) }),
98 spawn: (argv, input) => $.process.spawn({ argv, cwd, ...(input !== undefined && { input }) }),
99 usage: async () => {
100 const usage = await $.session.usage()
101
102 return { ...(usage.cost?.usd !== undefined && { costUsd: usage.cost.usd }), ...(usage.context?.percent !== undefined && { contextPercent: usage.context.percent }) }
103 },
104 rufloTools: async () => {
105 const names = (await $.tool.list()).flatMap(tool => RUFLO_TOOL.exec(tool.name)?.slice(1, 2) ?? [])
106
107 return { tools: names.length, servers: [...new Set(names)].sort() }
108 },
109 settings: async () => $.settings.read(),
110 home: async () => $.env.get('HOME'),
111 configDir: async () => $.env.get('CLAUDE_CONFIG_DIR'),
112 pluginRoot: $.plugin.root,
113 // `$.ruflo` exists only where ruflo-mods is seated; validate refuses feature-detecting a noun, so these are
114 // async: a missing noun throws inside the promise and every caller's catch sees a rejection.
115 rufloSnapshot: async () => $.ruflo.snapshot(),
116 rufloRoute: async () => $.ruflo.lastRoute(),
117 rufloSegment: async text => $.ruflo.segment({ id: 'console', text }),
118 // 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
119 // tick, a later event of their own.
120 submitPrompt: text =>
121 new Promise<void>((resolve, reject) => {
122 $.clock.after(1, () => void $.prompt.submit({ text }).then(() => resolve(), reject))
123 }),
124 fillPrompt: async text => (await $.prompt.fill({ text, mode: 'replace' })).isFilled,
125 runSlash: (command, args) =>
126 new Promise((resolve, reject) => {
127 $.clock.after(1, () => void $.command.run({ command, args }).then(resolve, reject))
128 }),
129 listCommands: async () => (await $.command.list()).map(command => command.name),
130 // ADR-465. A tool call waits on the turn like submitPrompt, so it starts from a clock tick, never inside the hook that asked.
131 toolCall: input =>
132 new Promise((resolve, reject) => {
133 $.clock.after(1, () => void $.tool.call(input as never).then(reply => resolve(reply as never), reject))
134 }),
135 toolCheck: async (tool, input) => $.tool.check({ tool, input }),
136 httpSend: async (url, init) => {
137 const response = await $.http.fetch(url, init)
138
139 return { ok: response.ok, status: response.status, text: response.text }
140 },
141 }
142}
143
144/**
145 * ruflo-console: ruflo's cockpit inside Claude Code, and the home of `/ruflo`. A pane of views over ruflo's state on
146 * disk and the ruflo CLI's local answers, a band above the prompt, a command palette, and management views (agent
147 * drill-down, timeline, approvals, events). Every change goes through the ruflo CLI with fixed argv after a confirm.
148 */
149export const register: Register = (on, raw: PluginOptions) => {
150 // The boot log reports this check, so an [ OK ] on screen means the area's commands resolved. It spawns nothing and takes a
151 // moment; a failure of the check itself leaves the log drawing as it did, never stops the console.
152 try {
153 setBootChecks(selfCheckResults())
154 } catch {
155 setBootChecks(undefined)
156 }
157
158 const state = newState(raw)
159 let host: Host | null = null
160 let control: Controller | null = null
161
162 // Claude's console tools (ADR-444): answered only for their own names, and only when the person's setting lets them exist.
163 serveModelTools(on, () => (control === null ? null : { state, control }))
164
165 on('session.start', async ($, e, next) => {
166 control?.stop()
167 host = hostOf($, e.cwd, { prefs: () => state.toastPrefs, record: digest => recordToast(state, digest) })
168 state.cwd = e.cwd
169 state.nostrKeyVerifiedAtMs = null
170 state.isInteractive = e.isInteractive !== false
171 control = createController(state, host)
172
173 // Which build is this? Only a checkout of this plugin in its repository is read (an installed copy inside some other repo is not
174 // that repo's commit); read-only, $0, and any failure leaves the header at its version alone.
175 const here = host
176 const root = here.pluginRoot
177
178 const built = here
179 .run(['git', '-C', root, 'rev-parse', '--show-prefix'], 3_000)
180 .then(prefix => (prefix.exitCode === 0 && isOurCheckout(prefix.stdout) ? here.run(['git', '-C', root, 'describe', '--always', '--dirty', '--abbrev=7'], 3_000) : null))
181 .then(described => {
182 setBuild(described !== null && described.exitCode === 0 ? buildOf(described.stdout) : '')
183 here.invalidate()
184 })
185 .catch(() => undefined)
186
187 // 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
188 // an update) and the screen has settled, one check for a newer published version. It never throws and never blocks the console.
189 const moded = here.storeGet(UPDATES_KEY).then(
190 value => {
191 state.updates = parseMode(value)
192 here.invalidate()
193 },
194 () => undefined,
195 )
196
197 // The Toasts setting is the person's, kept in the plugin's store and mirrored to a file the other plugins read (ADR-477).
198 const toasted = here.storeGet(TOASTS_KEY).then(
199 value => {
200 state.toastPrefs = prefsFromStore(value)
201 if (state.toastPrefs.mode !== 'all' || state.toastPrefs.muted.length > 0) void saveToastPrefs(state, here)
202 },
203 () => undefined,
204 )
205
206 // What's new (ADR-478): the record of what was looked at, read once; the controller's first disk read then takes the baseline.
207 const looked = hydrateWhatsNew(state, here).catch(() => undefined)
208
209 void toasted
210 void looked
211 void Promise.all([built, moded]).then(() => {
212 if (!state.isInteractive) return
213
214 here.after(2_500, () => void runUpdateCheck(state, here))
215 // A session left open for days re-asks too, quietly (no dialog mid-work); the daily gate keeps the network to once a day.
216 state.timers.set('update-recheck', here.every(RECHECK_EVERY_MS, () => void runUpdateCheck(state, here, { quiet: true })))
217 })
218
219 const bound = host
220
221 state.home = (await bound.home().catch(() => undefined)) ?? null
222 state.configDir = (await bound.configDir().catch(() => undefined)) ?? (state.home === null ? null : `${state.home}/.claude`)
223 // A recording or a wide screen can ask for a wider dock: RUFLO_CONSOLE_COLUMNS, whole columns, 40 to 400.
224 const asked = Number(await (async () => $.env.get('RUFLO_CONSOLE_COLUMNS'))().catch(() => ''))
225
226 // RUFLO_CONSOLE_PANEL=command|off overrides the panel option for this session (a recording that shows /ruflo opening it).
227 const panel = await (async () => $.env.get('RUFLO_CONSOLE_PANEL'))().catch(() => undefined)
228
229 if (panel === 'command' || panel === 'off') state.options.panel = panel
230 state.dockColumns = Number.isInteger(asked) && asked >= 40 && asked <= 400 ? asked : 0
231 // The x.ruv.io board's admin rows: only whether the token is set is kept, never its value.
232 state.xruv.hasAdminToken = await (async () => $.env.get('RUFLO_X_ADMIN_TOKEN'))().then(
233 value => typeof value === 'string' && value !== '',
234 () => null,
235 )
236 await Promise.all([
237 bound
238 .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]' })
239 .catch(() => undefined),
240 // Kept for good (ADR-406: no command is removed or renamed): `/ruflo-console` is the same command as `/ruflo`.
241 bound.registerCommand({ name: 'ruflo-console', description: 'Same as /ruflo: the ruflo console', argumentHint: '[view|palette|help]' }).catch(() => undefined),
242 bound.storeGet(storeKeyOf(e.cwd)).then(value => restore(state, value), () => undefined),
243 bound.storeGet(termStoreKeyOf(e.cwd)).then(value => restoreSessions(state, value), () => undefined),
244 bound.rufloTools().then(counted => void (state.rufloTools = counted), () => undefined),
245 ])
246 control.start()
247 await control.refresh()
248 control.autoOpen()
249
250 // 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.
251 await loadAiPrefs(state, bound).catch(() => undefined)
252
253 const forced = parseControlEnv(await (async () => $.env.get('RUFLO_CONSOLE_CONTROL'))().catch(() => undefined))
254
255 // The override may only lower what the person saved (ADR-450 T12): a project's settings env must not raise Claude's control.
256 // It is kept as session state and applied on every load and save of the preferences, so opening Settings cannot lift it (#3814).
257 setControlCap(state, forced === null ? null : { level: forced.level, confirm: forced.confirm })
258 await announceModelTools(tool => $.tool.register(tool), state).catch(() => 0)
259
260 return next(e)
261 })
262
263 on('session.end', async ($, e, next) => {
264 control?.stop()
265
266 return next(e)
267 })
268
269 /**
270 * `/ruflo`: the console's own subcommands are answered here; `mods` and `swarm <sub>` go to the plugins beneath that
271 * hook the same command (ruflo-mods, ruflo-swarm), and are answered with a hint when neither does.
272 */
273 on('command.run', { command: 'ruflo' }, async ($, e, next) => {
274 if (control === null) return next(e)
275
276 return dispatch(control, state, e.args, async () => (await next(e)) as { text?: string } | undefined)
277 })
278
279 /** `/ruflo-console` is the same command: ruflo-mods and ruflo-swarm hook it as they hook `/ruflo`. */
280 on('command.run', { command: 'ruflo-console' }, 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 // Which element was pressed or submitted, before its own closure runs: the runner reads it as the origin of the ask that follows, so
287 // the page puts the confirm and the answer right under it (views/attention.ts). Answering a confirm never moves the origin.
288 on('ui.press', { component: 'Pane' }, ($, e, next) => {
289 if (!ANSWER_KEYS.has(e.element)) state.lastPressed = e.element
290
291 // A click that reaches the engine after its drawing was replaced (a resize) finds no handler: answered quietly (press-guard.ts).
292 return tolerantPress(() => next(e), { element: e.element })
293 })
294
295 on('ui.input', { component: 'Pane' }, ($, e, next) => {
296 if (e.kind === 'submit') state.lastPressed = e.element
297
298 return next(e)
299 })
300
301 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
302 if (control === null) {
303 return next(e)
304 }
305
306 const started = Date.now()
307 const table = $.ui.resolve(e) as unknown as Kit
308 const columns = Math.max(20, Math.floor(Number(e.props.bodyColumns) || 0) - 1)
309 const isNarrow = columns < NARROW
310 const kit: Kit = isNarrow ? { Box: table.Box, Text: table.Text, Button: table.Button, ...(table.Input !== undefined && { Input: table.Input }) } : table
311
312 if (!state.pane.isOpen) state.pane.bootAtMs = Date.now()
313 state.pane.isOpen = true
314 state.pane.isFocused = e.props.isFocused === true
315 state.pane.columns = columns
316 state.pane.placement = e.props.placement
317 // A reload while the pane stayed up: timers are gone, so resume them from here.
318 if (!state.timers.has('watch')) control.resume()
319
320 const pictures = isNarrow ? new Map() : picturesOf(state, columns, Date.now(), Date.now())
321
322 state.mounted = new Map([...pictures].map(([key, grid]) => [key, { columns: grid.columns, rows: grid.rows }]))
323 control.animate()
324
325 state.pane.rows = Math.max(0, Math.floor(Number(e.props.scroll?.bodyRows) || 0))
326
327 const tree = paneView({ kit: withClearing(kit, state, control.actions.clearField, { columns, repaint: () => host?.invalidate() }), state, nowMs: Date.now(), columns, pictures, act: control.actions })
328
329 state.stats.renders.push(Date.now() - started)
330 if (state.stats.renders.length > 200) state.stats.renders.shift()
331
332 return tree
333 })
334
335 // The AI terminal's conversation is its own window: the wheel and the page keys over the pane move it, so the header,
336 // tabs and the field below stay where they are (the engine would scroll the whole pane).
337 on('ui.scroll', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
338 if (control === null || state.view !== 'terminal' || e.by === 0 || isResettingScroll) return next(e)
339
340 const lines = Math.abs(e.by) >= e.bodyRows ? Math.max(1, Math.round(e.bodyRows / 2)) : Math.abs(e.by) * 3
341
342 control.actions.term.scroll(e.by < 0 ? lines : -lines)
343
344 // The pane itself stays put: ask the engine for the offset it already has.
345 return next({ ...e, offset: e.offset - e.by })
346 })
347
348 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
349 const mode = state.bandMode ?? state.options.bar
350 const show = mode === 'on' || (mode === 'auto' && state.snapshot?.isRufloProject === true)
351
352 if (control === null || e.props.hasSurvey || !show) {
353 return next(e)
354 }
355
356 const table = $.ui.resolve(e) as unknown as Kit
357 const bound = control
358
359 state.barDrawnAtMs = Date.now()
360
361 const mark = table.Raster !== undefined ? table.Raster(markPicture(e.props.isWorking, Date.now()).toRaster(BAR_KEY)) : null
362
363 state.turnActive = e.props.isWorking === true
364 bound.markFrame(e.requestId, e.props.isWorking && mark !== null)
365
366 // A click on a part opens the console on its view, with the keys, so the person can act there at once.
367 return barView(table, state, Math.floor(Number(e.props.bodyColumns) || 80), mark, () => void bound.open(false), view => {
368 bound.setView(view)
369 void bound.open(true)
370 }, () => {
371 dismissNotices(state)
372 try {
373 $.ui.invalidate('ui.render')
374 } catch {
375 // A refused redraw leaves the notice showing until the next one.
376 }
377 })
378 })
379
380 // A tool row that ran while one mission task was running says which: one dim line under the engine's own row.
381 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
382 const owner = ownerOf(state, e.props.tool_use_id, e.props.isRunning === true)
383
384 if (owner === null) return next(e)
385
386 const table = $.ui.resolve(e) as unknown as Kit
387 const own = await next(e)
388
389 return table.Box({ key: `owner-${e.props.tool_use_id}`, flexDirection: 'column', children: [own, table.Text({ dimColor: true, children: ` ${ownerLine(owner)}` })] })
390 })
391
392 /** The band's mark pulses during a turn: a redraw at its start, and the loop stopped at its end, whatever redraws. */
393 on('turn.start', ($, e, next) => {
394 // A new turn: the per-turn cap on Claude's console actions starts over.
395 state.control.turnCalls = 0
396 if (e.agentId === undefined) state.turnStartedMs = Date.now()
397
398 try {
399 $.ui.invalidate('ui.render')
400 } catch {
401 // A refused redraw leaves the mark at rest.
402 }
403
404 return next(e)
405 })
406
407 on('turn.complete', ($, e, next) => {
408 if (e.agentId === undefined && state.turnStartedMs !== null) {
409 // A long turn that ends while nobody watches is worth saying: the band announces it (a short one is not news).
410 const took = Date.now() - state.turnStartedMs
411
412 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' })
413 }
414 if (e.agentId === undefined) state.turnStartedMs = null
415 if (e.agentId === undefined) control?.markFrame('', false)
416 if (e.agentId === undefined && host !== null) {
417 try {
418 onTurnComplete(state, host, e.reason)
419 } catch {
420 // A note that could not be recorded never changes the turn.
421 }
422 }
423
424 return next(e)
425 })
426
427 // The mission Claude is working on rides in the system prompt (ADR-443); the text changes only when the task does.
428 on('prompt.compose', async ($, e, next) => {
429 const result = await next(e)
430 const section = contextSection(state)
431
432 return section === null ? result : { sections: [...result.sections, section] }
433 })
434
435 // A prompt carrying a mission's loop marker is that loop's tick.
436 on('prompt.submit', ($, e, next) => {
437 if (host !== null) {
438 try {
439 onPromptSubmit(state, host, e.text)
440 } catch {
441 // Counting a tick never blocks the prompt.
442 }
443 }
444
445 return next(e)
446 })
447
448 on('ui.close', async ($, e, next) => {
449 const result = await next(e)
450
451 if (e.id === PANE_ID && result.deny === undefined) {
452 state.pane.isOpen = false
453 state.pane.isShown = false
454 if (e.origin.kind === 'person') control?.closedByPerson()
455 control?.animate()
456 }
457
458 return result
459 })
460
461 /** Observes only: every call goes on unchanged; the count feeds the activity sparkline. */
462 on('tool.call', ($, e, next) => {
463 control?.noteToolCall(e.agentId, e.tool)
464
465 return next(e)
466 })
467
468 /** Observes only: a deny any verdict reached is listed in the approvals queue; the verdict passes on unchanged. */
469 on('tool.check', async ($, e, next) => {
470 const verdict = await next(e)
471
472 if (verdict.decision === 'deny') {
473 state.denied.push({ tool: plain(String(e.tool), 40), reason: plain(verdict.reason ?? 'no reason given', 160), atMs: Date.now() })
474 if (state.denied.length > 20) state.denied.shift()
475 record(state.events, [{ atMs: Date.now(), kind: 'mods', text: `${plain(String(e.tool), 40)} denied: ${plain(verdict.reason ?? '', 80)}` }])
476 }
477
478 return verdict
479 })
480
481 /** Observes only, never refuses: which mods the engine admitted or refused after the console, for the plugins view. */
482 on('plugin.register', async ($, e, next) => {
483 const result = await next(e)
484
485 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() })
486 if (state.mods.length > 50) state.mods.shift()
487 record(state.events, [{ atMs: Date.now(), kind: 'mods', text: `${plain(e.name, 40)} ${result.refuse === undefined ? 'loaded' : 'REFUSED'} (${plain(e.provenance, 60)})` }])
488
489 return result
490 })
491}
492hooks/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/controller.ts 501 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 { markPicture } from './gfx/pictures'
18import type { Host } from './host'
19import { agentLogs } from './ops'
20import { landingRefusal } from './model-tools'
21import { createRunner, type Runner } from './runner'
22import { advance, loadLedger, mcOf } from './mission-control'
23import { hasLiveWork } from './mission-list'
24import { loadAllowed } from './remember'
25import { loadAiPrefs } from './settings'
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 record(state.events, diffEvents(previous, snapshot, now))
138
139 // 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.
140 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)
141
142 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)}%)` }])
143
144 state.usage = usage
145 state.ruflo.snapshot = ruflo
146 state.ruflo.route = route ?? ruflo?.lastRoute ?? null
147 push(state.writes, snapshot.changed)
148
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
158 if (patterns !== undefined && state.history.patterns.at(-1)?.value !== patterns) push(state.history.patterns, { atMs: now, value: patterns })
159 if (usage?.costUsd !== undefined && state.history.spend.at(-1)?.value !== usage.costUsd) push(state.history.spend, { atMs: now, value: usage.costUsd })
160 if ((snapshot.outcomes?.total ?? 0) > state.history.outcomes) {
161 if (state.history.outcomes > 0) state.curveGrewAtMs = now
162 state.history.outcomes = snapshot.outcomes?.total ?? 0
163 }
164
165 if (state.options.bar === 'off') {
166 const text = segmentOf(state)
167
168 if (text !== lastSegment) {
169 lastSegment = text
170 void host.rufloSegment(text).catch(() => undefined)
171 }
172 }
173 } catch (error) {
174 state.ruflo.error = plain(String(error), 120)
175 } finally {
176 state.isRefreshing = false
177 push(state.stats.refreshes, Date.now() - started, 200)
178
179 // Redraw only for something new while the pane is closed: the band need not repaint an unchanged line.
180 const spend = state.usage?.costUsd
181
182 if (state.pane.isOpen || (state.snapshot?.changed ?? 0) > 0 || spend !== lastSpend || !hasDrawn) {
183 hasDrawn = true
184 lastSpend = spend
185 host.invalidate()
186 }
187 }
188 }
189
190 const probesInFlight = new Map<string, Promise<void>>()
191
192 /** One probe run at a time per probe: a second ask while it runs joins it. */
193 function runProbe(probe: (typeof ALL_PROBES)[number]): Promise<void> {
194 const held = probesInFlight.get(probe.id)
195
196 if (held !== undefined) return held
197
198 const run = runProbeOnce(probe).finally(() => probesInFlight.delete(probe.id))
199
200 probesInFlight.set(probe.id, run)
201
202 return run
203 }
204
205 async function runProbeOnce(probe: (typeof ALL_PROBES)[number]): Promise<void> {
206 const held: ProbeResult = state.probes.get(probe.id) ?? { value: null, okAtMs: null, error: null, errorAtMs: null, isRunning: false }
207
208 state.probes.set(probe.id, { ...held, isRunning: true })
209 lastAttempt.set(probe.id, Date.now())
210
211 try {
212 const argv = probeArgv(probe, state.options.cli, state)
213 const result = await host.run(argv, probe.timeoutMs)
214 const value = result.exitCode === 0 ? (probe.parse(result.stdout) as unknown) : null
215 state.probes.set(
216 probe.id,
217 value !== null
218 ? { value, okAtMs: Date.now(), error: null, errorAtMs: held.errorAtMs, isRunning: false }
219 : {
220 ...held,
221 isRunning: false,
222 errorAtMs: Date.now(),
223 error: probeError(argv, result),
224 },
225 )
226 } catch (error) {
227 state.probes.set(probe.id, { ...held, isRunning: false, errorAtMs: Date.now(), error: plain(error instanceof Error ? error.message : String(error), 100) || 'refused' })
228 } finally {
229 host.invalidate()
230 }
231 }
232
233 /** Runs the probes the view in front draws, each no more often than its cadence; `force` ignores the cadence. */
234 async function probe(force = false): Promise<void> {
235 const now = Date.now()
236 const due = ALL_PROBES.filter(
237 entry =>
238 (isVisible() || force) &&
239 entry.views.includes(state.view) &&
240 (!entry.isNetwork || state.options.federationNetwork) && probeReady(entry, state) &&
241 (force || (state.probes.get(entry.id)?.isRunning !== true && now - (lastAttempt.get(entry.id) ?? 0) >= entry.everyMs)),
242 )
243
244 for (let i = 0; i < due.length; i += MAX_PARALLEL_PROBES) {
245 await Promise.all(due.slice(i, i + MAX_PARALLEL_PROBES).map(entry => runProbe(entry)))
246 }
247 }
248
249 function every(name: string, ms: number, fn: () => void): void {
250 if (!state.timers.has(name)) state.timers.set(name, host.every(ms, fn))
251 }
252
253 function cancel(name: string): void {
254 state.timers.get(name)?.cancel()
255 state.timers.delete(name)
256 }
257
258 // Whether the last frame drew the boot screen: when it ends the whole pane redraws once, and an unfocused pane's loop stops again.
259 let wasBooting = false
260
261 /** One frame of every picture of the view in front, each blitted only at the size it was mounted. */
262 function frame(): void {
263 const started = Date.now()
264 const booting = isBooting(state, started)
265
266 if (wasBooting && !booting) {
267 wasBooting = false
268 state.pane.menuAtMs = Date.now()
269 host.invalidate()
270 animate()
271
272 return
273 }
274
275 wasBooting = booting
276
277 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()
278
279 for (const [key, grid] of 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.mounted.size > 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 void loadAiPrefs(state, host)
321 void loadAllowed(state, host)
322 void loadLedger(state, host)
323 void host.storeGet(NAV_KEY).then(saved => {
324 const style = NAV_STYLES.find(candidate => candidate === saved)
325
326 if (style !== undefined) state.nav = style
327 }, () => undefined)
328
329 every('refresh', state.options.refreshSeconds * 1000, () => {
330 const now = Date.now()
331 const isSeen = state.pane.isOpen || now - state.barDrawnAtMs < BAR_FRESH_MS
332
333 // Nothing on screen reads the disk: re-read only on the idle cadence, so a closed console costs nearly nothing.
334 if (isSeen || now - lastIdleMs >= IDLE_REFRESH_MS) {
335 lastIdleMs = now
336 void refresh().then(() => {
337 void probe()
338 void refreshWorkflows(state, host)
339 advance(state, host)
340 })
341 }
342 })
343 every('activity', ACTIVITY_BUCKET_MS, () => {
344 push(state.activity, activityCount)
345 activityCount = 0
346 })
347 }
348
349 const resume = () => every('watch', PANE_WATCH_MS, () => void watchPane())
350
351 function stop(): void {
352 for (const timer of state.timers.values()) timer.cancel()
353 state.timers.clear()
354 }
355
356 /** `closeOnEscape` false: take the keys but leave Esc handing them back, as an auto-opened pane does. */
357 async function open(focus = true, closeOnEscape = focus): Promise<{ isPlaced: boolean; reason: string }> {
358 try {
359 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 }) })
360 const isPlaced = result === undefined || result.isPlaced !== false
361
362 if (isPlaced && !state.pane.isOpen) state.pane.bootAtMs = Date.now()
363 state.pane.isOpen = isPlaced
364 state.pane.isShown = isPlaced
365 if (focus) state.pane.isClosedByPerson = false
366 if (isPlaced) persist()
367 resume()
368 void refresh().then(() => probe(true))
369
370 return { isPlaced, reason: result?.reason ?? '' }
371 } catch (error) {
372 return { isPlaced: false, reason: plain(error instanceof Error ? error.message : String(error), 160) }
373 }
374 }
375
376 function autoOpen(): void {
377 if (state.options.panel !== 'auto' || state.snapshot?.isRufloProject !== true || state.pane.isOpen || state.pane.isClosedByPerson || state.pane.autoTried) return
378
379 state.pane.autoTried = true
380 // From a timer, never a render hook; without `focus`, so the prompt keeps the keys. The engine seats an unasked pane
381 // only where it docks (144 columns and up) and answers why not otherwise: the band then says "/ruflo to open".
382 state.timers.set(
383 'auto-open',
384 host.after(50, () => {
385 state.timers.delete('auto-open')
386 // /ruflo <view> may have opened it in the meantime: that choice stands.
387 if (state.pane.isOpen) return
388 // The BBS look opens on its main menu, as a board does after login.
389 if (state.options.look === 'bbs') state.view = 'menu'
390 void open(false).then(result => {
391 state.pane.autoReason = result.isPlaced ? '' : result.reason || 'not placed'
392 host.invalidate()
393 })
394 }),
395 )
396 }
397
398 async function close(): Promise<void> {
399 state.pane.isOpen = false
400 state.pane.isShown = false
401 state.pane.isClosedByPerson = true
402 persist()
403 cancel('frames')
404 cancel('watch')
405 await host.closePane(PANE_ID).catch(() => undefined)
406 }
407
408 function setView(view: State['view']): void {
409 state.isHelp = false
410 state.palette.isOpen = false
411
412 if (view !== state.view) {
413 if (view === 'agent' || state.view !== 'agent') state.back = state.view === 'agent' ? state.back : state.view
414 state.view = view
415 // A group picked on one page (the menu's pages row) does not follow you to the next, or back to this one.
416 state.navPick = null
417 state.pane.viewAtMs = Date.now()
418 state.select.item = 0
419 state.mounted.clear()
420 persist()
421 // A new view asks for its own height inline; the dock ignores it.
422 if (state.pane.isOpen) void host.openPane({ id: PANE_ID, title: 'ruflo', rows: rowsOf(view), ...(state.dockColumns > 0 && { columns: state.dockColumns }) }).catch(() => undefined)
423 void probe(true)
424 host.scrollTop()
425 }
426
427 host.invalidate()
428 // The terminal is for typing: its field takes the keys as it opens, so letters reach it, not the pane's hotkeys.
429 if (view === 'terminal') focusField('term-input')
430 // Opening the skills view is the person asking for its lists (npx skills reaches the network, so never unasked).
431 if (view === 'skills') {
432 void listSkills(state, host)
433 focusField('skills-search')
434 }
435 openLoaders(state, host, view)
436 }
437
438 /**
439 * 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
440 * mouse click on a tab does not give them, so a person who clicked their way to the terminal would type into
441 * Claude's prompt instead. Here the pane takes the keys first (an open with focus), then the ring moves to the field.
442 */
443 function focusField(key: string): void {
444 if (!state.pane.isOpen) return
445
446 const toField = () => void host.focus(PANE_ID, key).catch(() => undefined)
447
448 if (state.pane.isFocused) toField()
449 else void open(true, false).then(result => result.isPlaced && toField())
450 }
451
452 function drill(agentId: string): void {
453 const agent = state.snapshot?.agents.find(entry => entry.id === agentId)
454
455 state.drill = { agentId, logs: null, logsAtMs: 0 }
456 setView('agent')
457
458 const spec = agent === undefined ? null : agentLogs(agent)
459
460 if (spec !== null) readDrillLogs(state, host, agentId, spec.args)
461 }
462
463 const runner = createRunner(state, host, {
464 freshRead, setView, drill,
465 command: name => (name === 'refresh' ? actions.refresh() : name === 'help' ? actions.help() : actions.close()),
466 landingRefusal: pending => landingRefusal(state, pending),
467 })
468 const actions: Actions = actionsOf(state, host, runner, { freshRead, probe, setView, drill, close, animate })
469
470 function markFrame(requestId: string, isWorking: boolean): void {
471 markRequest = requestId
472
473 if (isWorking && state.options.fps > 0) {
474 every('mark', Math.round(1000 / state.options.fps), () => {
475 if (markRequest !== null) host.blit({ requestId: markRequest, key: 'mark', cells: markPicture(true, Date.now()).encode(), columns: 2, rows: 1 })
476 })
477 } else {
478 cancel('mark')
479 }
480 }
481
482 function noteToolCall(agentId: string | undefined, tool: string): void {
483 activityCount += 1
484
485 const who = agentId ?? 'main'
486 const list = state.toolsByAgent.get(who) ?? []
487
488 push(list, { atMs: Date.now(), tool: plain(tool, 40) }, 200)
489 state.toolsByAgent.set(who, list)
490 if (state.toolsByAgent.size > 50) state.toolsByAgent.delete(state.toolsByAgent.keys().next().value as string)
491 record(state.events, [{ atMs: Date.now(), kind: 'tools', text: `${agentName(state, agentId)}: ${plain(tool, 40)}` }])
492 }
493
494 const closedByPerson = () => {
495 state.pane.isClosedByPerson = true
496 persist()
497 }
498
499 return { refresh, probe, start, resume, stop, open, autoOpen, closedByPerson, close, setView, drill, animate, noteToolCall, actions, runner, markFrame, host }
500}
501hooks/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 500 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 const byRecency = (a: SwarmInfo, b: SwarmInfo) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')
183
184 return [...swarms.filter(swarm => swarm.status === 'running')].sort(byRecency)[0] ?? [...swarms].sort(byRecency)[0] ?? null
185}
186
187/** `.swarm/state.json`: the pointer `swarm init` (or `swarm start`) leaves. */
188export function parseSwarmPointer(text: string | null): { id: string; topology?: string; strategy?: string; status?: string } | null {
189 const value = jsonObject(text)
190 const id = idOf(value?.id) ?? idOf(value?.swarmId)
191
192 if (value === null || id === null) {
193 return null
194 }
195
196 const pointer: { id: string; topology?: string; strategy?: string; status?: string } = { id }
197 const topology = stringOf(value.topology, 40)
198 const strategy = stringOf(value.strategy, 40)
199 const status = stringOf(value.status, 40)
200
201 if (topology !== undefined) pointer.topology = topology
202 if (strategy !== undefined) pointer.strategy = strategy
203 if (status !== undefined) pointer.status = status
204
205 return pointer
206}
207
208/** `.claude-flow/agents/store.json`. */
209export function parseAgents(text: string | null): AgentRecord[] {
210 return valuesOf(jsonObject(text)?.agents).flatMap(entry => {
211 const agent = recordOf(entry)
212 const id = idOf(agent?.agentId)
213
214 if (agent === null || id === null) {
215 return []
216 }
217
218 const record: AgentRecord = { id, type: stringOf(agent.agentType, 40) ?? 'agent', status: stringOf(agent.status, 20) ?? 'unknown' }
219 const name = stringOf(agent.name, 40)
220 const health = ratioOf(agent.health)
221 const taskCount = countOf(agent.taskCount)
222 const createdAtMs = msOf(agent.createdAt)
223
224 if (name !== undefined) record.name = name
225 if (health !== undefined) record.health = health
226 if (taskCount !== undefined) record.taskCount = taskCount
227 if (createdAtMs !== undefined) record.createdAtMs = createdAtMs
228
229 return [record]
230 })
231}
232
233/** `.claude-flow/tasks/store.json`. */
234/** A task's result object as one bounded line of `key: value` pairs (strings and numbers only), or undefined. */
235function resultTextOf(value: unknown): string | undefined {
236 const result = recordOf(value)
237
238 if (result === null) return typeof value === 'string' ? plain(value, 400) || undefined : undefined
239
240 const text = Object.entries(result)
241 .slice(0, 8)
242 .flatMap(([key, v]) => (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? [`${plain(key, 24)}: ${plain(String(v), 160)}`] : []))
243 .join(' · ')
244
245 return text === '' ? undefined : text.slice(0, 500)
246}
247
248export function parseTasks(text: string | null): TaskRecord[] {
249 return valuesOf(jsonObject(text)?.tasks).flatMap(entry => {
250 const task = recordOf(entry)
251 const id = idOf(task?.taskId)
252
253 return task === null || id === null
254 ? []
255 : [
256 {
257 id,
258 type: stringOf(task.type, 40) ?? 'task',
259 description: plain(task.description, 200),
260 status: stringOf(task.status, 20) ?? 'unknown',
261 assignedTo: (Array.isArray(task.assignedTo) ? task.assignedTo : []).slice(0, 50).flatMap(agent => (idOf(agent) !== null ? [agent as string] : [])),
262 ...(msOf(task.createdAt) !== undefined && { createdAtMs: msOf(task.createdAt) }),
263 tags: (Array.isArray(task.tags) ? task.tags : []).slice(0, 12).flatMap(tag => (typeof tag === 'string' && /^[A-Za-z0-9_.:-]{1,90}$/.test(tag) ? [tag] : [])),
264 ...(msOf(task.startedAt) !== undefined && { startedAtMs: msOf(task.startedAt) }),
265 ...(msOf(task.completedAt) !== undefined && { completedAtMs: msOf(task.completedAt) }),
266 ...(resultTextOf(task.result) !== undefined && { resultText: resultTextOf(task.result) }),
267 },
268 ]
269 })
270}
271
272function claimantOf(value: unknown): Claimant | null {
273 const claimant = recordOf(value)
274
275 if (claimant === null) {
276 return null
277 }
278
279 const isAgent = claimant.type === 'agent'
280 const id = idOf(isAgent ? claimant.agentId : claimant.userId)
281
282 if (id === null) {
283 return null
284 }
285
286 const who: Claimant = { kind: isAgent ? 'agent' : 'human', id }
287 const agentType = stringOf(claimant.agentType, 40)
288 const name = stringOf(claimant.name, 40)
289
290 if (agentType !== undefined) who.agentType = agentType
291 if (name !== undefined) who.name = name
292
293 return who
294}
295
296/** `.claude-flow/claims/claims.json`: issue claims (who works on what), not the authorization file `.claude-flow/claims.json`. */
297export function parseClaims(text: string | null): ClaimRecord[] {
298 const store = jsonObject(text)
299 const stealable = recordOf(store?.stealable) ?? {}
300
301 return valuesOf(store?.claims).flatMap(entry => {
302 const claim = recordOf(entry)
303 const issueId = idOf(claim?.issueId)
304 const claimant = claimantOf(claim?.claimant)
305
306 if (claim === null || issueId === null || claimant === null) {
307 return []
308 }
309
310 const handoff = recordOf(claim.handoffTo)
311 const handoffTo = idOf(handoff?.agentId ?? handoff?.userId)
312 const record: ClaimRecord = {
313 issueId,
314 status: stringOf(claim.status, 30) ?? 'unknown',
315 claimant,
316 isStealable: claim.status === 'stealable' || Object.hasOwn(stealable, issueId),
317 }
318 const progress = numberOf(claim.progress)
319 const claimedAtMs = msOf(claim.claimedAt)
320 const changedAtMs = msOf(claim.statusChangedAt)
321 const expiresAtMs = msOf(claim.expiresAt)
322 const context = stringOf(claim.context, 120)
323
324 if (progress !== undefined) record.progress = Math.max(0, Math.min(100, progress))
325 if (handoffTo !== null) record.handoffTo = handoffTo
326 if (claimedAtMs !== undefined) record.claimedAtMs = claimedAtMs
327 if (changedAtMs !== undefined) record.changedAtMs = changedAtMs
328 if (expiresAtMs !== undefined) record.expiresAtMs = expiresAtMs
329 if (context !== undefined) record.context = context
330
331 return [record]
332 })
333}
334
335const votesOf = (value: unknown): { votesFor: number; votesAgainst: number; ballots: Ballot[] } => {
336 const ballots = Object.entries(recordOf(value) ?? {})
337 .slice(0, MAX_RECORDS)
338 .flatMap(([voter, vote]) => (idOf(voter) !== null && typeof vote === 'boolean' ? [{ voter, isFor: vote }] : []))
339
340 return { votesFor: ballots.filter(ballot => ballot.isFor).length, votesAgainst: ballots.filter(ballot => !ballot.isFor).length, ballots }
341}
342
343const idsOf = (value: unknown, max = 50): string[] => (Array.isArray(value) ? value : []).slice(0, max).flatMap(entry => (idOf(entry) !== null ? [entry as string] : []))
344
345/** A proposal's value as one bounded line: a string as it is, anything else as its JSON. */
346function valueText(value: unknown): string | undefined {
347 if (typeof value === 'string') return stringOf(value, 120)
348 if (value === undefined || value === null) return undefined
349
350 try {
351 return stringOf(JSON.stringify(value).slice(0, 400), 120)
352 } catch {
353 return undefined
354 }
355}
356
357function proposalOf(entry: unknown): Proposal | null {
358 const proposal = recordOf(entry)
359 const id = idOf(proposal?.proposalId)
360
361 if (proposal === null || id === null) return null
362
363 const out: Proposal = {
364 id,
365 type: stringOf(proposal.type, 40) ?? 'proposal',
366 status: stringOf(proposal.status, 20) ?? 'pending',
367 strategy: stringOf(proposal.strategy, 20) ?? 'unknown',
368 ...votesOf(proposal.votes),
369 byzantine: idsOf(proposal.byzantineVoters),
370 }
371 const value = valueText(proposal.value)
372 const proposedBy = idOf(proposal.proposedBy)
373 const proposedAtMs = msOf(proposal.proposedAt)
374 const term = numberOf(proposal.term)
375 const timeoutAtMs = msOf(proposal.timeoutAt)
376 const quorumPreset = stringOf(proposal.quorumPreset, 20)
377
378 if (value !== undefined) out.value = value
379 if (proposedBy !== null) out.proposedBy = proposedBy
380 if (proposedAtMs !== undefined) out.proposedAtMs = proposedAtMs
381 if (term !== undefined) out.term = term
382 if (timeoutAtMs !== undefined) out.timeoutAtMs = timeoutAtMs
383 if (quorumPreset !== undefined) out.quorumPreset = quorumPreset
384
385 return out
386}
387
388function decisionOf(entry: unknown): Decision | null {
389 const decision = recordOf(entry)
390 const id = idOf(decision?.proposalId)
391 const votes = recordOf(decision?.votes)
392
393 if (decision === null || id === null) return null
394
395 const out: Decision = {
396 id,
397 type: stringOf(decision.type, 40) ?? 'proposal',
398 result: stringOf(decision.result, 20) ?? 'unknown',
399 votesFor: countOf(votes?.for) ?? 0,
400 votesAgainst: countOf(votes?.against) ?? 0,
401 byzantine: idsOf(decision.byzantineDetected).length,
402 }
403 const strategy = stringOf(decision.strategy, 20)
404 const term = numberOf(decision.term)
405 const decidedAtMs = msOf(decision.decidedAt)
406
407 if (strategy !== undefined) out.strategy = strategy
408 if (term !== undefined) out.term = term
409 if (decidedAtMs !== undefined) out.decidedAtMs = decidedAtMs
410
411 return out
412}
413
414/** `sharedMemory.broadcasts`, the last 20, each field bounded: the message is free text another process wrote. */
415function broadcastsOf(value: unknown): Broadcast[] {
416 return (Array.isArray(value) ? value : []).slice(-20).flatMap(entry => {
417 const message = recordOf(entry)
418 const id = idOf(message?.messageId)
419 const text = stringOf(message?.message, 160)
420
421 if (message === null || id === null || text === undefined) return []
422
423 const atMs = msOf(message.timestamp)
424
425 return [{ id, message: text, priority: stringOf(message.priority, 12) ?? 'normal', from: idOf(message.fromId) ?? 'system', ...(atMs !== undefined && { atMs }) }]
426 })
427}
428
429/** `.claude-flow/hive-mind/state.json`, without its capability token. */
430export function parseHive(text: string | null): HiveInfo | null {
431 const hive = jsonObject(text)
432
433 if (hive === null || hive.initialized !== true) {
434 return null
435 }
436
437 const queen = recordOf(hive.queen)
438 const queenId = idOf(queen?.agentId)
439 const consensus = recordOf(hive.consensus)
440 const shared = recordOf(hive.sharedMemory)
441 const info: HiveInfo = {
442 topology: stringOf(hive.topology, 40) ?? 'unknown',
443 workers: idsOf(hive.workers, MAX_RECORDS),
444 pending: (Array.isArray(consensus?.pending) ? consensus.pending : []).slice(-50).flatMap(entry => proposalOf(entry) ?? []),
445 history: (Array.isArray(consensus?.history) ? consensus.history : []).slice(-50).flatMap(entry => decisionOf(entry) ?? []),
446 broadcasts: broadcastsOf(shared?.broadcasts),
447 memoryKeys: Object.keys(shared ?? {}).slice(0, 200).flatMap(key => (idOf(key) !== null ? [key] : [])),
448 }
449 const strategy = stringOf(hive.consensusStrategy, 30)
450 const term = numberOf(queen?.term)
451 const electedAtMs = msOf(queen?.electedAt)
452 const createdAtMs = msOf(hive.createdAt)
453 const updatedAtMs = msOf(hive.updatedAt)
454
455 if (strategy !== undefined) info.strategy = strategy
456 if (queenId !== null) info.queen = queenId
457 if (term !== undefined) info.queenTerm = term
458 if (electedAtMs !== undefined) info.queenElectedAtMs = electedAtMs
459 if (createdAtMs !== undefined) info.createdAtMs = createdAtMs
460 if (updatedAtMs !== undefined) info.updatedAtMs = updatedAtMs
461
462 return info
463}
464
465/** `.claude-flow/agents.json`: the workers `hive-mind spawn` writes (the other agent tools use agents/store.json), with their hive role. */
466export function parseHiveAgents(text: string | null): HiveAgentRecord[] {
467 const roles = new Map(
468 valuesOf(jsonObject(text)?.agents).flatMap(entry => {
469 const agent = recordOf(entry)
470 const id = idOf(agent?.agentId)
471 const role = stringOf(recordOf(agent?.config)?.hiveRole, 20)
472
473 return id !== null && role !== undefined ? [[id, role] as const] : []
474 }),
475 )
476
477 return parseAgents(text).map(agent => {
478 const role = roles.get(agent.id)
479
480 return role === undefined ? agent : { ...agent, role }
481 })
482}
483
484/** The tail of an id a person can tell apart at a glance: `agent-1790954653916-od014i` → `od014i`. */
485export function shortId(id: string): string {
486 const tail = id.split(/[-_:]/).pop() ?? id
487
488 return tail.length >= 4 ? tail.slice(-6) : id.slice(-6)
489}
490
491/** One readable label per agent: its name, else its type, with a short id only where two would read the same. */
492export function agentLabels(agents: readonly { id: string; name?: string; type: string }[]): Map<string, string> {
493 const base = (agent: { name?: string; type: string }) => agent.name ?? agent.type
494 const counts = new Map<string, number>()
495
496 for (const agent of agents) counts.set(base(agent), (counts.get(base(agent)) ?? 0) + 1)
497
498 return new Map(agents.map(agent => [agent.id, (counts.get(base(agent)) ?? 0) > 1 ? `${base(agent)}·${shortId(agent.id).slice(-4)}` : base(agent)]))
499}
500hooks/dispatch.ts 205 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 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') }
134 }
135
136 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')) }
137 }
138 case 'confirm':
139 if (state.pending === null) return { text: 'Nothing is waiting for a confirm.' }
140
141 if (!intent.isYes) {
142 control.runner.cancel()
143
144 return { text: 'Cancelled.' }
145 }
146
147 const confirmedAtMs = Date.now()
148
149 await control.runner.confirm()
150
151 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)' : ''}`) }
152 case 'agent': {
153 const who = intent.who.toLowerCase()
154 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))
155
156 if (agent === undefined) return { text: `No agent "${plain(intent.who, 40)}" in .claude-flow/agents/store.json.` }
157
158 control.drill(agent.id)
159
160 return open(control, state, `agent ${agent.name ?? agent.type}`)
161 }
162 case 'back':
163 control.actions.back()
164
165 return open(control, state, VIEWS.find(view => view.id === state.view)?.label ?? 'back')
166 case 'select':
167 control.actions.select(intent.by)
168
169 return { text: `selection moved (${intent.by > 0 ? 'next' : 'prev'}) on ${state.view}` }
170 case 'filter':
171 state.eventFilter = intent.filter
172 control.setView('events')
173
174 return open(control, state, `events · ${intent.filter}`)
175 case 'dump': {
176 const view = intent.view ?? state.view
177 const shown = state.view
178
179 return { text: await dumpOf(control, state, view) }
180 }
181 case 'band':
182 control.host.invalidate()
183
184 return { text: bandReply(state, intent.arg) }
185 case 'notices':
186 return { text: noticesReply(state, Date.now(), intent.isClear) }
187 case 'quiet':
188 control.host.invalidate()
189
190 return { text: quietReply(state, Date.now(), intent.arg) }
191 case 'autopilot': {
192 const apHost = hostOf(state)
193
194 return { text: apHost === undefined ? 'autopilot is not wired into this console yet' : await autopilotCommand(state, apHost, intent.arg) }
195 }
196 case 'commands':
197 return { text: commandsText(await loadCatalog(control), intent.query) }
198 case 'events':
199 case 'timeline':
200 return watchCommand(control, state, intent)
201 case 'unknown':
202 return { text: `Unknown: "${plain(intent.word, 30)}". /ruflo help lists the views (${VIEWS.map(view => view.id).join(', ')}) and commands.` }
203 }
204}
205hooks/gfx/pictures.ts 441 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 { Braille, COLOR, Grid, mix, ramp, sparkline } from './raster'
8import { bigText } from './font'
9import { hash } from './boot-cyber'
10import { getBuild } from '../build'
11import { CONSOLE_VERSION } from '../version'
12
13export { bootPicture, BOOT_ROWS } from './boot'
14
15export type TopoNode = { id: string; label: string; status: string; isLeader: boolean; /** When the console last saw an event about it. */ pulseAtMs?: number }
16export type TopoModel = { topology: string; nodes: TopoNode[] }
17
18export const PULSE_MS = 1_400
19/** How long a work-in-flight dot takes from the leader to a busy agent. */
20export const FLIGHT_MS = 1400
21const isBusy = (status: string) => /busy|active|running|working/i.test(status)
22const isDown = (status: string) => /stop|terminat|offline|dead|error|fail/i.test(status)
23
24export function nodeColor(node: TopoNode): number {
25 if (node.isLeader) return COLOR.accent
26 if (isDown(node.status)) return /error|fail/i.test(node.status) ? COLOR.bad : COLOR.dim
27 if (isBusy(node.status)) return COLOR.warn
28
29 return COLOR.info
30}
31
32/**
33 * Where each node sits, in braille dots, by topology: a tree (rows of workers under the leader) for hierarchical and
34 * star, a circle for mesh and ring. Large swarms wrap into more rows rather than overprinting.
35 */
36export function layout(model: TopoModel, width: number, height: number): { x: number; y: number }[] {
37 const n = model.nodes.length
38 const topology = model.topology.toLowerCase()
39 const isCircle = (topology.includes('mesh') && !topology.includes('hierarchical')) || topology.includes('ring')
40
41 if (n === 0) return []
42
43 if (!isCircle) {
44 const workers = n - 1
45 const perRow = Math.max(1, Math.min(workers, Math.floor(width / 10)))
46 const tiers = Math.max(1, Math.ceil(workers / perRow))
47 const top = 3
48 const span = Math.max(4, height - 6 - top)
49
50 return model.nodes.map((_, i) => {
51 if (i === 0) return { x: width / 2, y: top }
52
53 const k = i - 1
54 const tier = Math.floor(k / perRow)
55 const inTier = Math.min(perRow, workers - tier * perRow)
56 const slot = k % perRow
57
58 return { x: ((slot + 0.5) / inTier) * (width - 8) + 4, y: top + 6 + (tiers === 1 ? span - 2 : (tier / Math.max(1, tiers - 1)) * (span - 2)) }
59 })
60 }
61
62 const cx = width / 2
63 const cy = height / 2
64 const r = Math.max(4, Math.min(width / 2 - 6, height / 2 - 3))
65
66 return model.nodes.map((_, i) => {
67 const angle = -Math.PI / 2 + (i / n) * Math.PI * 2
68
69 return { x: cx + Math.cos(angle) * r * 1.6, y: cy + Math.sin(angle) * r }
70 })
71}
72
73/** The edges a topology draws between node indexes (capped: a 100-agent mesh draws its first 300). */
74export function edges(model: TopoModel): [number, number][] {
75 const n = model.nodes.length
76 const out: [number, number][] = []
77 const topology = model.topology.toLowerCase()
78
79 if (n < 2) return out
80
81 if (topology.includes('mesh') && !topology.includes('hierarchical')) {
82 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])
83 } else if (topology.includes('ring')) {
84 for (let a = 0; a < n; a++) out.push([a, (a + 1) % n])
85 } else {
86 for (let b = 1; b < n; b++) out.push([0, b])
87 if (topology.includes('hierarchical-mesh')) for (let a = 1; a < n - 1; a++) out.push([a, a + 1])
88 }
89
90 return out
91}
92
93/**
94 * The swarm graph: nodes coloured by the status ruflo wrote (busy amber, idle blue, stopped grey), the leader starred.
95 * A dot runs from the leader to a node once each time the console sees an event about that agent (data); the leader's
96 * slow heartbeat is decoration.
97 */
98export function topologyPicture(model: TopoModel, columns: number, rows: number, t: number): Grid {
99 const grid = new Grid(columns, rows)
100 const canvas = new Braille(columns, rows)
101 const points = layout(model, canvas.width, canvas.height)
102 const links = edges(model)
103 const leader = points[0]
104
105 for (const [a, b] of links) {
106 const p = points[a]
107 const q = points[b]
108
109 if (p !== undefined && q !== undefined) canvas.line(p.x, p.y, q.x, q.y, COLOR.line)
110 }
111
112 // Work in flight: while ruflo has an agent busy, a dim amber dot keeps travelling down its edge from the leader.
113 // It runs only for as long as the status says busy, so it is data, not decoration; each agent has its own phase.
114 model.nodes.forEach((node, i) => {
115 const q = points[i]
116
117 if (i === 0 || leader === undefined || q === undefined || !isBusy(node.status)) return
118
119 const k = (((t / FLIGHT_MS + i * 0.37) % 1) + 1) % 1
120 const x = leader.x + (q.x - leader.x) * k
121 const y = leader.y + (q.y - leader.y) * k
122
123 canvas.dot(x, y, COLOR.warn)
124 canvas.dot(x + 1, y, COLOR.warn)
125 })
126
127 model.nodes.forEach((node, i) => {
128 const q = points[i]
129 const k = node.pulseAtMs === undefined ? -1 : (t - node.pulseAtMs) / PULSE_MS
130
131 if (i === 0 || leader === undefined || q === undefined || k < 0 || k > 1) return
132
133 const x = leader.x + (q.x - leader.x) * k
134 const y = leader.y + (q.y - leader.y) * k
135
136 // Two dots wide, so a pulse on a vertical edge stands out of the line rather than sitting on its dots.
137 canvas.dot(x, y, 0xffffff)
138 canvas.dot(x + 1, y, 0xffffff)
139 })
140
141 canvas.blitInto(grid, 0, 0)
142
143 const room = Math.floor(columns / Math.max(2, Math.min(model.nodes.length, Math.floor(canvas.width / 10))))
144
145 points.forEach((point, i) => {
146 const node = model.nodes[i]
147
148 if (node === undefined) return
149
150 const cx = Math.floor(point.x / 2)
151 const cy = Math.floor(point.y / 4)
152 const heartbeat = node.isLeader ? Math.max(0, Math.sin(t / 260)) ** 6 : 0
153 const flash = node.pulseAtMs !== undefined && t - node.pulseAtMs >= 0 && t - node.pulseAtMs < PULSE_MS + 600
154 // A busy agent breathes (brighter and back, about once every 2 s) so it reads as working, not just coloured.
155 const breath = !node.isLeader && isBusy(node.status) ? 0.45 * Math.sin(t / 330 + i) ** 2 : 0
156 const color = flash ? 0xffffff : node.isLeader ? mix(COLOR.accent, 0xffffff, heartbeat) : mix(nodeColor(node), 0xffffff, breath)
157
158 grid.set(cx, cy, node.isLeader ? '★' : isBusy(node.status) ? '◉' : '●', color)
159
160 if (room >= 5 || node.isLeader) {
161 const label = node.label.slice(0, Math.max(3, room - 1))
162
163 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 ? COLOR.accent : nodeColor(node))
164 }
165 })
166
167 return grid
168}
169
170/**
171 * Two measured series as sparklines with their labels: tool calls the console saw per 5 s, and ruflo state files that
172 * changed per refresh. The newest bar glows while the pane animates: decoration over measured bars.
173 */
174export function activityPicture(series: readonly { label: string; values: readonly number[] }[], columns: number, t: number): Grid {
175 const grid = new Grid(columns, Math.max(1, series.length))
176 const labelWidth = Math.min(18, Math.max(8, ...series.map(entry => entry.label.length + 1)))
177 const width = Math.max(4, columns - labelWidth)
178
179 series.forEach((entry, row) => {
180 grid.text(0, row, entry.label.slice(0, labelWidth - 1), COLOR.dim)
181 sparkline(grid, labelWidth, row, width, entry.values, v => ramp(0.3 + v * 0.7))
182
183 const glow = 0.5 + 0.5 * Math.sin(t / 300)
184 const last = labelWidth + width - 1
185
186 if ((entry.values[entry.values.length - 1] ?? 0) > 0) grid.set(last, row, grid.glyph(last, row), mix(COLOR.info, 0xffffff, glow * 0.6))
187 })
188
189 return grid
190}
191
192/**
193 * The running success rate of routed tasks (routing-outcomes.json), oldest left, as a braille line over a 0-100% frame.
194 * When new outcomes arrive the newest stretch draws in over 900 ms from `grewAtMs`: that motion is data arriving.
195 */
196export function curvePicture(points: readonly boolean[], columns: number, rows: number, t: number, grewAtMs = 0): Grid {
197 const grid = new Grid(columns, rows)
198 const canvas = new Braille(Math.max(1, columns - 5), rows)
199 const n = points.length
200
201 for (let r = 0; r < rows; r++) grid.text(0, r, r === 0 ? '100%' : r === rows - 1 ? ' 0%' : ' ', COLOR.dim)
202
203 if (n === 0) {
204 grid.text(6, Math.floor(rows / 2), 'no routed outcomes on disk yet', COLOR.dim)
205
206 return grid
207 }
208
209 let ok = 0
210 const rates = points.map((point, i) => {
211 ok += point ? 1 : 0
212
213 return ok / (i + 1)
214 })
215 const xOf = (i: number) => (n === 1 ? canvas.width / 2 : (i / (n - 1)) * (canvas.width - 1))
216 const yOf = (rate: number) => (1 - rate) * (canvas.height - 1)
217 const drawIn = grewAtMs > 0 ? Math.max(0, Math.min(1, (t - grewAtMs) / 900)) : 1
218 const shown = Math.max(1, Math.round(n * (0.8 + 0.2 * drawIn)))
219
220 for (let x = 0; x < canvas.width; x += 4) canvas.dot(x, yOf(0.5), COLOR.line)
221 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))
222 if (n === 1) canvas.dot(xOf(0), yOf(rates[0] as number), ramp(rates[0] as number))
223
224 canvas.blitInto(grid, 5, 0)
225
226 return grid
227}
228
229/** The band's mark: a diamond that pulses while Claude works and rests otherwise. */
230export function markPicture(isWorking: boolean, t: number): Grid {
231 const grid = new Grid(2, 1)
232 const k = isWorking ? 0.5 + 0.5 * Math.sin(t / 220) : 1
233
234 grid.set(0, 0, '◆', isWorking ? mix(COLOR.line, COLOR.accent, k) : COLOR.accent)
235
236 return grid
237}
238
239/** The pane's title strip: a highlight sweeps across it every few seconds while the pane is focused. Decoration only. */
240/** RUFLO in a two-row half-block font, the way a BBS splash spelled its name. */
241const LOGO = ['█▀█ █ █ █▀▀ █ █▀█', '█▀▄ █▄█ █▀ █▄▄ █▄█'] as const
242const NEON_MAGENTA = 0xff2a6d
243const NEON_CYAN = 0x05d9e8
244
245/** A header strikes in over this long when its page is switched to, and when the menu enters. */
246export const TITLE_ENTRY_MS = 1_200
247const GLITCH = '#%&@/\\|<>=+*'
248
249/**
250 * The strike-in shared by the page titles and the menu banner: from `from`, the letters appear left to right, a bright edge leading
251 * 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),
252 * and now and then a row slips one cell sideways. Hash-driven, so a frame is reproducible; nothing once `age` reaches TITLE_ENTRY_MS.
253 */
254function strikeIn(grid: Grid, from: number, age: number, t = 0): void {
255 if (age >= TITLE_ENTRY_MS) return occasionalGlitch(grid, from, t)
256
257 let last = from
258
259 for (let i = 0; i < grid.columns * grid.rows; i++) if (grid.cells[i * 3] !== 0x20) last = Math.max(last, i % grid.columns)
260
261 const span = last - from + 1
262 const lead = from + (age / TITLE_ENTRY_MS) * (span + 1)
263
264 for (let y = 0; y < grid.rows; y++) {
265 for (let x = from; x <= last; x++) {
266 if (grid.glyph(x, y) === 0x20) continue
267
268 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))
269 else if (x > lead - 1.5) grid.set(x, y, grid.glyph(x, y), 0xffffff)
270 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)
271 }
272
273 const slip = hash(y * 5 + Math.floor(age / 80))
274
275 if (slip % 16 === 0 && age < TITLE_ENTRY_MS - 150) {
276 const by = (slip >>> 4) % 2 === 0 ? 1 : -1
277 const row = grid.cells.slice(y * grid.columns * 3, (y + 1) * grid.columns * 3)
278
279 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)
280 }
281 }
282}
283
284/** One burst every BURST_EVERY_MS at a hash-chosen moment in its slot, lasting BURST_MS. */
285const BURST_EVERY_MS = 8_000
286const BURST_MS = 260
287
288/**
289 * 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
290 * 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,
291 * fps 0) is never glitched.
292 */
293function occasionalGlitch(grid: Grid, from: number, t: number): void {
294 if (t <= 0) return
295
296 const slot = Math.floor(t / BURST_EVERY_MS)
297 const at = t - (slot * BURST_EVERY_MS + (hash(slot + 977) % (BURST_EVERY_MS - 1_000)))
298
299 if (at < 0 || at >= BURST_MS) return
300
301 const frame = Math.floor(at / 45)
302
303 for (let y = 0; y < grid.rows; y++) {
304 for (let x = from; x < grid.columns; x++) {
305 if (grid.glyph(x, y) === 0x20) continue
306 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)
307 }
308
309 if (hash(y * 3 + frame + slot) % 5 === 0) {
310 const row = grid.cells.slice(y * grid.columns * 3, (y + 1) * grid.columns * 3)
311
312 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)
313 }
314 }
315}
316
317/**
318 * The BBS banner: the logo in a magenta-to-cyan gradient with a scanline sweeping across it (decoration), a tag line,
319 * the project, and a blinking block cursor. Two rows.
320 */
321export function bannerPicture(project: string, columns: number, t: number, age = Infinity): Grid {
322 const grid = new Grid(columns, 2)
323 const width = LOGO[0].length
324 const sweep = ((t / 28) % (columns + 40)) - 20
325
326 LOGO.forEach((line, y) => {
327 ;[...line].forEach((ch, x) => {
328 if (ch === ' ' || x >= columns) return
329
330 const base = mix(NEON_MAGENTA, NEON_CYAN, x / Math.max(1, width - 1))
331 const glow = Math.max(0, 1 - Math.abs(x - sweep) / 4)
332
333 grid.set(x, y, ch, mix(base, 0xffffff, glow * 0.7))
334 })
335 })
336
337 const x0 = width + 2
338
339 if (columns > x0 + 4) {
340 // The version, and the git revision when the session knows it: the revision changes with every commit, so it shows which build is loaded.
341 // 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.
342 const title = '░▒▓ AGENT SWARM CONSOLE'
343 const full = `${title} v${CONSOLE_VERSION}${getBuild() === '' ? '' : ` · ${getBuild()}`}`
344 // The longest that fits, never cut mid-word: version and build, the title, a shorter title, the shortest.
345 const room = columns - x0
346 const shown = [full, title, '░▒▓ SWARM CONSOLE', '░▒▓ CONSOLE'].find(text => text.length <= room) ?? '░▒▓ CONSOLE'
347
348 grid.text(x0, 0, shown.slice(0, room), NEON_MAGENTA)
349
350 const line = `▸ npx ruflo · ${project}`
351 const node = line.length <= room - 2 ? line : `${line.slice(0, Math.max(1, room - 3))}…`
352
353 grid.text(x0, 1, node, NEON_CYAN)
354 if (Math.floor(t / 530) % 2 === 0 && x0 + node.length + 1 < columns) grid.set(x0 + node.length + 1, 1, '█', NEON_CYAN)
355 }
356
357 strikeIn(grid, 0, age, t)
358
359 return grid
360}
361
362const NEON_CORAL = 0xff7a59
363
364/**
365 * A view's BBS title: its name in the two-row half-block font, magenta to coral like the ANSI art boards, framed by
366 * dithered ░▒▓ ramps, with a slow shimmer down the letters (decoration). Two rows.
367 */
368export function titlePicture(name: string, columns: number, t: number, age = Infinity): Grid {
369 const grid = new Grid(columns, 2)
370 const [top, bottom] = bigText(name)
371 const edge = '░▒▓'
372 const x0 = edge.length + 1
373 const width = Math.max(top.length, bottom.length)
374 const shimmer = ((t / 40) % (width + 30)) - 15
375 // `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.
376 const logo = name.toLowerCase().startsWith('ruflo |') ? bigText('ruflo')[0].length : 0
377 const sweep = ((t / 28) % (logo + 40)) - 20
378
379 for (let y = 0; y < 2; y++) {
380 ;[...edge].forEach((ch, i) => grid.set(i, y, ch, mix(0x3a0f2e, NEON_MAGENTA, (i + 1) / edge.length)))
381 ;[...(y === 0 ? top : bottom)].forEach((ch, i) => {
382 if (ch === ' ' || x0 + i >= columns) return
383
384 if (i < logo) {
385 const lit = Math.max(0, 1 - Math.abs(i - sweep) / 4)
386
387 grid.set(x0 + i, y, ch, mix(mix(NEON_MAGENTA, NEON_CYAN, i / Math.max(1, logo - 1)), 0xffffff, lit * 0.7))
388
389 return
390 }
391
392 const glow = Math.max(0, 1 - Math.abs(i - shimmer) / 3)
393
394 grid.set(x0 + i, y, ch, mix(mix(NEON_MAGENTA, NEON_CORAL, i / Math.max(1, width - 1)), 0xffffff, glow * 0.6))
395 })
396 // The line closes on the ramp the other way round, ░▒▓, mirroring how the dark ▓▒░ edge opened it.
397 ;[...'░▒▓'].forEach((ch, i) => {
398 const x = x0 + width + 1 + i
399
400 if (x < columns) grid.set(x, y, ch, mix(0x3a0f2e, NEON_MAGENTA, (i + 1) / edge.length))
401 })
402 }
403
404 strikeIn(grid, x0, age, t)
405
406 return grid
407}
408
409/**
410 * 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,
411 * 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
412 * 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.
413 * Decoration, like the boot's sign: it carries no data. A pure function of its size and the clock, as every picture here.
414 */
415export function palettePicture(columns: number, t: number, colors: readonly number[]): Grid {
416 const grid = new Grid(columns, 1)
417 const at = ((t / 36) % (columns + 24)) - 12
418
419 for (let x = 0; x < columns; x++) {
420 const base = colors[Math.min(colors.length - 1, Math.floor((x * colors.length) / columns))] ?? 0xffffff
421 const glow = Math.max(0, 1 - Math.abs(x - at) / 7)
422
423 grid.set(x, 0, '▀', mix(base, 0xffffff, glow * 0.8))
424 }
425
426 return grid
427}
428
429export function headerPicture(title: string, columns: number, t: number): Grid {
430 const grid = new Grid(columns, 1)
431 const at = ((t / 22) % (columns + 60)) - 20
432
433 ;[...title.slice(0, columns)].forEach((ch, x) => {
434 const glow = Math.max(0, 1 - Math.abs(x - at) / 6)
435
436 grid.set(x, 0, ch, mix(x < 2 ? COLOR.accent : COLOR.dim, 0xffffff, glow * 0.8))
437 })
438
439 return grid
440}
441hooks/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})`
49hooks/state.ts 465 lines1import type { PluginOptions, Timer } from 'claude-code'
2import { guardOptionsOf, type GuardOptions } from './data/wf-alerts'
3import { convoOptionsOf, type ConvoOptions } from './data/wf-targets'
4
5import { emptyAuto, type AutoState } from './data/automate'
6import type { ProbeResult } from './data/cli'
7import { emptyFields, type DevFields } from './data/devtools'
8import type { ConsoleEvent } from './data/events'
9import type { ReadCache } from './data/files'
10import { emptyEvolve, type EvolveState } from './data/evolve'
11import { emptySkills, type SkillsState } from './data/skills'
12import type { UpdatesMode } from './updates'
13import type { Digest, ToastPrefs } from './toast-policy'
14import { newWhatsNew, type WhatsNewState } from './whatsnew'
15import { emptyMemoryLab, type MemoryLabState } from './memory-lab'
16import { emptyVector, type VectorState } from './data/vector'
17import type { Snapshot } from './data/snapshot'
18import type { RufloRoute, RufloSnapshot } from '../types'
19import type { Notice } from './notices'
20import { emptyWf, type WfState } from './wf-state'
21
22export const PLUGIN_NAME = 'ruflo-console'
23export const PANE_ID = 'ruflo-console'
24
25/** How the main nav spells its tabs: auto (names when the pane is wide), icons only, icon and a brief title, icon and the full title. */
26export type NavStyle = 'auto' | 'icons' | 'brief' | 'full'
27export const NAV_STYLES: readonly NavStyle[] = ['auto', 'icons', 'brief', 'full']
28export const NAV_KEY = 'nav-style'
29
30export type ViewId = 'menu' | 'overview' | 'swarm' | 'workflows' | 'hive' | 'claims' | 'federation' | 'plugins' | 'learning' | 'metaharness' | 'memory' | 'cost' | 'timeline' | 'approvals' | 'events' | 'room' | 'missions' | 'xruv' | 'terminal' | 'skills' | 'agent' | 'secure' | 'perf' | 'automate' | 'neural' | 'vector' | 'evolve' | 'devtools' | 'sandbox' | 'market' | 'settings' | 'whatsnew' | 'adrs'
31
32/**
33 * The views in tab order, each with its hotkey and the inline height it asks for. Digits are the first nine; the three
34 * management views take letters no other control uses. `agent` is the drill-down, reached from a selection, not a tab.
35 */
36/**
37 * `icon` is an emoji with default emoji presentation (no variation selector, so it renders as one 2-cell glyph
38 * everywhere), shown in the tab bar; the
39 * current tab adds its label, and `blurb` is the one line under the bar that says what the view is for.
40 */
41export const VIEWS: readonly { id: ViewId; key: string; label: string; short: string; icon: string; blurb: string; rows: number }[] = [
42 // Every view has a hotkey (one digit or lowercase letter is all a Button takes): digits 0-9 are the first ten, letters follow. A view's own
43 // keys (claims c l o s, the terminal l c v u, the footer p x r h) win while that view is open; the tab and the menu still reach it.
44 { id: 'menu', key: '0', label: 'Main Menu', short: 'Mnu', icon: '📟', blurb: 'the board: every area by its key, the line status, and a prompt that takes a key or a name', rows: 32 },
45 { id: 'missions', key: '1', label: 'Missions', short: 'Msn', icon: '🎯', blurb: 'Mission Control: a goal becomes a SPARC plan, a mission and tasks that Claude carries out, with guidance, controls and evidence', rows: 26 },
46 { id: 'overview', key: '2', label: 'Overview', short: 'Ovr', icon: '🏠', blurb: 'what ruflo is doing here: subsystems, mods, health alerts and live activity', rows: 26 },
47 { id: 'swarm', key: '3', label: 'Swarm', short: 'Swm', icon: '🐝', blurb: 'the swarm as ruflo wrote it: topology, agents at work, and the hive-mind votes', rows: 30 },
48 { id: 'hive', key: 'b', label: 'Hive-Mind', short: 'Hiv', icon: '👑', blurb: 'the queen, her workers and their votes: quorum, fault tolerance, proposals and broadcasts', rows: 40 },
49 { id: 'claims', key: '4', label: 'Claims', short: 'Clm', icon: '📌', blurb: 'who holds which task: claim, release, hand off or steal, each after a y/n confirm', rows: 30 },
50 { id: 'workflows', key: '', label: 'Workflows', short: 'Wfl', icon: '🔀', blurb: 'Claude Code workflow runs and the ruflo swarm side by side: phases, agents, tokens, and what each is doing', rows: 34 },
51 { id: 'federation', key: '5', label: 'Federation', short: 'Fed', icon: '🌐', blurb: 'this node, its peers, keys and channels, placed by how far each is trusted', rows: 26 },
52 { id: 'plugins', key: '6', label: 'Plugins', short: 'Plg', icon: '🧩', blurb: 'ruflo plugins: installed, enabled, in the marketplace clone, and loaded as mods', rows: 30 },
53 { id: 'learning', key: '7', label: 'Learning', short: 'Lrn', icon: '🧠', blurb: 'router picks and outcomes, and the RETRIEVE → JUDGE → DISTILL → CONSOLIDATE pipeline', rows: 30 },
54 { id: 'metaharness', key: '8', label: 'MetaHarness', short: 'MH', icon: '🔬', blurb: 'harness readiness, the flywheel, the audit trend, and a lab that runs every MetaHarness verb', rows: 40 },
55 { id: 'memory', key: '9', label: 'Memory', short: 'Mem', icon: '💾', blurb: 'the Memory Lab: browse, search, store and delete entries; AgentDB, embeddings and upkeep, each a button', rows: 60 },
56 { id: 'cost', key: 'c', label: 'Cost', short: 'Cst', icon: '💰', blurb: 'set a budget, see spend across Claude Code and Codex, and how to cut it', rows: 40 },
57 { id: 'timeline', key: 'g', label: 'Timeline', short: 'Gnt', icon: '🕒', blurb: 'each agent busy or idle over the last minutes, beside Claude Code tool calls', rows: 24 },
58 { id: 'approvals', key: 'q', label: 'Approvals', short: 'Apv', icon: '✅', blurb: 'decisions waiting for a person: votes, stealable claims, refused mods, budget', rows: 24 },
59 { id: 'events', key: 'e', label: 'Events', short: 'Evt', icon: '📡', blurb: 'every swarm, claim, memory and mod event as it happens (f filters them)', rows: 26 },
60 { id: 'room', key: '', label: 'Room', short: 'Room', icon: '💬', blurb: 'what the people and the agents here are saying and doing, live, and the one thing waiting for a yes', rows: 30 },
61 { id: 'xruv', key: 'w', label: 'x.ruv.io', short: 'XRV', icon: '🛸', blurb: 'the open agent federation: what it offers, how to join, its channels and who is on', rows: 50 },
62 { id: 'terminal', key: 'i', label: 'Terminal', short: 'Trm', icon: '💻', blurb: 'an AI terminal: claude -p, codex or both, each a session that remembers the conversation, streamed live', rows: 120 },
63 { id: 'skills', key: 'z', label: 'Skills', short: 'Skl', icon: '🧰', blurb: 'agent skills (npx skills, skills.sh): installed, search, use without installing, preview, add to chosen agents, update, create', rows: 60 },
64 { id: 'secure', key: 'u', label: 'Security & Doctor', short: 'Sec', icon: '🔒',blurb: 'security scans, a paste field where AIDefence checks text for injection and PII, policy, sentries that scan on a schedule or on change, and every doctor check', rows: 40 },
65 { id: 'perf', key: 'f', label: 'Performance', short: 'Prf', icon: '📈', blurb: 'metrics, profile, benchmarks, bottlenecks and a latency sparkline from each run', rows: 30 },
66 { id: 'automate', key: 'a', label: 'Automation', short: 'Aut', icon: '🤖', blurb: 'workflows, the twelve background workers and their daemon, loops, autopilot, sessions, config and a task kanban', rows: 44 },
67 { id: 'neural', key: 'l', label: 'Learning Lab', short: 'Lab', icon: '🧪', blurb: 'train neural patterns and watch the loss, ask the router which agent fits a task, and why', rows: 36 },
68 { id: 'vector', key: 'v', label: 'Vector Lab', short: 'Vec', icon: '🧲', blurb: 'ruvector: the shared brain, RVF stores, rvlite queries, decompile, workers, edge, hooks intel and your pi identity', rows: 44 },
69 { id: 'evolve', key: 't', label: 'Self-Evolution', short: 'Evo', icon: '🧬', blurb: 'the governed loop: flywheel receipts, ledger, lineage, the policy gate, the witness; Autogenous and rGi', rows: 44 },
70 { id: 'devtools', key: 'd', label: 'Dev Tools', short: 'Dev', icon: '🔧', blurb: 'the integration surface: GitHub, diff analysis, agenticow, WASM, browser, terminal, providers, maintenance', rows: 40 },
71 { id: 'sandbox', key: '', label: 'Sandbox', short: 'Sbx', icon: '🧫', blurb: 'isolated places to try things: tmux sessions, RVF copy-on-write branches, RVM', rows: 40 },
72 { id: 'market', key: 'm', label: 'Plugin Catalog', short: 'Cat', icon: '📦', blurb: 'every ruflo plugin, mod and skill: what it ships, install, enable, disable, update, view and use', rows: 50 },
73 { id: 'adrs', key: '', label: 'ADRs', short: 'ADR', icon: '📐', blurb: 'your project’s Architecture Decision Records: find, propose, accept and supersede them, attach them to a mission so Claude and the swarm follow what was decided', rows: 44 },
74 { id: 'whatsnew', key: '', label: 'What’s new', short: 'New', icon: '🆕', blurb: 'what changed in your ruflo plugins, newest first: from each plugin’s own CHANGELOG, breaking changes pinned until you dismiss them', rows: 40 },
75 { id: 'settings', key: 's', label: 'Settings', short: 'Set', icon: '⚙️', blurb: 'simple to advanced settings: plugin options, ruflo config, updates, and the AI terminal’s model and budget, each edited in place', rows: 50 },
76]
77
78export const AGENT_VIEW = { id: 'agent' as const, rows: 28 }
79
80export const rowsOf = (view: ViewId): number => (view === 'agent' ? AGENT_VIEW.rows : (VIEWS.find(entry => entry.id === view)?.rows ?? 24))
81
82/** A view by id, digit, label, or a prefix of three letters or more. */
83export const viewOf = (word: string): ViewId | null => {
84 const lower = word.trim().toLowerCase()
85
86 return VIEWS.find(view => view.id === lower || (view.key !== '' && view.key === lower) || view.label.toLowerCase() === lower || (lower.length >= 3 && view.id.startsWith(lower)))?.id ?? null
87}
88
89/**
90 * `$.store` is the plugin's, not the folder's: the key carries the working directory, so a view chosen in one project
91 * never follows the person into another (the ruflo-swarm leak).
92 */
93export const storeKeyOf = (cwd: string): string => `ruflo-console/ui:${cwd}`
94
95/** How actions reach the ruflo CLI: each a fixed argv prefix. Only `npx` may download. */
96export const CLI_PREFIXES = {
97 'npx-offline': ['npx', '--offline', '-y', '@claude-flow/cli@latest'],
98 npx: ['npx', '-y', '@claude-flow/cli@latest'],
99 ruflo: ['ruflo'],
100 'claude-flow': ['claude-flow'],
101} as const satisfies Record<string, readonly string[]>
102
103export type CliChoice = keyof typeof CLI_PREFIXES
104
105export type Options = GuardOptions & ConvoOptions & {
106 cli: CliChoice
107 /** How often the disk is re-read while the pane or band shows (seconds, 2-60). */
108 refreshSeconds: number
109 /** The animation's frame cap while the pane is shown and focused (0 turns motion off; at most 12). */
110 fps: number
111 /** `auto`: the band shows in a ruflo project; `on`: always; `off`: never. */
112 bar: 'auto' | 'on' | 'off'
113 /** `auto` opens the cockpit at session start where it can dock (never taking the keys); `command` only on /ruflo; `off` never. */
114 panel: 'auto' | 'command' | 'off'
115 /** Lets the federation view ask the public relay for the roster. Off by default: no network without consent. */
116 federationNetwork: boolean
117 /** `bbs`: the neon ASCII-art look (default); `plain`: the terminal theme's own colours and plain rules. */
118 look: 'bbs' | 'plain'
119 /** With the bbs look, a short dial-up boot screen when the cockpit opens. */
120 boot: boolean
121 /** ADR-474: keep the Events and Timeline history in `.claude-flow/console/` (events.jsonl, lanes.jsonl). On by default; masked text only. */
122 eventsPersist: boolean
123}
124
125const num = (value: unknown, fallback: number, lo: number, hi: number): number => {
126 const n = typeof value === 'number' ? value : Number(value)
127
128 return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : fallback
129}
130
131/** The options as the settings hold them, each one checked: a value the plugin does not know is its default. */
132export function optionsOf(raw: PluginOptions | undefined): Options {
133 const value = (raw ?? {}) as Record<string, unknown>
134
135 return {
136 cli: typeof value.cli === 'string' && Object.hasOwn(CLI_PREFIXES, value.cli) ? (value.cli as CliChoice) : 'npx-offline',
137 refreshSeconds: num(value.refreshSeconds, 3, 2, 60),
138 fps: num(value.fps, 8, 0, 12),
139 bar: value.bar === 'on' || value.bar === 'off' ? value.bar : 'auto',
140 panel: value.panel === 'command' || value.panel === 'off' ? value.panel : 'auto',
141 federationNetwork: value.federationNetwork === true,
142 look: value.look === 'plain' ? 'plain' : 'bbs',
143 boot: value.boot !== false,
144 eventsPersist: value.eventsPersist !== false && value.eventsPersist !== 'false',
145 ...guardOptionsOf(raw),
146 ...convoOptionsOf(raw),
147 }
148}
149
150/** A mutating action waiting for the person's second press; `shows` is the command line when it is not a ruflo one. */
151export type Pending = { /** Which ask this is (runner.ts hands out ids in order): a Yes names the card it answers, so another card that took its place is never the one run. */ id?: number; label: string; args: readonly string[]; expect: string; askedAtMs: number; shows?: string; note?: string; /** The kind of action, when it may be remembered (see remember.ts). */ rememberKey?: string; /** Where in its view the ask came from. */ scope?: string; /** The page that raised it: the ask shows in full there, and as a pointer on every other page. */ view?: string; /** Who raised it: Claude's tool call or the person's own action (ADR-450 T14). */ source?: 'claude' | 'you'; /** The class the entry declares for itself; the gate takes the stricter of this and the class read from its words. */ declared?: 'write' | 'network' | 'install' | 'spend' | 'delete'; /** The class of action, set only on Claude's asks. */ kind?: 'write' | 'network' | 'install' | 'spend' | 'delete' }
152
153/** The MetaHarness lab's last run: what it was, how it exited, its cost note, and its output as lines to scroll. */
154export type LabResult = { id: string; label: string; ok: boolean; exitCode: number | null; note?: string; lines: string[]; atMs: number }
155
156/** The harnesses the terminal view can ask; `swarm` asks codex and claude at once. */
157export type HarnessId = 'codex' | 'claude' | 'ruflo' | 'swarm'
158/** What actually runs: a swarm is a codex run and a claude run side by side. */
159export type AgentId = Exclude<HarnessId, 'swarm'>
160
161/**
162 * One line of the terminal's scrollback: what was asked (`in`), an agent starting its answer (`head`), what came back,
163 * a tool it used (`tool`), how its turn ended (`end`), or
164 * the console's own note (`sys`); `from` names the agent when more than one is talking.
165 */
166export type TermLine = { kind: 'in' | 'head' | 'out' | 'err' | 'sys' | 'tool' | 'end'; text: string; from?: AgentId }
167
168/** A conversation kept per project: codex's thread id, claude's session id, so a follow-up resumes it. */
169export type TermSessions = { codex?: string; claude?: string }
170
171export const termStoreKeyOf = (cwd: string): string => `ruflo-console/term:${cwd}`
172
173/** What an action did: what ran, how it exited, whether the disk shows the change, and anything it printed to show. */
174export type Outcome = { label: string; ok: boolean; verified: 'yes' | 'no' | 'n/a'; detail: string; atMs: number; lines?: string[] }
175
176/** One module seen registering since the console loaded, as the engine's scan named it. */
177export type ModSeen = { name: string; provenance: string; isLoaded: boolean; reason?: string; atMs: number }
178
179/** A tool call refused by a permission verdict this session, as `tool.check` answered it. */
180export type Denied = { tool: string; reason: string; atMs: number }
181
182/** One sample of a measured series, with when it was taken. */
183export type Sample = { atMs: number; value: number }
184
185/** One thing Claude did with the console's tools (ADR-444): what, and how it came out. */
186export type ControlEntry = { atMs: number; tool: string; summary: string; outcome: 'ok' | 'waiting' | 'denied' | 'error'; detail: string }
187
188export type State = {
189 options: Options
190 cwd: string
191 home: string | null
192 /** Session evidence from a confirmed JOIN, kept without background key access. */
193 nostrKeyVerifiedAtMs: number | null
194 /** Claude Code's config directory: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. Its plugin records are read from here. */
195 configDir: string | null
196 /** False in a session with no pane to show (claude -p, an SDK host): views are then answered as text. */
197 isInteractive: boolean
198 /** When this module loaded: "since the console loaded" series and stall times count from here. */
199 loadedAtMs: number
200 view: ViewId
201 /** The view to go back to from the drill-down. */
202 back: ViewId
203 isHelp: boolean
204 /** ruHelp: the question typed, and the guide open (null: the index). */
205 help: { query: string; topic: string | null }
206 snapshot: Snapshot | null
207 cache: ReadCache
208 probes: Map<string, ProbeResult>
209 ruflo: { snapshot: RufloSnapshot | null; route: RufloRoute | null; error: string | null }
210 usage: { costUsd?: number; contextPercent?: number } | null
211 /** The custom budget field, kept across redraws. */
212 costBudgetDraft: string
213 rufloTools: { tools: number; servers: string[] } | null
214 mods: ModSeen[]
215 denied: Denied[]
216 /** Tool calls the console saw, per 5 s bucket, newest last; and per agent (Claude Code's ids) for the timeline. */
217 activity: number[]
218 toolsByAgent: Map<string, { atMs: number; tool: string }[]>
219 /** ruflo state files changed per refresh, newest last. */
220 writes: number[]
221 /** Measured series since the console loaded: patterns learned, session spend. */
222 history: { patterns: Sample[]; spend: Sample[]; outcomes: number }
223 events: ConsoleEvent[]
224 /** Each ruflo agent's status as the console saw it change, oldest first: the timeline's spans. */
225 statusLog: Map<string, { atMs: number; status: string }[]>
226 eventFilter: 'all' | ConsoleEvent['kind']
227 /** When the newest learning point arrived: the curve draws it in from there. */
228 curveGrewAtMs: number
229 /** Kinds of action the person said never to ask about again, with a sample label (saved; Settings forgets them). */
230 allowed: Map<string, string>
231 /** The main nav's style, saved across sessions. */
232 nav: NavStyle
233 /** Whether to check for a newer published ruflo-console: ask first (the default), update without asking, or never check. Kept in the plugin's store. */
234 updates: UpdatesMode
235 /** The Toasts setting (ADR-477): which levels draw and which sources are muted. Kept in the plugin's store and mirrored to a file the other plugins read. */
236 toastPrefs: ToastPrefs
237 /** The console's own toasts, drawn or not, until the Events pass takes them in (bounded). */
238 toastLog: Digest[]
239 /** What's new (ADR-478): the record of what was looked at, the changelogs read when the page opens. */
240 whatsnew: WhatsNewState
241 /** What the last update check found, in a line, for Settings; empty until one has run. */
242 updateNote: string
243 /** A published version the person has not taken ("Not now"), shown on the band as a link to Settings; empty when there is none. */
244 updateAvailable: string
245 /** The nav group whose pages are showing, picked on this page (it follows the open page again once the page changes). */
246 navPick: { group: string; view: ViewId } | null
247 /** The nav search words (empty: no search). */
248 navQuery: string
249 /** The slash command names the session offered when last asked (for the mission skills). */
250 commandNames: string[]
251 /** True while the primary Claude session is running a turn (the band reports it each draw). */
252 turnActive: boolean
253 /** Notices the band announced (notices.ts): the newest 30, a running id, and a time before which the notice row stays quiet. */
254 notices: Notice[]
255 noticeSeq: number
256 noticesQuietUntilMs: number
257 /** `/ruflo band`: the band's mode for this session over the plugin option (null = the option), and whether it shows one row. */
258 bandMode: 'auto' | 'on' | 'off' | null
259 bandCompact: boolean
260 /** When the person-facing turn began (the band shows how long Claude has been working), null between turns. */
261 turnStartedMs: number | null
262 /** Collapsible sections the person flipped from their default (`<view>/<id>`): open ones closed, closed ones open. */
263 sections: Set<string>
264 /** What one-shot entry fields hold while typed (cleared on Enter), by field key. */
265 fieldText: Map<string, string>
266 /** The dock width asked for (RUFLO_CONSOLE_COLUMNS, 40 to 400); 0 leaves the engine's share. A request: a dragged width wins. */
267 dockColumns: number
268 pane: { isOpen: boolean; isShown: boolean; isFocused: boolean; columns: number; rows: number; placement: 'dock' | 'inline'; isClosedByPerson: boolean; autoTried: boolean; autoReason: string; /** When the pane last opened: the BBS boot screen plays from here. */ bootAtMs: number; /** When the boot ended: the menu's entry plays from here (0: not yet). */ menuAtMs: number; /** When the page was last switched: its title strikes in from here (0: not since the pane opened). */ viewAtMs: number }
269 /** The size of each Raster as last mounted, by key: a blit of any other size is refused, so none is sent. */
270 mounted: Map<string, { columns: number; rows: number }>
271 select: { claim: number; agent: number; task: number; item: number }
272 /** The drill-down's agent and what `agent logs` printed for it. */
273 drill: { agentId: string | null; logs: string[] | null; logsAtMs: number }
274 palette: { isOpen: boolean; query: string; index: number; context: 'all' | 'selection' }
275 pending: Pending | null
276 /** The key of the element last pressed, and the one the last ask or answer came from: the page draws them right there (views/attention.ts). */
277 lastPressed: string | null
278 origin: string | null
279 outcome: Outcome | null
280 isActing: boolean
281 /** The MetaHarness lab: its last result, and the run in flight (j/k scroll the result through `select.item`). */
282 lab: { result: LabResult | null; running: { id: string; label: string; startedAtMs: number } | null }
283 /**
284 * The x.ruv.io board: its own result panel (j/k scroll it too), this node's Nostr pubkey once a result named it
285 * (the key file is never read), and whether RUFLO_X_ADMIN_TOKEN is set (only that boolean is kept; null: not asked).
286 */
287 xruv: { result: LabResult | null; running: { id: string; label: string; startedAtMs: number } | null; pubkey: string | null; hasAdminToken: boolean | null }
288 isRefreshing: boolean
289 /** When the band above the prompt last drew: the disk is re-read on the fast cadence only while it is seen. */
290 barDrawnAtMs: number
291 /** The terminal view: the harness picked, the field's text, the scrollback, and the runs in flight. */
292 terminal: {
293 harness: HarnessId
294 draft: string
295 lines: TermLine[]
296 /** One run per agent at most; codex and claude may run at the same time. */
297 runs: Map<AgentId, { label: string; startedAtMs: number; stop: () => void }>
298 /** The conversations to resume, and which of them the person has said yes to in this Claude Code session. */
299 sessions: TermSessions
300 isLive: { codex: boolean; claude: boolean }
301 /** Turns and spend this session, as the agents reported them. */
302 turns: { codex: number; claude: number }
303 costUsd: number
304 /** How many terminal results actually reported dollars, including measured zero. */
305 costReports: number
306 /** Screen rows scrolled up from the newest (0 follows the tail), and how many lines arrived while scrolled up. */
307 scroll: number
308 unseen: number
309 /** The text the last Enter asked about: Enter on the same text again confirms it. */
310 asked: { key: string; label: string } | null
311 }
312 /** The skills view: installed skills, the last search, and the change running now. */
313 skills: SkillsState
314 /** The Memory Lab's fields and picks (its last run is `lab.result`, under a mem- id). */
315 memoryLab: MemoryLabState
316 /** The Automation and Learning Lab views: the lists a click asked for, and this session's training runs. */
317 auto: AutoState
318 /** The Vector Lab's fields; its runs land in `lab` under vec- ids. */
319 vector: VectorState
320 /** The Self-Evolution view: the flywheel files as last read, and what its checks answered. */
321 evolve: EvolveState
322 /** The Dev Tools view: what is typed in its fields (its runs share the lab result panel, ids dt-*). */
323 devtools: { fields: DevFields; /** Whether tmux is on this machine, from a probe when the Sandbox page opens. */ tmux: 'unknown' | 'present' | 'missing' }
324 timers: Map<string, Timer>
325 stats: { renders: number[]; refreshes: number[]; frames: number[] }
326 /** The Workflows page: the last read of Claude Code's run folders, the cursor, the inspector tab (wf-state.ts). */
327 wf: WfState
328 /** Claude's control of the console (ADR-444): paused by the person, the call counts, and the log the dashboard shows. */
329 control: { paused: boolean; calls: number; turnCalls: number; /** Model-driven actions this session, by class (ADR-450 T8 budget). */ used: Record<string, number>; log: ControlEntry[]; /** Until when Claude counts as driving (a tool call extends it): the console does not spend a second Claude turn on guidance meanwhile. */ drivingUntilMs: number; /** Claude's console_run / console_set calls running now: an ask that lands while one runs is settled (gated) by that call; one that lands with none running (console_open and console_state settle nothing) is checked by the runner. */ activeCalls: number; /** True while one of Claude's tool calls is running: a person's "always allow" answer must not let Claude's call skip the level and confirm checks (ADR-444). */ viaModel: boolean }
330}
331
332export function newState(raw: PluginOptions | undefined): State {
333 return {
334 options: optionsOf(raw),
335 cwd: '',
336 home: null,
337 nostrKeyVerifiedAtMs: null,
338 configDir: null,
339 isInteractive: true,
340 loadedAtMs: Date.now(),
341 view: 'overview',
342 back: 'overview',
343 isHelp: false,
344 help: { query: '', topic: null },
345 snapshot: null,
346 cache: new Map(),
347 probes: new Map(),
348 ruflo: { snapshot: null, route: null, error: null },
349 usage: null,
350 costBudgetDraft: '',
351 rufloTools: null,
352 mods: [],
353 denied: [],
354 activity: [],
355 toolsByAgent: new Map(),
356 writes: [],
357 history: { patterns: [], spend: [], outcomes: 0 },
358 events: [],
359 statusLog: new Map(),
360 eventFilter: 'all',
361 curveGrewAtMs: 0,
362 dockColumns: 0,
363 nav: 'auto',
364 updates: 'ask',
365 toastPrefs: { mode: 'all', muted: [] },
366 toastLog: [],
367 whatsnew: newWhatsNew(),
368 updateNote: '',
369 updateAvailable: '',
370 navPick: null,
371 navQuery: '',
372 turnActive: false,
373 notices: [],
374 noticeSeq: 0,
375 noticesQuietUntilMs: 0,
376 bandMode: null,
377 bandCompact: false,
378 turnStartedMs: null,
379 commandNames: [],
380 allowed: new Map(),
381 sections: new Set(),
382 fieldText: new Map(),
383 pane: { isOpen: false, isShown: false, isFocused: false, columns: 0, rows: 0, placement: 'inline', isClosedByPerson: false, autoTried: false, autoReason: '', bootAtMs: 0, menuAtMs: 0, viewAtMs: 0 },
384 mounted: new Map(),
385 select: { claim: 0, agent: 0, task: 0, item: 0 },
386 drill: { agentId: null, logs: null, logsAtMs: 0 },
387 palette: { isOpen: false, query: '', index: 0, context: 'all' },
388 pending: null,
389 lastPressed: null,
390 origin: null,
391 outcome: null,
392 isActing: false,
393 lab: { result: null, running: null },
394 xruv: { result: null, running: null, pubkey: null, hasAdminToken: null },
395 isRefreshing: false,
396 barDrawnAtMs: 0,
397 terminal: { harness: 'claude', draft: '', lines: [], runs: new Map(), sessions: {}, isLive: { codex: false, claude: false }, turns: { codex: 0, claude: 0 }, costUsd: 0, costReports: 0, scroll: 0, unseen: 0, asked: null },
398 skills: emptySkills(),
399 memoryLab: emptyMemoryLab(),
400 auto: emptyAuto(),
401 vector: emptyVector(),
402 evolve: emptyEvolve(),
403 devtools: { fields: emptyFields(), tmux: 'unknown' },
404 timers: new Map(),
405 stats: { renders: [], refreshes: [], frames: [] },
406 wf: emptyWf(),
407 control: { paused: false, calls: 0, turnCalls: 0, used: {}, log: [], drivingUntilMs: 0, activeCalls: 0, viaModel: false },
408 }
409}
410
411/** Keeps the newest `max` samples of a series. */
412export function push<T>(series: T[], value: T, max = 120): void {
413 series.push(value)
414
415 if (series.length > max) {
416 series.splice(0, series.length - max)
417 }
418}
419
420/** What is written to `$.store`: the person's choices only. */
421export type Persisted = { view: ViewId; isClosedByPerson: boolean }
422
423export function restore(state: State, value: unknown): void {
424 const held = value !== null && typeof value === 'object' ? (value as Partial<Persisted>) : {}
425
426 if (typeof held.view === 'string' && VIEWS.some(view => view.id === held.view)) {
427 state.view = held.view
428 }
429
430 state.pane.isClosedByPerson = held.isClosedByPerson === true
431}
432
433const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/
434
435/** The terminal's saved conversations: only id-shaped strings come back, since each one becomes an argv element. */
436export function restoreSessions(state: State, value: unknown): void {
437 const held = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
438
439 for (const agent of ['codex', 'claude'] as const) {
440 const id = held[agent]
441
442 if (typeof id === 'string' && SESSION_ID.test(id)) state.terminal.sessions[agent] = id
443 }
444}
445
446export const isSessionId = (id: string): boolean => SESSION_ID.test(id)
447
448/** The BBS boot screen's span: at least BOOT_MIN_MS, longer while the first read is still out, never past BOOT_MAX_MS. */
449export const BOOT_MIN_MS = 5_400
450export const BOOT_MAX_MS = 8_000
451
452export function isBooting(state: State, nowMs: number): boolean {
453 if (state.options.look !== 'bbs' || !state.options.boot || state.pane.bootAtMs === 0) return false
454
455 const age = nowMs - state.pane.bootAtMs
456
457 return age >= 0 && age < BOOT_MAX_MS && (age < BOOT_MIN_MS || state.snapshot === null)
458}
459
460/**
461 * Compact: an inline pane the layout could not make as tall as the view asks. It drops the banner and moves the
462 * controls up so they stay on screen. A docked pane scrolls, so it always gets the full frame, banner and title.
463 */
464export const isCompactPane = (state: State): boolean => state.pane.placement === 'inline' && state.pane.rows > 0 && state.pane.rows < rowsOf(state.view)
465hooks/views/bar.ts 305 lines1/**
2 * The band above the prompt: one row saying what is happening here now, with a mark that pulses while Claude works.
3 * Parts come most-urgent first, so a narrow band truncates the least useful ones: what needs a person, who is
4 * working on what (and for how long), the AI terminal's runs, the newest event while it is fresh; only then the
5 * standing context (claims, this session's spend). With nothing happening it says so, and when it last did.
6 * Each part is a fact on disk or n/a; a part with nothing to say is left out rather than shown as zero.
7 */
8import { activeMission, derive, progressOf } from '../mission-control'
9import type { RenderElement } from 'claude-code'
10
11import { alertsOf, waitingApprovalsOf } from '../data/alerts'
12import { agentLabels } from '../data/parse'
13import { secMemo } from '../secure'
14import type { State, ViewId } from '../state'
15import { sparkline } from '../memory-lines'
16import { visibleNotice, type Notice } from '../notices'
17import { ago, clip, type Kit } from './common'
18
19export const BAR_KEY = 'mark'
20
21/**
22 * One part of the band: its words, how loud, and the view a click on it opens. `row` is where it sits: the status row (what needs a
23 * person, the mission, who is working, a fresh event) or the standing row (the last tool call, claims, spend, findings, an update), so
24 * a long mission title in the first cannot push the second out.
25 */
26export type BarPart = { text: string; tone: 'attention' | 'live' | 'plain'; go?: ViewId; row?: 'status' | 'standing'; /** A shorter form, used when the row would otherwise cut a part. */ compact?: string }
27
28/** How long an event counts as "now" on the band. */
29const FRESH_MS = 60_000
30
31/** The window of the activity sparkline: one bar per minute. */
32export const ACTIVITY_MINUTES = 10
33
34/**
35 * Tool calls per minute over the last ten minutes, oldest left, one bar a minute: how busy an unattended session has been, at a glance.
36 * Null when fewer than three calls fell in the window (a rhythm needs more than a blip). Counts only what the console observed.
37 */
38export function activityBars(events: readonly { atMs: number; kind: string }[], nowMs: number): string | null {
39 const start = nowMs - ACTIVITY_MINUTES * 60_000
40 const counts = Array.from({ length: ACTIVITY_MINUTES }, () => 0)
41 let total = 0
42
43 for (const event of events) {
44 if (event.kind !== 'tools' || event.atMs < start || event.atMs > nowMs) continue
45 counts[Math.min(ACTIVITY_MINUTES - 1, Math.floor((event.atMs - start) / 60_000))] += 1
46 total += 1
47 }
48
49 return total < 3 ? null : sparkline(counts)
50}
51
52const since = (atMs: number | undefined, nowMs: number): string => (atMs === undefined ? '' : ` ${ago(atMs, nowMs).replace(' ago', '')}`)
53
54/** Who is working, each on what: the agent's in-progress task, or just "working". At most two, then a count. */
55function workingParts(state: State, nowMs: number): BarPart[] {
56 const snap = state.snapshot
57
58 if (snap === null) return []
59
60 const busy = snap.agents.filter(agent => /busy|active|working/i.test(agent.status))
61 const labels = agentLabels(snap.agents)
62 const parts = busy.slice(0, 2).map(agent => {
63 const task = snap.tasks.find(entry => entry.assignedTo.includes(agent.id) && /progress|running|active/i.test(entry.status))
64 const what = task !== undefined ? ` on ${clip(task.description || task.type, 40)}` : ' working'
65 const span = state.statusLog.get(agent.id)?.at(-1)?.atMs
66
67 return { text: `▶ ${labels.get(agent.id) ?? agent.type}${what}${since(span, nowMs)}`, tone: 'live' as const, go: 'swarm' as const }
68 })
69
70 if (busy.length > 2) parts.push({ text: `+${busy.length - 2} more working`, tone: 'live', go: 'swarm' })
71
72 return parts
73}
74
75/** Dollars a person reads at a glance: cents under $100, whole dollars with separators above. */
76export function money(usd: number): string {
77 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd).toLocaleString('en-US')}`
78}
79
80/** The active mission as a band part: progress and the running task, or paused; none when there is no mission, or it is done or cancelled. */
81export function missionPart(state: State): BarPart | null {
82 const mission = activeMission(state)
83
84 if (mission === null || mission.cancelled) return null
85
86 const tasks = state.snapshot?.tasks ?? []
87 const { done, total } = progressOf(mission, tasks)
88 const status = derive(mission, tasks)
89 const running = mission.tasks.find(task => status.get(task.id) === 'running')
90
91 if (total === 0 || done >= total) return null
92
93 return { text: `🎯 ${done}/${total}${mission.paused ? ' paused' : running !== undefined ? ` · ${running.id} ${clip(running.title, 28)}` : ''} (1)`, tone: running !== undefined ? 'live' : 'plain', go: 'missions' }
94}
95
96/**
97 * A module's own band parts (the autopilot's segment, views/ap-band.ts): registered once at import, drawn on the first row after the
98 * mission. A source returns nothing when it has nothing to say, and one that throws is skipped, so it can never blank the band.
99 */
100const sources: ((state: State, nowMs: number) => BarPart[])[] = []
101
102export const registerBarSource = (source: (state: State, nowMs: number) => BarPart[]): void => void (sources.includes(source) || sources.push(source))
103
104export function barParts(state: State, nowMs: number = Date.now()): BarPart[] {
105 const snap = state.snapshot
106 const parts: BarPart[] = []
107
108 // What needs a person: approvals waiting and warn/bad alerts. Info alerts (a claim held for days) stay in the pane.
109 const approvals = waitingApprovalsOf(state).length
110 const alerts = alertsOf(state, nowMs, state.loadedAtMs).filter(alert => alert.level !== 'info').length
111
112 if (approvals > 0) parts.push({ text: `${approvals} to approve (q)`, tone: 'attention', go: 'approvals' })
113 if (alerts > 0) parts.push({ text: `⚠ ${alerts} alert${alerts === 1 ? '' : 's'}`, tone: 'attention', go: 'overview' })
114
115 // The active mission: how far along, and the task Claude is on (or that it is paused); a click opens Mission Control.
116 const missing = missionPart(state)
117
118 if (missing !== null) parts.push(missing)
119
120 for (const source of sources) {
121 try {
122 parts.push(...source(state, nowMs))
123 } catch {
124 // A source that fails draws nothing.
125 }
126 }
127
128 // What is happening now: how long Claude has been on this turn, agents at work, the AI terminal's runs, and the newest event while it is fresh.
129 if (state.turnActive && state.turnStartedMs !== null) parts.push({ text: `▶ Claude working${since(state.turnStartedMs, nowMs)}`, tone: 'live', go: 'events' })
130
131 parts.push(...workingParts(state, nowMs))
132
133 for (const [agent, run] of state.terminal.runs) parts.push({ text: `💻 ${agent} answering${since(run.startedAtMs, nowMs)}`, tone: 'live', go: 'terminal' })
134
135 const latest = state.events.at(-1)
136 const isFresh = latest !== undefined && nowMs - latest.atMs < FRESH_MS
137
138 if (isFresh) parts.push({ text: `${clip(latest.text, 44)} ·${since(latest.atMs, nowMs)} ago`, tone: 'plain', go: 'events' })
139
140 // Nothing moving: say so, with how many agents stand ready. (When something did happen, the last event is a standing part below.)
141 if (!parts.some(part => part.tone === 'live') && !isFresh && snap?.swarm != null) {
142 const ready = snap.agents.length
143
144 parts.push({ text: ready > 0 ? `idle · ${ready} agent${ready === 1 ? '' : 's'} ready` : 'swarm, no agents', tone: 'plain', go: 'swarm' })
145 }
146
147 // Standing context, on its own row: the last tool call or event with how long ago (it used to vanish after a minute, taking what Claude
148 // last did with it), claims held, this session's spend, what the last scan found, and a published update not yet taken.
149 if (latest !== undefined && !isFresh) parts.push({ text: `${clip(latest.text, 44)} ·${since(latest.atMs, nowMs)} ago`, tone: 'plain', go: 'events', row: 'standing', compact: `${clip(latest.text, 18)} ·${since(latest.atMs, nowMs)} ago` })
150
151 const bars = activityBars(state.events, nowMs)
152
153 if (bars !== null) parts.push({ text: `${bars} tool calls, ${ACTIVITY_MINUTES}m`, tone: 'plain', go: 'events', row: 'standing', compact: bars })
154
155 const claims = snap?.claims ?? []
156
157 if (claims.length > 0) {
158 const stealable = claims.filter(claim => claim.isStealable).length
159
160 parts.push({ text: `${claims.length} claim${claims.length === 1 ? '' : 's'}${stealable > 0 ? ` (${stealable} stealable)` : ''}`, tone: 'plain', go: 'claims', row: 'standing', compact: `${claims.length} claim${claims.length === 1 ? '' : 's'}` })
161 }
162
163 if (state.usage?.costUsd !== undefined && state.usage.costUsd >= 0.01) parts.push({ text: `${money(state.usage.costUsd)} this session`, tone: 'plain', go: 'cost', row: 'standing', compact: money(state.usage.costUsd) })
164
165 // The context window filling: quiet until it matters, amber when it is close, with the hint that acts on it.
166 const context = state.usage?.contextPercent
167
168 if (context !== undefined && context >= 60) {
169 parts.push({ text: `ctx ${Math.round(context)}%${context >= 85 ? ' · /compact soon' : ''}`, tone: context >= 80 ? 'attention' : 'plain', go: 'cost', row: 'standing', compact: `ctx ${Math.round(context)}%` })
170 }
171
172 const findings = secMemo(state).findings
173 const serious = findings === null ? 0 : findings.counts.critical + findings.counts.high
174
175 if (findings !== null && serious > 0) parts.push({ text: `🔒 ${serious} high or critical`, tone: findings.counts.critical > 0 ? 'attention' : 'plain', go: 'secure', row: 'standing', compact: `🔒 ${serious}` })
176 if (state.updateAvailable !== '') parts.push({ text: `⬆ ${state.updateAvailable} available`, tone: 'attention', go: 'settings', row: 'standing' })
177
178 return parts
179}
180
181/** The band's words, for `/ruflo status` and anything that wants it as one line. */
182export function barText(state: State, nowMs: number = Date.now()): string {
183 return ['ruflo', ...barParts(state, nowMs).map(part => part.text)].join(' · ')
184}
185
186/**
187 * The band's panel: a dark ground with a border, in colours from the 256-colour cube and grey ramp. They are explicit, not theme
188 * names, so the text stays readable on the ground and a name the host does not know cannot make it refuse the whole band. (A
189 * Button cannot be coloured: its label takes the theme's, which reads on a dark theme; on a light one it is dim on the dark ground.)
190 */
191export const PANEL = { ground: '#1c1c1c', border: '#5f5faf', text: '#d0d0d0', dim: '#8a8a8a', attention: '#ffaf00', live: '#5fd75f', bad: '#ff5f5f' } as const
192
193/** Links at the end of the standing row, each opening the console on that view: where to go next, whatever is happening. */
194export const BAND_LINKS: readonly { label: string; go: ViewId }[] = [
195 { label: 'Missions', go: 'missions' },
196 { label: 'Swarm', go: 'swarm' },
197 { label: 'Security', go: 'secure' },
198 { label: 'Memory', go: 'memory' },
199 { label: 'Cost', go: 'cost' },
200 { label: 'Menu', go: 'menu' },
201]
202
203/**
204 * The band's overall state, which colours its border so it reads from across the room: a notice that is bad (or Anatole blocking) is red,
205 * something that needs a person is amber, Claude at work is green, and a quiet band keeps its usual purple.
206 */
207export type BandTone = 'bad' | 'attention' | 'live' | 'idle'
208
209export function bandTone(parts: readonly BarPart[], notice: Notice | null): BandTone {
210 if (notice?.level === 'bad') return 'bad'
211 if (parts.some(part => part.tone === 'attention') || notice?.level === 'warn') return 'attention'
212 if (parts.some(part => part.tone === 'live')) return 'live'
213
214 return 'idle'
215}
216
217const BORDER: Record<BandTone, string> = { bad: PANEL.bad, attention: PANEL.attention, live: PANEL.live, idle: PANEL.border }
218const NOTICE_MARK = { ok: '✓', info: 'ℹ', warn: '⚠', bad: '✖' } as const
219const NOTICE_COLOR = { ok: PANEL.live, info: PANEL.text, warn: PANEL.attention, bad: PANEL.bad } as const
220
221const toneColor = (tone: BarPart['tone']): string => (tone === 'attention' ? PANEL.attention : tone === 'live' ? PANEL.live : PANEL.text)
222
223/**
224 * The band, in a bordered panel with a background, two rows. The first is what is happening now (what needs a person, the mission,
225 * who is working, a fresh event). The second is what stands: the last tool call and how long ago, claims, spend, findings, an update,
226 * then links to the main views. They are separate rows so a long mission title cannot push the standing facts out. Each part is a
227 * link: a click opens the console on the view it is about. `onGo` opens the console there; `onOpen` opens it as it was.
228 */
229export function barView(kit: Kit, state: State, columns: number, mark: RenderElement | null, onOpen: () => void, onGo?: (view: ViewId) => void, onDismiss?: () => void): RenderElement {
230 // A stale marketplace clone is one of the alerts, so it already turns the band's attention part on.
231 const parts = barParts(state)
232 const notice = visibleNotice(state, Date.now())
233 const inner = Math.max(16, columns - 4)
234 const sep = (): RenderElement => kit.Text({ color: PANEL.dim, children: ' · ' })
235
236 // One part: a button to its view where it has one, else words in its tone's colour.
237 const partElement = (part: BarPart, key: string, room: number): RenderElement => {
238 const go = part.go
239 const label = clip(part.text, room - 3)
240
241 if (go !== undefined && onGo !== undefined) return kit.Button({ key, label, plain: true, ...(part.tone === 'plain' && { dimColor: true }), onPress: () => onGo(go) })
242
243 return kit.Text({ wrap: 'truncate-end', color: toneColor(part.tone), children: label })
244 }
245 // A row whose parts do not all fit in full uses their compact forms (a part with none keeps its words), so a part is shortened by
246 // its own choice of words, not cut in the middle of one.
247 // `bare` is a row whose lead already ends in its own space (the standing row's "↳ "): its first part needs no separator before it.
248 const fill = (lead: RenderElement[], room: number, shown: BarPart[], from: number, bare = false): { children: RenderElement[]; room: number } => {
249 const children = [...lead]
250 const tight = shown.reduce((sum, part) => sum + part.text.length + 3, 0) > room
251 const forms = shown.map(part => (tight && part.compact !== undefined ? { ...part, text: part.compact } : part))
252
253 for (const [i, part] of forms.entries()) {
254 if (room <= 6) break
255 children.push(...(bare && i === 0 ? [] : [sep()]), partElement(part, `band-${from + i}`, room))
256 room -= part.text.length + (bare && i === 0 ? 0 : 3)
257 }
258
259 return { children, room }
260 }
261
262 const status = parts.filter(part => part.row !== 'standing')
263 const standing = parts.filter(part => part.row === 'standing')
264 const first = fill([mark !== null ? mark : kit.Text({ color: PANEL.attention, children: '◆ ' }), kit.Text({ bold: true, color: PANEL.text, children: 'ruflo' })], inner - 5 - (state.pane.isOpen ? 0 : 18), status, 0)
265
266 if (!state.pane.isOpen) first.children.push(kit.Text({ children: ' ' }), kit.Button({ key: 'open-console', label: 'open console', plain: true, onPress: onOpen }))
267
268 // The second row: the standing facts, then the links with what room is left (a link that does not fit is dropped, not cut).
269 const second = fill([kit.Text({ color: PANEL.dim, children: '↳ ' })], inner - 2, standing, status.length, true)
270 let room = second.room
271
272 for (const [i, link] of BAND_LINKS.entries()) {
273 if (room < link.label.length + 5) break
274 // A bar sets the links off from the facts before them; between links, a space.
275 if (i > 0) second.children.push(kit.Text({ children: ' ' }))
276 else if (standing.length > 0) second.children.push(kit.Text({ color: PANEL.dim, children: ' │ ' }))
277
278 second.children.push(onGo !== undefined ? kit.Button({ key: `band-link-${link.go}`, label: link.label, plain: true, dimColor: true, onPress: () => onGo(link.go) }) : kit.Text({ color: PANEL.dim, children: link.label }))
279 room -= link.label.length + (i === 0 ? 3 : 1)
280 }
281
282 // An announcement, on its own row: what changed, a link to where it is, and a dismiss. It goes by itself after a short while.
283 const noticeRow = notice === null ? [] : [
284 kit.Box({
285 flexDirection: 'row',
286 children: [
287 kit.Text({ bold: true, color: NOTICE_COLOR[notice.level], children: `${NOTICE_MARK[notice.level]} ` }),
288 kit.Text({ color: NOTICE_COLOR[notice.level], wrap: 'truncate-end', children: clip(notice.text, Math.max(10, inner - 24)) }),
289 kit.Text({ children: ' ' }),
290 ...(notice.go !== undefined && onGo !== undefined ? [kit.Button({ key: 'band-notice-go', label: 'view', plain: true, onPress: () => onGo(notice.go as ViewId) })] : []),
291 ...(onDismiss !== undefined ? [kit.Text({ children: ' ' }), kit.Button({ key: 'band-notice-dismiss', label: '✕', plain: true, dimColor: true, onPress: onDismiss })] : []),
292 ],
293 }),
294 ]
295
296 return kit.Box({
297 flexDirection: 'column',
298 borderStyle: 'round',
299 borderColor: BORDER[bandTone(parts, notice)],
300 backgroundColor: PANEL.ground,
301 paddingX: 1,
302 children: [kit.Box({ flexDirection: 'row', children: first.children }), ...(state.bandCompact ? [] : [kit.Box({ flexDirection: 'row', children: second.children })]), ...noticeRow],
303 })
304}
305