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."
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 464 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { ANSWER_KEYS } from './views/attention'
3
4import { createController, type Controller } from './controller'
5import { record } from './data/events'
6import { plain } from './data/parse'
7import { dispatch } from './dispatch'
8import { markPicture } from './gfx/pictures'
9import type { Host } from './host'
10import { ownerLine, ownerOf } from './tool-owner'
11import { newState, PANE_ID, restore, restoreSessions, storeKeyOf, termStoreKeyOf } from './state'
12import { BAR_KEY, barView } from './views/bar'
13import { addNotice, dismissNotices } from './notices'
14import { setBootChecks } from './boot-checks'
15import { buildOf, isOurCheckout, setBuild } from './build'
16import { runUpdateCheck } from './update-flow'
17import { announceModelTools, confirmOf, levelOf, lowerOnly, parseControlEnv, serveModelTools } from './model-tools'
18import { loadAiPrefs, settingsOf } from './settings'
19import { contextSection, onPromptSubmit, onTurnComplete } from './mission-claude'
20import { parseMode, RECHECK_EVERY_MS, UPDATES_KEY } from './updates'
21import { selfCheckResults } from './self-check'
22import type { Kit } from './views/common'
23import { picturesOf } from './views/frames'
24import { withClearing } from './views/clearing'
25import { NARROW, paneView } from './views/pane'
26
27const RUFLO_TOOL = /^mcp__(claude-flow|ruflo|plugin_ruflo[\w-]*)__/
28
29/**
30 * Binds a Host from `$`, every member spelled `$.noun.method(...)` here and nowhere else, so the engine reads what
31 * the module calls off this one place. Calls that answer nothing are wrapped: a refused draw is not a crashed hook.
32 */
33/** 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. */
34let isResettingScroll = false
35
36function hostOf($: EngineInterface, cwd: string): Host {
37 const rooted = (path: string) => (path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`)
38 const quietly = (fn: () => unknown) => {
39 try {
40 const result = fn()
41
42 if (result instanceof Promise) result.catch(() => undefined)
43 } catch {
44 // Refused: there is nothing to do about a draw nobody may make.
45 }
46 }
47
48 return {
49 fs: { read: async path => $.fs.read(rooted(path)), stat: async path => $.fs.stat(rooted(path)), list: async path => $.fs.list(rooted(path)) },
50 every: (ms, fn) => $.clock.every(ms, fn),
51 after: (ms, fn) => $.clock.after(ms, fn),
52 storeGet: async key => $.store.get(key),
53 storeSet: async (key, value) => $.store.set(key, value as never),
54 fetchText: async url => {
55 const response = await $.http.fetch(url)
56
57 return { ok: response.ok, status: response.status, text: response.text }
58 },
59 askChoice: async (question, options) => $.ui.ask(question, options),
60 toast: (text, timeoutMs) => quietly(() => $.ui.toast(text, timeoutMs === undefined ? undefined : { timeoutMs })),
61 invalidate: () => quietly(() => $.ui.invalidate('ui.render')),
62 // 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.
63 scrollTop: () => {
64 const go = () =>
65 quietly(() => {
66 isResettingScroll = true
67
68 return Promise.resolve($.ui.scroll({ to: 'start', in: PANE_ID })).finally(() => {
69 isResettingScroll = false
70 })
71 })
72
73 go()
74 $.clock.after(80, go)
75 },
76 focus: async (paneId, key) => $.ui.focus({ requestId: paneId, key }),
77 blit: args => quietly(() => $.ui.blit(args)),
78 openPane: async pane => $.ui.open(pane),
79 closePane: async id => $.ui.close({ id }),
80 panes: async () => $.ui.panes(),
81 registerCommand: async spec => $.command.register(spec),
82 run: async (argv, timeoutMs, stdin) => $.process.run(argv, { cwd, timeoutMs, ...(stdin !== undefined && { stdin }) }),
83 spawn: (argv, input) => $.process.spawn({ argv, cwd, ...(input !== undefined && { input }) }),
84 usage: async () => {
85 const usage = await $.session.usage()
86
87 return { ...(usage.cost?.usd !== undefined && { costUsd: usage.cost.usd }), ...(usage.context?.percent !== undefined && { contextPercent: usage.context.percent }) }
88 },
89 rufloTools: async () => {
90 const names = (await $.tool.list()).flatMap(tool => RUFLO_TOOL.exec(tool.name)?.slice(1, 2) ?? [])
91
92 return { tools: names.length, servers: [...new Set(names)].sort() }
93 },
94 settings: async () => $.settings.read(),
95 home: async () => $.env.get('HOME'),
96 configDir: async () => $.env.get('CLAUDE_CONFIG_DIR'),
97 pluginRoot: $.plugin.root,
98 // `$.ruflo` exists only where ruflo-mods is seated; validate refuses feature-detecting a noun, so these are
99 // async: a missing noun throws inside the promise and every caller's catch sees a rejection.
100 rufloSnapshot: async () => $.ruflo.snapshot(),
101 rufloRoute: async () => $.ruflo.lastRoute(),
102 rufloSegment: async text => $.ruflo.segment({ id: 'console', text }),
103 // 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
104 // tick, a later event of their own.
105 submitPrompt: text =>
106 new Promise<void>((resolve, reject) => {
107 $.clock.after(1, () => void $.prompt.submit({ text }).then(() => resolve(), reject))
108 }),
109 fillPrompt: async text => (await $.prompt.fill({ text, mode: 'replace' })).isFilled,
110 runSlash: (command, args) =>
111 new Promise((resolve, reject) => {
112 $.clock.after(1, () => void $.command.run({ command, args }).then(resolve, reject))
113 }),
114 listCommands: async () => (await $.command.list()).map(command => command.name),
115 // ADR-465. A tool call waits on the turn like submitPrompt, so it starts from a clock tick, never inside the hook that asked.
116 toolCall: input =>
117 new Promise((resolve, reject) => {
118 $.clock.after(1, () => void $.tool.call(input as never).then(reply => resolve(reply as never), reject))
119 }),
120 toolCheck: async (tool, input) => $.tool.check({ tool, input }),
121 httpSend: async (url, init) => {
122 const response = await $.http.fetch(url, init)
123
124 return { ok: response.ok, status: response.status, text: response.text }
125 },
126 }
127}
128
129/**
130 * ruflo-console: ruflo's cockpit inside Claude Code, and the home of `/ruflo`. A pane of views over ruflo's state on
131 * disk and the ruflo CLI's local answers, a band above the prompt, a command palette, and management views (agent
132 * drill-down, timeline, approvals, events). Every change goes through the ruflo CLI with fixed argv after a confirm.
133 */
134export const register: Register = (on, raw: PluginOptions) => {
135 // The boot log reports this check, so an [ OK ] on screen means the area's commands resolved. It spawns nothing and takes a
136 // moment; a failure of the check itself leaves the log drawing as it did, never stops the console.
137 try {
138 setBootChecks(selfCheckResults())
139 } catch {
140 setBootChecks(undefined)
141 }
142
143 const state = newState(raw)
144 let host: Host | null = null
145 let control: Controller | null = null
146
147 // Claude's console tools (ADR-444): answered only for their own names, and only when the person's setting lets them exist.
148 serveModelTools(on, () => (control === null ? null : { state, control }))
149
150 on('session.start', async ($, e, next) => {
151 control?.stop()
152 host = hostOf($, e.cwd)
153 state.cwd = e.cwd
154 state.nostrKeyVerifiedAtMs = null
155 state.isInteractive = e.isInteractive !== false
156 control = createController(state, host)
157
158 // Which build is this? Only a checkout of this plugin in its repository is read (an installed copy inside some other repo is not
159 // that repo's commit); read-only, $0, and any failure leaves the header at its version alone.
160 const here = host
161 const root = here.pluginRoot
162
163 const built = here
164 .run(['git', '-C', root, 'rev-parse', '--show-prefix'], 3_000)
165 .then(prefix => (prefix.exitCode === 0 && isOurCheckout(prefix.stdout) ? here.run(['git', '-C', root, 'describe', '--always', '--dirty', '--abbrev=7'], 3_000) : null))
166 .then(described => {
167 setBuild(described !== null && described.exitCode === 0 ? buildOf(described.stdout) : '')
168 here.invalidate()
169 })
170 .catch(() => undefined)
171
172 // 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
173 // an update) and the screen has settled, one check for a newer published version. It never throws and never blocks the console.
174 const moded = here.storeGet(UPDATES_KEY).then(
175 value => {
176 state.updates = parseMode(value)
177 here.invalidate()
178 },
179 () => undefined,
180 )
181
182 void Promise.all([built, moded]).then(() => {
183 if (!state.isInteractive) return
184
185 here.after(2_500, () => void runUpdateCheck(state, here))
186 // A session left open for days re-asks too, quietly (no dialog mid-work); the daily gate keeps the network to once a day.
187 state.timers.set('update-recheck', here.every(RECHECK_EVERY_MS, () => void runUpdateCheck(state, here, { quiet: true })))
188 })
189
190 const bound = host
191
192 state.home = (await bound.home().catch(() => undefined)) ?? null
193 state.configDir = (await bound.configDir().catch(() => undefined)) ?? (state.home === null ? null : `${state.home}/.claude`)
194 // A recording or a wide screen can ask for a wider dock: RUFLO_CONSOLE_COLUMNS, whole columns, 40 to 400.
195 const asked = Number(await (async () => $.env.get('RUFLO_CONSOLE_COLUMNS'))().catch(() => ''))
196
197 // RUFLO_CONSOLE_PANEL=command|off overrides the panel option for this session (a recording that shows /ruflo opening it).
198 const panel = await (async () => $.env.get('RUFLO_CONSOLE_PANEL'))().catch(() => undefined)
199
200 if (panel === 'command' || panel === 'off') state.options.panel = panel
201 state.dockColumns = Number.isInteger(asked) && asked >= 40 && asked <= 400 ? asked : 0
202 // The x.ruv.io board's admin rows: only whether the token is set is kept, never its value.
203 state.xruv.hasAdminToken = await (async () => $.env.get('RUFLO_X_ADMIN_TOKEN'))().then(
204 value => typeof value === 'string' && value !== '',
205 () => null,
206 )
207 await Promise.all([
208 bound
209 .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]' })
210 .catch(() => undefined),
211 // Kept for good (ADR-406: no command is removed or renamed): `/ruflo-console` is the same command as `/ruflo`.
212 bound.registerCommand({ name: 'ruflo-console', description: 'Same as /ruflo: the ruflo console', argumentHint: '[view|palette|help]' }).catch(() => undefined),
213 bound.storeGet(storeKeyOf(e.cwd)).then(value => restore(state, value), () => undefined),
214 bound.storeGet(termStoreKeyOf(e.cwd)).then(value => restoreSessions(state, value), () => undefined),
215 bound.rufloTools().then(counted => void (state.rufloTools = counted), () => undefined),
216 ])
217 control.start()
218 await control.refresh()
219 control.autoOpen()
220
221 // 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.
222 await loadAiPrefs(state, bound).catch(() => undefined)
223
224 const forced = parseControlEnv(await (async () => $.env.get('RUFLO_CONSOLE_CONTROL'))().catch(() => undefined))
225
226 // The override may only lower what the person saved (ADR-450 T12): a project's settings env must not raise Claude's control.
227 const ai = settingsOf(state).ai
228 const effective = lowerOnly({ level: levelOf(ai.modelControl), confirm: confirmOf(ai.modelConfirm) }, forced)
229
230 Object.assign(ai, { modelControl: effective.level, modelConfirm: effective.confirm })
231 await announceModelTools(tool => $.tool.register(tool), state).catch(() => 0)
232
233 return next(e)
234 })
235
236 on('session.end', async ($, e, next) => {
237 control?.stop()
238
239 return next(e)
240 })
241
242 /**
243 * `/ruflo`: the console's own subcommands are answered here; `mods` and `swarm <sub>` go to the plugins beneath that
244 * hook the same command (ruflo-mods, ruflo-swarm), and are answered with a hint when neither does.
245 */
246 on('command.run', { command: 'ruflo' }, async ($, e, next) => {
247 if (control === null) return next(e)
248
249 return dispatch(control, state, e.args, async () => (await next(e)) as { text?: string } | undefined)
250 })
251
252 /** `/ruflo-console` is the same command: ruflo-mods and ruflo-swarm hook it as they hook `/ruflo`. */
253 on('command.run', { command: 'ruflo-console' }, async ($, e, next) => {
254 if (control === null) return next(e)
255
256 return dispatch(control, state, e.args, async () => (await next(e)) as { text?: string } | undefined)
257 })
258
259 // Which element was pressed or submitted, before its own closure runs: the runner reads it as the origin of the ask that follows, so
260 // the page puts the confirm and the answer right under it (views/attention.ts). Answering a confirm never moves the origin.
261 on('ui.press', { component: 'Pane' }, ($, e, next) => {
262 if (!ANSWER_KEYS.has(e.element)) state.lastPressed = e.element
263
264 return next(e)
265 })
266
267 on('ui.input', { component: 'Pane' }, ($, e, next) => {
268 if (e.kind === 'submit') state.lastPressed = e.element
269
270 return next(e)
271 })
272
273 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
274 if (control === null) {
275 return next(e)
276 }
277
278 const started = Date.now()
279 const table = $.ui.resolve(e) as unknown as Kit
280 const columns = Math.max(20, Math.floor(Number(e.props.bodyColumns) || 0) - 1)
281 const isNarrow = columns < NARROW
282 const kit: Kit = isNarrow ? { Box: table.Box, Text: table.Text, Button: table.Button, ...(table.Input !== undefined && { Input: table.Input }) } : table
283
284 if (!state.pane.isOpen) state.pane.bootAtMs = Date.now()
285 state.pane.isOpen = true
286 state.pane.isFocused = e.props.isFocused === true
287 state.pane.columns = columns
288 state.pane.placement = e.props.placement
289 // A reload while the pane stayed up: timers are gone, so resume them from here.
290 if (!state.timers.has('watch')) control.resume()
291
292 const pictures = isNarrow ? new Map() : picturesOf(state, columns, Date.now(), Date.now())
293
294 state.mounted = new Map([...pictures].map(([key, grid]) => [key, { columns: grid.columns, rows: grid.rows }]))
295 control.animate()
296
297 state.pane.rows = Math.max(0, Math.floor(Number(e.props.scroll?.bodyRows) || 0))
298
299 const tree = paneView({ kit: withClearing(kit, state, control.actions.clearField), state, nowMs: Date.now(), columns, pictures, act: control.actions })
300
301 state.stats.renders.push(Date.now() - started)
302 if (state.stats.renders.length > 200) state.stats.renders.shift()
303
304 return tree
305 })
306
307 // The AI terminal's conversation is its own window: the wheel and the page keys over the pane move it, so the header,
308 // tabs and the field below stay where they are (the engine would scroll the whole pane).
309 on('ui.scroll', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
310 if (control === null || state.view !== 'terminal' || e.by === 0 || isResettingScroll) return next(e)
311
312 const lines = Math.abs(e.by) >= e.bodyRows ? Math.max(1, Math.round(e.bodyRows / 2)) : Math.abs(e.by) * 3
313
314 control.actions.term.scroll(e.by < 0 ? lines : -lines)
315
316 // The pane itself stays put: ask the engine for the offset it already has.
317 return next({ ...e, offset: e.offset - e.by })
318 })
319
320 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
321 const mode = state.bandMode ?? state.options.bar
322 const show = mode === 'on' || (mode === 'auto' && state.snapshot?.isRufloProject === true)
323
324 if (control === null || e.props.hasSurvey || !show) {
325 return next(e)
326 }
327
328 const table = $.ui.resolve(e) as unknown as Kit
329 const bound = control
330
331 state.barDrawnAtMs = Date.now()
332
333 const mark = table.Raster !== undefined ? table.Raster(markPicture(e.props.isWorking, Date.now()).toRaster(BAR_KEY)) : null
334
335 state.turnActive = e.props.isWorking === true
336 bound.markFrame(e.requestId, e.props.isWorking && mark !== null)
337
338 // A click on a part opens the console on its view, with the keys, so the person can act there at once.
339 return barView(table, state, Math.floor(Number(e.props.bodyColumns) || 80), mark, () => void bound.open(false), view => {
340 bound.setView(view)
341 void bound.open(true)
342 }, () => {
343 dismissNotices(state)
344 try {
345 $.ui.invalidate('ui.render')
346 } catch {
347 // A refused redraw leaves the notice showing until the next one.
348 }
349 })
350 })
351
352 // A tool row that ran while one mission task was running says which: one dim line under the engine's own row.
353 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
354 const owner = ownerOf(state, e.props.tool_use_id, e.props.isRunning === true)
355
356 if (owner === null) return next(e)
357
358 const table = $.ui.resolve(e) as unknown as Kit
359 const own = await next(e)
360
361 return table.Box({ key: `owner-${e.props.tool_use_id}`, flexDirection: 'column', children: [own, table.Text({ dimColor: true, children: ` ${ownerLine(owner)}` })] })
362 })
363
364 /** The band's mark pulses during a turn: a redraw at its start, and the loop stopped at its end, whatever redraws. */
365 on('turn.start', ($, e, next) => {
366 // A new turn: the per-turn cap on Claude's console actions starts over.
367 state.control.turnCalls = 0
368 if (e.agentId === undefined) state.turnStartedMs = Date.now()
369
370 try {
371 $.ui.invalidate('ui.render')
372 } catch {
373 // A refused redraw leaves the mark at rest.
374 }
375
376 return next(e)
377 })
378
379 on('turn.complete', ($, e, next) => {
380 if (e.agentId === undefined && state.turnStartedMs !== null) {
381 // A long turn that ends while nobody watches is worth saying: the band announces it (a short one is not news).
382 const took = Date.now() - state.turnStartedMs
383
384 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' })
385 }
386 if (e.agentId === undefined) state.turnStartedMs = null
387 if (e.agentId === undefined) control?.markFrame('', false)
388 if (e.agentId === undefined && host !== null) {
389 try {
390 onTurnComplete(state, host, e.reason)
391 } catch {
392 // A note that could not be recorded never changes the turn.
393 }
394 }
395
396 return next(e)
397 })
398
399 // The mission Claude is working on rides in the system prompt (ADR-443); the text changes only when the task does.
400 on('prompt.compose', async ($, e, next) => {
401 const result = await next(e)
402 const section = contextSection(state)
403
404 return section === null ? result : { sections: [...result.sections, section] }
405 })
406
407 // A prompt carrying a mission's loop marker is that loop's tick.
408 on('prompt.submit', ($, e, next) => {
409 if (host !== null) {
410 try {
411 onPromptSubmit(state, host, e.text)
412 } catch {
413 // Counting a tick never blocks the prompt.
414 }
415 }
416
417 return next(e)
418 })
419
420 on('ui.close', async ($, e, next) => {
421 const result = await next(e)
422
423 if (e.id === PANE_ID && result.deny === undefined) {
424 state.pane.isOpen = false
425 state.pane.isShown = false
426 if (e.origin.kind === 'person') control?.closedByPerson()
427 control?.animate()
428 }
429
430 return result
431 })
432
433 /** Observes only: every call goes on unchanged; the count feeds the activity sparkline. */
434 on('tool.call', ($, e, next) => {
435 control?.noteToolCall(e.agentId, e.tool)
436
437 return next(e)
438 })
439
440 /** Observes only: a deny any verdict reached is listed in the approvals queue; the verdict passes on unchanged. */
441 on('tool.check', async ($, e, next) => {
442 const verdict = await next(e)
443
444 if (verdict.decision === 'deny') {
445 state.denied.push({ tool: plain(String(e.tool), 40), reason: plain(verdict.reason ?? 'no reason given', 160), atMs: Date.now() })
446 if (state.denied.length > 20) state.denied.shift()
447 record(state.events, [{ atMs: Date.now(), kind: 'mods', text: `${plain(String(e.tool), 40)} denied: ${plain(verdict.reason ?? '', 80)}` }])
448 }
449
450 return verdict
451 })
452
453 /** Observes only, never refuses: which mods the engine admitted or refused after the console, for the plugins view. */
454 on('plugin.register', async ($, e, next) => {
455 const result = await next(e)
456
457 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() })
458 if (state.mods.length > 50) state.mods.shift()
459 record(state.events, [{ atMs: Date.now(), kind: 'mods', text: `${plain(e.name, 40)} ${result.refuse === undefined ? 'loaded' : 'REFUSED'} (${plain(e.provenance, 60)})` }])
460
461 return result
462 })
463}
464hooks/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 496 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 } 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 { createRunner, type Runner } from './runner'
21import { advance, loadLedger, mcOf } from './mission-control'
22import { hasLiveWork } from './mission-list'
23import { loadAllowed } from './remember'
24import { loadAiPrefs } from './settings'
25import { openLoaders } from './view-open'
26import { listSkills } from './skills'
27import { readDrillLogs } from './drill-logs'
28import { entryAge } from './menu-entry'
29import { BOOT_MIN_MS, CLI_PREFIXES, isBooting, NAV_KEY, NAV_STYLES, PANE_ID, push, rowsOf, storeKeyOf, type State } from './state'
30import type { Actions } from './views/common'
31import { picturesOf } from './views/frames'
32import { pulseDue } from './pulse'
33import { refreshWorkflows } from './wf-live'
34
35const ACTIVITY_BUCKET_MS = 5_000
36const PANE_WATCH_MS = 1_000
37const MAX_PARALLEL_PROBES = 2
38const 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
39const BAR_FRESH_MS = 10_000
40const IDLE_REFRESH_MS = 30_000
41const TOOLS_RECOUNT_MS = 30_000
42
43export type Controller = {
44 refresh: () => Promise<void>
45 probe: (force?: boolean) => Promise<void>
46 start: () => void
47 /** Restarts the pane's watch after a reload left the pane up and the timers gone. */
48 resume: () => void
49 stop: () => void
50 open: (focus?: boolean) => Promise<{ isPlaced: boolean; reason: string }>
51 /** At session start, with `panel: auto`: opens where it docks, never taking the keys; else leaves a hint. */
52 autoOpen: () => void
53 /** The person closed the pane (Esc, its mark): auto-open stands down until /ruflo opens it again. */
54 closedByPerson: () => void
55 close: () => Promise<void>
56 setView: (view: State['view']) => void
57 drill: (agentId: string) => void
58 animate: () => void
59 noteToolCall: (agentId: string | undefined, tool: string) => void
60 actions: Actions
61 runner: Runner
62 host: Host
63 /** The command catalog, once `/ruflo commands` has read it. */
64 catalog?: Promise<Catalog>
65 /** Blits the band's mark while Claude works; the band calls it with its requestId. */
66 markFrame: (requestId: string, isWorking: boolean) => void
67}
68
69export function createController(state: State, host: Host): Controller {
70 let activityCount = 0
71 let markRequest: string | null = null
72 let lastSegment: string | null | undefined
73 let lastSpend: number | undefined
74 let hasDrawn = false
75 let toolsCountedAt = 0
76 let inflight: Promise<void> | null = null
77 const lastAttempt = new Map<string, number>()
78
79 const persist = () => void host.storeSet(storeKeyOf(state.cwd), { view: state.view === 'agent' ? state.back : state.view, isClosedByPerson: state.pane.isClosedByPerson }).catch(() => undefined)
80 const isVisible = () => state.pane.isOpen && state.pane.isShown
81
82 function refresh(): Promise<void> {
83 inflight ??= readAll().finally(() => {
84 inflight = null
85 })
86
87 return inflight
88 }
89
90 /** A read that starts after this call: what an action checks, since a read already running may predate its write. */
91 async function freshRead(): Promise<void> {
92 await inflight?.catch(() => undefined)
93
94 // A file the action just created must not wait out the missing-file backoff.
95 for (const [path, held] of state.cache) {
96 if ('missingUntilMs' in held) state.cache.delete(path)
97 }
98
99 await refresh()
100 }
101
102 async function readAll(): Promise<void> {
103 state.isRefreshing = true
104
105 const started = Date.now()
106
107 try {
108 // Claude Code connects MCP servers after the session starts: count the ruflo tools again now and then.
109 if (Date.now() - toolsCountedAt >= TOOLS_RECOUNT_MS) {
110 toolsCountedAt = Date.now()
111 void host.rufloTools().then(counted => void (state.rufloTools = counted), () => undefined)
112 }
113
114 const [settings, usage, ruflo, route] = await Promise.all([
115 host.settings().catch(() => null),
116 host.usage().catch(() => null),
117 host.rufloSnapshot().catch((error: unknown) => {
118 state.ruflo.error = plain(String(error), 120)
119
120 return null
121 }),
122 host.rufloRoute().catch(() => null),
123 ])
124 const previous = state.snapshot
125 const now = Date.now()
126 const snapshot = await readSnapshot(host.fs, state.cache, state.cwd, state.home, settings, now, state.configDir, state.options.federationNetwork)
127 if (snapshot.hasNostrKey === false) state.nostrKeyVerifiedAtMs = null
128 // What changed since the last read is announced on the band (the first read announces nothing).
129 const before = previous === null ? null : factsOf(state, now)
130
131 state.snapshot = snapshot
132 record(state.events, diffEvents(previous, snapshot, now))
133
134 if (before !== null) announceChanges(state, before, now)
135
136 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)}%)` }])
137
138 state.usage = usage
139 state.ruflo.snapshot = ruflo
140 state.ruflo.route = route ?? ruflo?.lastRoute ?? null
141 push(state.writes, snapshot.changed)
142
143 for (const agent of snapshot.agents.slice(0, 200)) {
144 const log = state.statusLog.get(agent.id) ?? []
145
146 if (log.at(-1)?.status !== agent.status) push(log, { atMs: now, status: agent.status }, 100)
147 state.statusLog.set(agent.id, log)
148 }
149
150 const patterns = snapshot.neural?.patterns
151
152 if (patterns !== undefined && state.history.patterns.at(-1)?.value !== patterns) push(state.history.patterns, { atMs: now, value: patterns })
153 if (usage?.costUsd !== undefined && state.history.spend.at(-1)?.value !== usage.costUsd) push(state.history.spend, { atMs: now, value: usage.costUsd })
154 if ((snapshot.outcomes?.total ?? 0) > state.history.outcomes) {
155 if (state.history.outcomes > 0) state.curveGrewAtMs = now
156 state.history.outcomes = snapshot.outcomes?.total ?? 0
157 }
158
159 if (state.options.bar === 'off') {
160 const text = segmentOf(state)
161
162 if (text !== lastSegment) {
163 lastSegment = text
164 void host.rufloSegment(text).catch(() => undefined)
165 }
166 }
167 } catch (error) {
168 state.ruflo.error = plain(String(error), 120)
169 } finally {
170 state.isRefreshing = false
171 push(state.stats.refreshes, Date.now() - started, 200)
172
173 // Redraw only for something new while the pane is closed: the band need not repaint an unchanged line.
174 const spend = state.usage?.costUsd
175
176 if (state.pane.isOpen || (state.snapshot?.changed ?? 0) > 0 || spend !== lastSpend || !hasDrawn) {
177 hasDrawn = true
178 lastSpend = spend
179 host.invalidate()
180 }
181 }
182 }
183
184 const probesInFlight = new Map<string, Promise<void>>()
185
186 /** One probe run at a time per probe: a second ask while it runs joins it. */
187 function runProbe(probe: (typeof ALL_PROBES)[number]): Promise<void> {
188 const held = probesInFlight.get(probe.id)
189
190 if (held !== undefined) return held
191
192 const run = runProbeOnce(probe).finally(() => probesInFlight.delete(probe.id))
193
194 probesInFlight.set(probe.id, run)
195
196 return run
197 }
198
199 async function runProbeOnce(probe: (typeof ALL_PROBES)[number]): Promise<void> {
200 const held: ProbeResult = state.probes.get(probe.id) ?? { value: null, okAtMs: null, error: null, errorAtMs: null, isRunning: false }
201
202 state.probes.set(probe.id, { ...held, isRunning: true })
203 lastAttempt.set(probe.id, Date.now())
204
205 try {
206 const argv = probeArgv(probe, state.options.cli, state)
207 const result = await host.run(argv, probe.timeoutMs)
208 const value = result.exitCode === 0 ? (probe.parse(result.stdout) as unknown) : null
209 state.probes.set(
210 probe.id,
211 value !== null
212 ? { value, okAtMs: Date.now(), error: null, errorAtMs: held.errorAtMs, isRunning: false }
213 : {
214 ...held,
215 isRunning: false,
216 errorAtMs: Date.now(),
217 error: probeError(argv, result),
218 },
219 )
220 } catch (error) {
221 state.probes.set(probe.id, { ...held, isRunning: false, errorAtMs: Date.now(), error: plain(error instanceof Error ? error.message : String(error), 100) || 'refused' })
222 } finally {
223 host.invalidate()
224 }
225 }
226
227 /** Runs the probes the view in front draws, each no more often than its cadence; `force` ignores the cadence. */
228 async function probe(force = false): Promise<void> {
229 const now = Date.now()
230 const due = ALL_PROBES.filter(
231 entry =>
232 (isVisible() || force) &&
233 entry.views.includes(state.view) &&
234 (!entry.isNetwork || state.options.federationNetwork) && probeReady(entry, state) &&
235 (force || (state.probes.get(entry.id)?.isRunning !== true && now - (lastAttempt.get(entry.id) ?? 0) >= entry.everyMs)),
236 )
237
238 for (let i = 0; i < due.length; i += MAX_PARALLEL_PROBES) {
239 await Promise.all(due.slice(i, i + MAX_PARALLEL_PROBES).map(entry => runProbe(entry)))
240 }
241 }
242
243 function every(name: string, ms: number, fn: () => void): void {
244 if (!state.timers.has(name)) state.timers.set(name, host.every(ms, fn))
245 }
246
247 function cancel(name: string): void {
248 state.timers.get(name)?.cancel()
249 state.timers.delete(name)
250 }
251
252 // Whether the last frame drew the boot screen: when it ends the whole pane redraws once, and an unfocused pane's loop stops again.
253 let wasBooting = false
254
255 /** One frame of every picture of the view in front, each blitted only at the size it was mounted. */
256 function frame(): void {
257 const started = Date.now()
258 const booting = isBooting(state, started)
259
260 if (wasBooting && !booting) {
261 wasBooting = false
262 state.pane.menuAtMs = Date.now()
263 host.invalidate()
264 animate()
265
266 return
267 }
268
269 wasBooting = booting
270
271 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()
272
273 for (const [key, grid] of picturesOf(state, state.pane.columns, Date.now(), Date.now())) {
274 const mounted = state.mounted.get(key)
275
276 if (mounted !== undefined && mounted.columns === grid.columns && mounted.rows === grid.rows) {
277 host.blit({ requestId: PANE_ID, key, cells: grid.encode(), columns: grid.columns, rows: grid.rows })
278 }
279 }
280
281 push(state.stats.frames, Date.now() - started, 200)
282 }
283
284 /** Runs the frame loop while the pane is shown and holds the keys (or plays the boot screen), at `fps`; stops it otherwise. */
285 function animate(): void {
286 // 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.
287 const moving = state.lab.running !== null || (state.view === 'missions' && hasLiveWork(state.snapshot?.missions?.missions ?? [], mcOf(state).guidance?.status === 'running'))
288
289 if (!(state.options.fps > 0 && isVisible() && (state.pane.isFocused || isBooting(state, Date.now())) && (state.mounted.size > 0 || moving))) return cancel('frames')
290
291 every('frames', Math.round(1000 / state.options.fps), frame)
292 }
293
294 /** Watches whether the pane is shown and focused, so the loop stops behind another tab and resumes in front. */
295 async function watchPane(): Promise<void> {
296 const panes = await host.panes().catch(() => null)
297 const mine = panes?.find(pane => pane.id === PANE_ID)
298
299 if (panes !== null) {
300 state.pane.isOpen = mine !== undefined
301 state.pane.isShown = mine?.isShown === true
302 state.pane.isFocused = mine?.isFocused === true
303 }
304
305 if (!state.pane.isOpen) cancel('watch')
306
307 animate()
308 }
309
310 function start(): void {
311 let lastIdleMs = 0
312
313 // The AI terminal's saved model and budget apply from the first turn, not only once Settings was opened.
314 void loadAiPrefs(state, host)
315 void loadAllowed(state, host)
316 void loadLedger(state, host)
317 void host.storeGet(NAV_KEY).then(saved => {
318 const style = NAV_STYLES.find(candidate => candidate === saved)
319
320 if (style !== undefined) state.nav = style
321 }, () => undefined)
322
323 every('refresh', state.options.refreshSeconds * 1000, () => {
324 const now = Date.now()
325 const isSeen = state.pane.isOpen || now - state.barDrawnAtMs < BAR_FRESH_MS
326
327 // Nothing on screen reads the disk: re-read only on the idle cadence, so a closed console costs nearly nothing.
328 if (isSeen || now - lastIdleMs >= IDLE_REFRESH_MS) {
329 lastIdleMs = now
330 void refresh().then(() => {
331 void probe()
332 void refreshWorkflows(state, host)
333 advance(state, host)
334 })
335 }
336 })
337 every('activity', ACTIVITY_BUCKET_MS, () => {
338 push(state.activity, activityCount)
339 activityCount = 0
340 })
341 }
342
343 const resume = () => every('watch', PANE_WATCH_MS, () => void watchPane())
344
345 function stop(): void {
346 for (const timer of state.timers.values()) timer.cancel()
347 state.timers.clear()
348 }
349
350 /** `closeOnEscape` false: take the keys but leave Esc handing them back, as an auto-opened pane does. */
351 async function open(focus = true, closeOnEscape = focus): Promise<{ isPlaced: boolean; reason: string }> {
352 try {
353 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 }) })
354 const isPlaced = result === undefined || result.isPlaced !== false
355
356 if (isPlaced && !state.pane.isOpen) state.pane.bootAtMs = Date.now()
357 state.pane.isOpen = isPlaced
358 state.pane.isShown = isPlaced
359 if (focus) state.pane.isClosedByPerson = false
360 if (isPlaced) persist()
361 resume()
362 void refresh().then(() => probe(true))
363
364 return { isPlaced, reason: result?.reason ?? '' }
365 } catch (error) {
366 return { isPlaced: false, reason: plain(error instanceof Error ? error.message : String(error), 160) }
367 }
368 }
369
370 function autoOpen(): void {
371 if (state.options.panel !== 'auto' || state.snapshot?.isRufloProject !== true || state.pane.isOpen || state.pane.isClosedByPerson || state.pane.autoTried) return
372
373 state.pane.autoTried = true
374 // From a timer, never a render hook; without `focus`, so the prompt keeps the keys. The engine seats an unasked pane
375 // only where it docks (144 columns and up) and answers why not otherwise: the band then says "/ruflo to open".
376 state.timers.set(
377 'auto-open',
378 host.after(50, () => {
379 state.timers.delete('auto-open')
380 // /ruflo <view> may have opened it in the meantime: that choice stands.
381 if (state.pane.isOpen) return
382 // The BBS look opens on its main menu, as a board does after login.
383 if (state.options.look === 'bbs') state.view = 'menu'
384 void open(false).then(result => {
385 state.pane.autoReason = result.isPlaced ? '' : result.reason || 'not placed'
386 host.invalidate()
387 })
388 }),
389 )
390 }
391
392 async function close(): Promise<void> {
393 state.pane.isOpen = false
394 state.pane.isShown = false
395 state.pane.isClosedByPerson = true
396 persist()
397 cancel('frames')
398 cancel('watch')
399 await host.closePane(PANE_ID).catch(() => undefined)
400 }
401
402 function setView(view: State['view']): void {
403 state.isHelp = false
404 state.palette.isOpen = false
405
406 if (view !== state.view) {
407 if (view === 'agent' || state.view !== 'agent') state.back = state.view === 'agent' ? state.back : state.view
408 state.view = view
409 // A group picked on one page (the menu's pages row) does not follow you to the next, or back to this one.
410 state.navPick = null
411 state.pane.viewAtMs = Date.now()
412 state.select.item = 0
413 state.mounted.clear()
414 persist()
415 // A new view asks for its own height inline; the dock ignores it.
416 if (state.pane.isOpen) void host.openPane({ id: PANE_ID, title: 'ruflo', rows: rowsOf(view), ...(state.dockColumns > 0 && { columns: state.dockColumns }) }).catch(() => undefined)
417 void probe(true)
418 host.scrollTop()
419 }
420
421 host.invalidate()
422 // The terminal is for typing: its field takes the keys as it opens, so letters reach it, not the pane's hotkeys.
423 if (view === 'terminal') focusField('term-input')
424 // Opening the skills view is the person asking for its lists (npx skills reaches the network, so never unasked).
425 if (view === 'skills') {
426 void listSkills(state, host)
427 focusField('skills-search')
428 }
429 openLoaders(state, host, view)
430 }
431
432 /**
433 * 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
434 * mouse click on a tab does not give them, so a person who clicked their way to the terminal would type into
435 * Claude's prompt instead. Here the pane takes the keys first (an open with focus), then the ring moves to the field.
436 */
437 function focusField(key: string): void {
438 if (!state.pane.isOpen) return
439
440 const toField = () => void host.focus(PANE_ID, key).catch(() => undefined)
441
442 if (state.pane.isFocused) toField()
443 else void open(true, false).then(result => result.isPlaced && toField())
444 }
445
446 function drill(agentId: string): void {
447 const agent = state.snapshot?.agents.find(entry => entry.id === agentId)
448
449 state.drill = { agentId, logs: null, logsAtMs: 0 }
450 setView('agent')
451
452 const spec = agent === undefined ? null : agentLogs(agent)
453
454 if (spec !== null) readDrillLogs(state, host, agentId, spec.args)
455 }
456
457 const runner = createRunner(state, host, {
458 freshRead,
459 setView,
460 drill,
461 command: name => (name === 'refresh' ? actions.refresh() : name === 'help' ? actions.help() : actions.close()),
462 })
463 const actions: Actions = actionsOf(state, host, runner, { freshRead, probe, setView, drill, close, animate })
464
465 function markFrame(requestId: string, isWorking: boolean): void {
466 markRequest = requestId
467
468 if (isWorking && state.options.fps > 0) {
469 every('mark', Math.round(1000 / state.options.fps), () => {
470 if (markRequest !== null) host.blit({ requestId: markRequest, key: 'mark', cells: markPicture(true, Date.now()).encode(), columns: 2, rows: 1 })
471 })
472 } else {
473 cancel('mark')
474 }
475 }
476
477 function noteToolCall(agentId: string | undefined, tool: string): void {
478 activityCount += 1
479
480 const who = agentId ?? 'main'
481 const list = state.toolsByAgent.get(who) ?? []
482
483 push(list, { atMs: Date.now(), tool: plain(tool, 40) }, 200)
484 state.toolsByAgent.set(who, list)
485 if (state.toolsByAgent.size > 50) state.toolsByAgent.delete(state.toolsByAgent.keys().next().value as string)
486 record(state.events, [{ atMs: Date.now(), kind: 'tools', text: `${agentName(state, agentId)}: ${plain(tool, 40)}` }])
487 }
488
489 const closedByPerson = () => {
490 state.pane.isClosedByPerson = true
491 persist()
492 }
493
494 return { refresh, probe, start, resume, stop, open, autoOpen, closedByPerson, close, setView, drill, animate, noteToolCall, actions, runner, markFrame, host }
495}
496hooks/data/events.ts 145 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'
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}
17
18export const EVENT_KINDS: readonly EventKind[] = ['swarm', 'claims', 'federation', 'learning', 'tools', 'mods', 'missions']
19export const MAX_EVENTS = 300
20
21const ev = (atMs: number, kind: EventKind, text: string, agentId?: string): ConsoleEvent => ({ atMs, kind, text, ...(agentId !== undefined && { agentId }) })
22
23/** What changed from `prev` to `next`. The first read (no `prev`) is a baseline and yields nothing. */
24export function diffEvents(prev: Snapshot | null, next: Snapshot, atMs: number): ConsoleEvent[] {
25 if (prev === null) {
26 return []
27 }
28
29 const out: ConsoleEvent[] = []
30 const before = new Map(prev.agents.map(agent => [agent.id, agent]))
31 const after = new Map(next.agents.map(agent => [agent.id, agent]))
32
33 if (prev.swarm?.id !== next.swarm?.id && next.swarm !== null) out.push(ev(atMs, 'swarm', `swarm ${next.swarm.id} (${next.swarm.topology}) appeared`))
34 if (prev.swarm !== null && next.swarm !== null && prev.swarm.id === next.swarm.id && prev.swarm.status !== next.swarm.status) {
35 out.push(ev(atMs, 'swarm', `swarm ${next.swarm.status} (was ${prev.swarm.status})`))
36 }
37
38 for (const [id, agent] of after) {
39 const old = before.get(id)
40
41 if (old === undefined) out.push(ev(atMs, 'swarm', `agent ${agent.name ?? agent.type} spawned (${agent.type})`, id))
42 else if (old.status !== agent.status) out.push(ev(atMs, 'swarm', `agent ${agent.name ?? agent.type}: ${old.status} → ${agent.status}`, id))
43 }
44
45 for (const [id, agent] of before) {
46 if (!after.has(id)) out.push(ev(atMs, 'swarm', `agent ${agent.name ?? agent.type} left the store`, id))
47 }
48
49 const claimsBefore = new Map(prev.claims.map(claim => [claim.issueId, claim]))
50 const claimsAfter = new Map(next.claims.map(claim => [claim.issueId, claim]))
51
52 for (const [id, claim] of claimsAfter) {
53 const old = claimsBefore.get(id)
54 const owner = claim.claimant.kind === 'agent' ? claim.claimant.id : undefined
55
56 if (old === undefined) out.push(ev(atMs, 'claims', `${id} claimed by ${claim.claimant.agentType ?? claim.claimant.name ?? claim.claimant.id}`, owner))
57 else if (old.claimant.id !== claim.claimant.id) out.push(ev(atMs, 'claims', `${id} now held by ${claim.claimant.agentType ?? claim.claimant.id}`, owner))
58 else if (old.status !== claim.status) out.push(ev(atMs, 'claims', `${id}: ${old.status} → ${claim.status}${claim.handoffTo !== undefined ? ` (to ${claim.handoffTo})` : ''}`, owner))
59 else if ((old.progress ?? 0) !== (claim.progress ?? 0)) out.push(ev(atMs, 'claims', `${id} progress ${claim.progress ?? 0}%`, owner))
60 }
61
62 for (const [id, claim] of claimsBefore) {
63 if (!claimsAfter.has(id)) out.push(ev(atMs, 'claims', `${id} released`, claim.claimant.kind === 'agent' ? claim.claimant.id : undefined))
64 }
65
66 const tasksBefore = new Set(prev.tasks.map(task => task.id))
67
68 for (const task of next.tasks) {
69 if (!tasksBefore.has(task.id)) out.push(ev(atMs, 'claims', `task ${task.id} created: ${task.description.slice(0, 60)}`))
70 }
71
72 const proposals = new Set(prev.hive?.pending.map(entry => entry.id) ?? [])
73 const decided = new Set(prev.hive?.history.map(entry => entry.id) ?? [])
74
75 for (const proposal of next.hive?.pending ?? []) {
76 if (!proposals.has(proposal.id)) out.push(ev(atMs, 'swarm', `proposal ${proposal.type} (${proposal.strategy}) opened`, next.hive?.queen))
77 }
78
79 for (const decision of next.hive?.history ?? []) {
80 if (!decided.has(decision.id)) out.push(ev(atMs, 'swarm', `proposal ${decision.type} decided: ${decision.result} (${decision.votesFor}/${decision.votesAgainst})`, next.hive?.queen))
81 }
82
83 // Each ballot new since the last read, tagged with its voter: the hive's honeycomb pulses that worker's cell.
84 const ballotsBefore = new Map(prev.hive?.pending.map(entry => [entry.id, new Set(entry.ballots.map(ballot => ballot.voter))]) ?? [])
85
86 for (const proposal of next.hive?.pending ?? []) {
87 const seen = ballotsBefore.get(proposal.id) ?? new Set<string>()
88
89 for (const ballot of proposal.ballots) {
90 if (!seen.has(ballot.voter)) out.push(ev(atMs, 'swarm', `${ballot.voter} voted ${ballot.isFor ? 'for' : 'against'} ${proposal.type} (${proposal.id})`, ballot.voter))
91 }
92 }
93
94 const workersBefore = new Set(prev.hive?.workers ?? [])
95 const workersAfter = new Set(next.hive?.workers ?? [])
96
97 for (const worker of workersAfter) if (!workersBefore.has(worker)) out.push(ev(atMs, 'swarm', `${worker} joined the hive`, worker))
98 for (const worker of workersBefore) if (!workersAfter.has(worker)) out.push(ev(atMs, 'swarm', `${worker} left the hive`, worker))
99
100 const patterns = (next.neural?.patterns ?? 0) - (prev.neural?.patterns ?? 0)
101
102 if (prev.neural !== null && next.neural !== null && patterns > 0) out.push(ev(atMs, 'learning', `+${patterns} pattern${patterns === 1 ? '' : 's'} learned`))
103
104 const outcomes = (next.outcomes?.total ?? 0) - (prev.outcomes?.total ?? 0)
105
106 if (prev.outcomes !== null && outcomes > 0) out.push(ev(atMs, 'learning', `+${outcomes} routed outcome${outcomes === 1 ? '' : 's'} judged`))
107 if ((prev.federationNodes?.length ?? 0) !== (next.federationNodes?.length ?? 0)) out.push(ev(atMs, 'federation', `federation keys: ${next.federationNodes?.length ?? 0} node ids on disk`))
108 const missionsBefore = new Map((prev.missions?.missions ?? []).map(mission => [mission.id, mission]))
109
110 for (const mission of next.missions?.missions ?? []) {
111 const old = missionsBefore.get(mission.id)
112
113 if (old === undefined) out.push(ev(atMs, 'missions', `mission ${mission.id} (${mission.state}): ${mission.objective.slice(0, 60)}`))
114 else if (old.state !== mission.state) out.push(ev(atMs, 'missions', `mission ${mission.id}: ${old.state} → ${mission.state}`))
115 else if (old.evidence.verified !== mission.evidence.verified) out.push(ev(atMs, 'missions', `mission ${mission.id}: ${mission.evidence.verified}/${mission.evidence.count} evidence verified`))
116 }
117
118 if (prev.hasNostrKey !== next.hasNostrKey && next.hasNostrKey === true) out.push(ev(atMs, 'federation', 'a nostr identity appeared (~/.ruflo/nostr.key)'))
119
120 return out
121}
122
123/** Appends events, newest last, keeping at most MAX_EVENTS. */
124export function record(events: ConsoleEvent[], fresh: readonly ConsoleEvent[]): void {
125 if (fresh.length === 0) return
126
127 events.push(...fresh)
128
129 if (events.length > MAX_EVENTS) events.splice(0, events.length - MAX_EVENTS)
130}
131
132/** The agents an event touched in the last `windowMs`, newest event per agent: what the topology pulses for. */
133export function recentByAgent(events: readonly ConsoleEvent[], nowMs: number, windowMs: number): Map<string, number> {
134 const out = new Map<string, number>()
135
136 for (let i = events.length - 1; i >= 0; i--) {
137 const event = events[i] as ConsoleEvent
138
139 if (nowMs - event.atMs > windowMs) break
140 if (event.agentId !== undefined && !out.has(event.agentId)) out.set(event.agentId, event.atMs)
141 }
142
143 return out
144}
145hooks/data/parse.ts 499 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 */
8
9/** Text longer than this is not parsed: a store that size is not one the CLI wrote, and parsing it would stall a hook. */
10export const MAX_TEXT = 4_000_000
11/** At most this many records of one kind are kept; the rest are counted, not drawn. */
12export const MAX_RECORDS = 1_000
13
14const ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/
15
16// 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
17// would leave `[1m` or `]8;;https://…` in the text. Written as \u escapes so no invisible character sits in this source.
18export const ESCAPES = new RegExp('\\u001b\\][^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)|\\u009d[^\\u0007\\u009c]*[\\u0007\\u009c]|(?:\\u001b\\[|\\u009b)[0-9;?]*[ -/]*[@-~]', 'g')
19// Controls, DEL, C1, soft hyphen, combining grapheme joiner, Arabic letter mark, zero-width and bidi characters, invisible operators,
20// variation selectors, Hangul fillers and BOM: nothing a person could read, all of them fit for hiding or reordering text.
21export const HIDDEN = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180f\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2064\\u2066-\\u2069\\u3164\\ufe00-\\ufe0d\\ufeff\\uffa0]|[\\u{e0000}-\\u{e0fff}]', 'gu')
22
23/** 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. */
24export const INVISIBLE = new RegExp('[\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180f\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2064\\u2066-\\u2069\\u3164\\ufe00-\\ufe0f\\ufeff\\uffa0]|[\\u{e0000}-\\u{e0fff}]', 'gu')
25
26/** Plain printable text of at most `max` characters: no escape sequence, control, hidden or bidi-override character reaches the terminal. */
27export function plain(value: unknown, max = 200): string {
28 if (typeof value !== 'string') {
29 return ''
30 }
31
32 const cleaned = value.replace(ESCAPES, '').replace(INVISIBLE, '').replace(HIDDEN, ' ').replace(/\s+/g, ' ').trim()
33
34 return cleaned.length <= max ? cleaned : `${cleaned.slice(0, Math.max(0, max - 1))}…`
35}
36
37/** An id as ruflo mints them (`agent-…`, `swarm-…`, `proposal-…`), or null: only such a string ever reaches an argv. */
38export function idOf(value: unknown): string | null {
39 return typeof value === 'string' && ID.test(value) ? value : null
40}
41
42export const numberOf = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
43export const stringOf = (value: unknown, max = 80): string | undefined => (typeof value === 'string' && value !== '' ? plain(value, max) || undefined : undefined)
44export const recordOf = (value: unknown): Record<string, unknown> | null =>
45 value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
46
47/** JSON text to a plain object, or null for anything else (too long, malformed, an array, a scalar). */
48export function jsonObject(text: string | null | undefined): Record<string, unknown> | null {
49 if (typeof text !== 'string' || text.length > MAX_TEXT) {
50 return null
51 }
52
53 try {
54 return recordOf(JSON.parse(text))
55 } catch {
56 return null
57 }
58}
59
60export const valuesOf = (value: unknown): unknown[] => {
61 const record = recordOf(value)
62
63 return record === null ? [] : Object.values(record).slice(0, MAX_RECORDS)
64}
65
66/** An ISO time to epoch milliseconds, or undefined. */
67export const msOf = (value: unknown): number | undefined => {
68 if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
69 return value
70 }
71
72 const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN
73
74 return Number.isFinite(parsed) ? parsed : undefined
75}
76
77export type SwarmInfo = { id: string; topology: string; status: string; maxAgents?: number; strategy?: string; agentIds: string[]; updatedAt?: string }
78export type AgentRecord = { id: string; type: string; name?: string; status: string; health?: number; taskCount?: number; createdAtMs?: number }
79export type TaskRecord = {
80 id: string
81 type: string
82 description: string
83 status: string
84 assignedTo: string[]
85 createdAtMs?: number
86 /** `mission:<id>` / `task:<id>` style labels (plain words only), as `task_create` stored them. */
87 tags?: string[]
88 startedAtMs?: number
89 completedAtMs?: number
90 /** What `task_complete` / `task_update` recorded as the result, flattened to `key: value` text (bounded). */
91 resultText?: string
92}
93export type Claimant = { kind: 'agent' | 'human'; id: string; agentType?: string; name?: string }
94export type ClaimRecord = {
95 issueId: string
96 status: string
97 claimant: Claimant
98 progress?: number
99 handoffTo?: string
100 isStealable: boolean
101 claimedAtMs?: number
102 changedAtMs?: number
103 /** ruflo's claim type declares `expiresAt`, but no claims tool sets it today: absent means no TTL, not an expired one. */
104 expiresAtMs?: number
105 context?: string
106}
107/** One worker's vote on a proposal, as `votes` records it: the voter's id and whether it voted for. */
108export type Ballot = { voter: string; isFor: boolean }
109export type Proposal = {
110 id: string
111 type: string
112 status: string
113 strategy: string
114 votesFor: number
115 votesAgainst: number
116 /** Who voted which way, in the order the store lists them. */
117 ballots: Ballot[]
118 /** Voters the CLI excluded as Byzantine (bft proposals only). */
119 byzantine: string[]
120 value?: string
121 proposedBy?: string
122 proposedAtMs?: number
123 /** Raft: the term the proposal belongs to, and when it may be re-proposed in the next one. */
124 term?: number
125 timeoutAtMs?: number
126 /** Quorum: unanimous, majority or supermajority. */
127 quorumPreset?: string
128}
129export type Decision = { id: string; type: string; result: string; votesFor: number; votesAgainst: number; strategy?: string; term?: number; decidedAtMs?: number; byzantine: number }
130/** A message `hive-mind broadcast` left in the hive's shared memory. */
131export type Broadcast = { id: string; message: string; priority: string; from: string; atMs?: number }
132export type HiveInfo = {
133 topology: string
134 strategy?: string
135 queen?: string
136 queenTerm?: number
137 queenElectedAtMs?: number
138 workers: string[]
139 pending: Proposal[]
140 history: Decision[]
141 /** The newest broadcasts, oldest first, and every shared-memory key (values are not kept: they are anyone's JSON). */
142 broadcasts: Broadcast[]
143 memoryKeys: string[]
144 createdAtMs?: number
145 updatedAtMs?: number
146}
147/** A worker `hive-mind spawn` wrote to `.claude-flow/agents.json`, with the role it was given in the hive. */
148export type HiveAgentRecord = AgentRecord & { role?: string }
149
150/** `.claude-flow/swarm/swarm-state.json`: the running swarm, else the one updated last. */
151export function parseSwarmStore(text: string | null): SwarmInfo | null {
152 const swarms = valuesOf(jsonObject(text)?.swarms).flatMap(entry => {
153 const swarm = recordOf(entry)
154 const id = idOf(swarm?.swarmId)
155
156 if (swarm === null || id === null) {
157 return []
158 }
159
160 const config = recordOf(swarm.config)
161 const info: SwarmInfo = {
162 id,
163 topology: stringOf(swarm.topology, 40) ?? 'unknown',
164 status: stringOf(swarm.status, 40) ?? 'unknown',
165 agentIds: (Array.isArray(swarm.agents) ? swarm.agents : []).slice(0, MAX_RECORDS).flatMap(agent => {
166 const agentId = idOf(agent) ?? idOf(recordOf(agent)?.agentId) ?? idOf(recordOf(agent)?.id)
167
168 return agentId !== null ? [agentId] : []
169 }),
170 }
171 const maxAgents = numberOf(swarm.maxAgents)
172 const strategy = stringOf(config?.strategy, 40)
173 const updatedAt = stringOf(swarm.updatedAt, 40)
174
175 if (maxAgents !== undefined) info.maxAgents = maxAgents
176 if (strategy !== undefined) info.strategy = strategy
177 if (updatedAt !== undefined) info.updatedAt = updatedAt
178
179 return [info]
180 })
181 const byRecency = (a: SwarmInfo, b: SwarmInfo) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')
182
183 return [...swarms.filter(swarm => swarm.status === 'running')].sort(byRecency)[0] ?? [...swarms].sort(byRecency)[0] ?? null
184}
185
186/** `.swarm/state.json`: the pointer `swarm init` (or `swarm start`) leaves. */
187export function parseSwarmPointer(text: string | null): { id: string; topology?: string; strategy?: string; status?: string } | null {
188 const value = jsonObject(text)
189 const id = idOf(value?.id) ?? idOf(value?.swarmId)
190
191 if (value === null || id === null) {
192 return null
193 }
194
195 const pointer: { id: string; topology?: string; strategy?: string; status?: string } = { id }
196 const topology = stringOf(value.topology, 40)
197 const strategy = stringOf(value.strategy, 40)
198 const status = stringOf(value.status, 40)
199
200 if (topology !== undefined) pointer.topology = topology
201 if (strategy !== undefined) pointer.strategy = strategy
202 if (status !== undefined) pointer.status = status
203
204 return pointer
205}
206
207/** `.claude-flow/agents/store.json`. */
208export function parseAgents(text: string | null): AgentRecord[] {
209 return valuesOf(jsonObject(text)?.agents).flatMap(entry => {
210 const agent = recordOf(entry)
211 const id = idOf(agent?.agentId)
212
213 if (agent === null || id === null) {
214 return []
215 }
216
217 const record: AgentRecord = { id, type: stringOf(agent.agentType, 40) ?? 'agent', status: stringOf(agent.status, 20) ?? 'unknown' }
218 const name = stringOf(agent.name, 40)
219 const health = numberOf(agent.health)
220 const taskCount = numberOf(agent.taskCount)
221 const createdAtMs = msOf(agent.createdAt)
222
223 if (name !== undefined) record.name = name
224 if (health !== undefined) record.health = health
225 if (taskCount !== undefined) record.taskCount = taskCount
226 if (createdAtMs !== undefined) record.createdAtMs = createdAtMs
227
228 return [record]
229 })
230}
231
232/** `.claude-flow/tasks/store.json`. */
233/** A task's result object as one bounded line of `key: value` pairs (strings and numbers only), or undefined. */
234function resultTextOf(value: unknown): string | undefined {
235 const result = recordOf(value)
236
237 if (result === null) return typeof value === 'string' ? plain(value, 400) || undefined : undefined
238
239 const text = Object.entries(result)
240 .slice(0, 8)
241 .flatMap(([key, v]) => (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? [`${plain(key, 24)}: ${plain(String(v), 160)}`] : []))
242 .join(' · ')
243
244 return text === '' ? undefined : text.slice(0, 500)
245}
246
247export function parseTasks(text: string | null): TaskRecord[] {
248 return valuesOf(jsonObject(text)?.tasks).flatMap(entry => {
249 const task = recordOf(entry)
250 const id = idOf(task?.taskId)
251
252 return task === null || id === null
253 ? []
254 : [
255 {
256 id,
257 type: stringOf(task.type, 40) ?? 'task',
258 description: plain(task.description, 200),
259 status: stringOf(task.status, 20) ?? 'unknown',
260 assignedTo: (Array.isArray(task.assignedTo) ? task.assignedTo : []).slice(0, 50).flatMap(agent => (idOf(agent) !== null ? [agent as string] : [])),
261 ...(msOf(task.createdAt) !== undefined && { createdAtMs: msOf(task.createdAt) }),
262 tags: (Array.isArray(task.tags) ? task.tags : []).slice(0, 12).flatMap(tag => (typeof tag === 'string' && /^[A-Za-z0-9_.:-]{1,90}$/.test(tag) ? [tag] : [])),
263 ...(msOf(task.startedAt) !== undefined && { startedAtMs: msOf(task.startedAt) }),
264 ...(msOf(task.completedAt) !== undefined && { completedAtMs: msOf(task.completedAt) }),
265 ...(resultTextOf(task.result) !== undefined && { resultText: resultTextOf(task.result) }),
266 },
267 ]
268 })
269}
270
271function claimantOf(value: unknown): Claimant | null {
272 const claimant = recordOf(value)
273
274 if (claimant === null) {
275 return null
276 }
277
278 const isAgent = claimant.type === 'agent'
279 const id = idOf(isAgent ? claimant.agentId : claimant.userId)
280
281 if (id === null) {
282 return null
283 }
284
285 const who: Claimant = { kind: isAgent ? 'agent' : 'human', id }
286 const agentType = stringOf(claimant.agentType, 40)
287 const name = stringOf(claimant.name, 40)
288
289 if (agentType !== undefined) who.agentType = agentType
290 if (name !== undefined) who.name = name
291
292 return who
293}
294
295/** `.claude-flow/claims/claims.json`: issue claims (who works on what), not the authorization file `.claude-flow/claims.json`. */
296export function parseClaims(text: string | null): ClaimRecord[] {
297 const store = jsonObject(text)
298 const stealable = recordOf(store?.stealable) ?? {}
299
300 return valuesOf(store?.claims).flatMap(entry => {
301 const claim = recordOf(entry)
302 const issueId = idOf(claim?.issueId)
303 const claimant = claimantOf(claim?.claimant)
304
305 if (claim === null || issueId === null || claimant === null) {
306 return []
307 }
308
309 const handoff = recordOf(claim.handoffTo)
310 const handoffTo = idOf(handoff?.agentId ?? handoff?.userId)
311 const record: ClaimRecord = {
312 issueId,
313 status: stringOf(claim.status, 30) ?? 'unknown',
314 claimant,
315 isStealable: claim.status === 'stealable' || Object.hasOwn(stealable, issueId),
316 }
317 const progress = numberOf(claim.progress)
318 const claimedAtMs = msOf(claim.claimedAt)
319 const changedAtMs = msOf(claim.statusChangedAt)
320 const expiresAtMs = msOf(claim.expiresAt)
321 const context = stringOf(claim.context, 120)
322
323 if (progress !== undefined) record.progress = Math.max(0, Math.min(100, progress))
324 if (handoffTo !== null) record.handoffTo = handoffTo
325 if (claimedAtMs !== undefined) record.claimedAtMs = claimedAtMs
326 if (changedAtMs !== undefined) record.changedAtMs = changedAtMs
327 if (expiresAtMs !== undefined) record.expiresAtMs = expiresAtMs
328 if (context !== undefined) record.context = context
329
330 return [record]
331 })
332}
333
334const votesOf = (value: unknown): { votesFor: number; votesAgainst: number; ballots: Ballot[] } => {
335 const ballots = Object.entries(recordOf(value) ?? {})
336 .slice(0, MAX_RECORDS)
337 .flatMap(([voter, vote]) => (idOf(voter) !== null && typeof vote === 'boolean' ? [{ voter, isFor: vote }] : []))
338
339 return { votesFor: ballots.filter(ballot => ballot.isFor).length, votesAgainst: ballots.filter(ballot => !ballot.isFor).length, ballots }
340}
341
342const idsOf = (value: unknown, max = 50): string[] => (Array.isArray(value) ? value : []).slice(0, max).flatMap(entry => (idOf(entry) !== null ? [entry as string] : []))
343
344/** A proposal's value as one bounded line: a string as it is, anything else as its JSON. */
345function valueText(value: unknown): string | undefined {
346 if (typeof value === 'string') return stringOf(value, 120)
347 if (value === undefined || value === null) return undefined
348
349 try {
350 return stringOf(JSON.stringify(value).slice(0, 400), 120)
351 } catch {
352 return undefined
353 }
354}
355
356function proposalOf(entry: unknown): Proposal | null {
357 const proposal = recordOf(entry)
358 const id = idOf(proposal?.proposalId)
359
360 if (proposal === null || id === null) return null
361
362 const out: Proposal = {
363 id,
364 type: stringOf(proposal.type, 40) ?? 'proposal',
365 status: stringOf(proposal.status, 20) ?? 'pending',
366 strategy: stringOf(proposal.strategy, 20) ?? 'unknown',
367 ...votesOf(proposal.votes),
368 byzantine: idsOf(proposal.byzantineVoters),
369 }
370 const value = valueText(proposal.value)
371 const proposedBy = idOf(proposal.proposedBy)
372 const proposedAtMs = msOf(proposal.proposedAt)
373 const term = numberOf(proposal.term)
374 const timeoutAtMs = msOf(proposal.timeoutAt)
375 const quorumPreset = stringOf(proposal.quorumPreset, 20)
376
377 if (value !== undefined) out.value = value
378 if (proposedBy !== null) out.proposedBy = proposedBy
379 if (proposedAtMs !== undefined) out.proposedAtMs = proposedAtMs
380 if (term !== undefined) out.term = term
381 if (timeoutAtMs !== undefined) out.timeoutAtMs = timeoutAtMs
382 if (quorumPreset !== undefined) out.quorumPreset = quorumPreset
383
384 return out
385}
386
387function decisionOf(entry: unknown): Decision | null {
388 const decision = recordOf(entry)
389 const id = idOf(decision?.proposalId)
390 const votes = recordOf(decision?.votes)
391
392 if (decision === null || id === null) return null
393
394 const out: Decision = {
395 id,
396 type: stringOf(decision.type, 40) ?? 'proposal',
397 result: stringOf(decision.result, 20) ?? 'unknown',
398 votesFor: numberOf(votes?.for) ?? 0,
399 votesAgainst: numberOf(votes?.against) ?? 0,
400 byzantine: idsOf(decision.byzantineDetected).length,
401 }
402 const strategy = stringOf(decision.strategy, 20)
403 const term = numberOf(decision.term)
404 const decidedAtMs = msOf(decision.decidedAt)
405
406 if (strategy !== undefined) out.strategy = strategy
407 if (term !== undefined) out.term = term
408 if (decidedAtMs !== undefined) out.decidedAtMs = decidedAtMs
409
410 return out
411}
412
413/** `sharedMemory.broadcasts`, the last 20, each field bounded: the message is free text another process wrote. */
414function broadcastsOf(value: unknown): Broadcast[] {
415 return (Array.isArray(value) ? value : []).slice(-20).flatMap(entry => {
416 const message = recordOf(entry)
417 const id = idOf(message?.messageId)
418 const text = stringOf(message?.message, 160)
419
420 if (message === null || id === null || text === undefined) return []
421
422 const atMs = msOf(message.timestamp)
423
424 return [{ id, message: text, priority: stringOf(message.priority, 12) ?? 'normal', from: idOf(message.fromId) ?? 'system', ...(atMs !== undefined && { atMs }) }]
425 })
426}
427
428/** `.claude-flow/hive-mind/state.json`, without its capability token. */
429export function parseHive(text: string | null): HiveInfo | null {
430 const hive = jsonObject(text)
431
432 if (hive === null || hive.initialized !== true) {
433 return null
434 }
435
436 const queen = recordOf(hive.queen)
437 const queenId = idOf(queen?.agentId)
438 const consensus = recordOf(hive.consensus)
439 const shared = recordOf(hive.sharedMemory)
440 const info: HiveInfo = {
441 topology: stringOf(hive.topology, 40) ?? 'unknown',
442 workers: idsOf(hive.workers, MAX_RECORDS),
443 pending: (Array.isArray(consensus?.pending) ? consensus.pending : []).slice(-50).flatMap(entry => proposalOf(entry) ?? []),
444 history: (Array.isArray(consensus?.history) ? consensus.history : []).slice(-50).flatMap(entry => decisionOf(entry) ?? []),
445 broadcasts: broadcastsOf(shared?.broadcasts),
446 memoryKeys: Object.keys(shared ?? {}).slice(0, 200).flatMap(key => (idOf(key) !== null ? [key] : [])),
447 }
448 const strategy = stringOf(hive.consensusStrategy, 30)
449 const term = numberOf(queen?.term)
450 const electedAtMs = msOf(queen?.electedAt)
451 const createdAtMs = msOf(hive.createdAt)
452 const updatedAtMs = msOf(hive.updatedAt)
453
454 if (strategy !== undefined) info.strategy = strategy
455 if (queenId !== null) info.queen = queenId
456 if (term !== undefined) info.queenTerm = term
457 if (electedAtMs !== undefined) info.queenElectedAtMs = electedAtMs
458 if (createdAtMs !== undefined) info.createdAtMs = createdAtMs
459 if (updatedAtMs !== undefined) info.updatedAtMs = updatedAtMs
460
461 return info
462}
463
464/** `.claude-flow/agents.json`: the workers `hive-mind spawn` writes (the other agent tools use agents/store.json), with their hive role. */
465export function parseHiveAgents(text: string | null): HiveAgentRecord[] {
466 const roles = new Map(
467 valuesOf(jsonObject(text)?.agents).flatMap(entry => {
468 const agent = recordOf(entry)
469 const id = idOf(agent?.agentId)
470 const role = stringOf(recordOf(agent?.config)?.hiveRole, 20)
471
472 return id !== null && role !== undefined ? [[id, role] as const] : []
473 }),
474 )
475
476 return parseAgents(text).map(agent => {
477 const role = roles.get(agent.id)
478
479 return role === undefined ? agent : { ...agent, role }
480 })
481}
482
483/** The tail of an id a person can tell apart at a glance: `agent-1790954653916-od014i` → `od014i`. */
484export function shortId(id: string): string {
485 const tail = id.split(/[-_:]/).pop() ?? id
486
487 return tail.length >= 4 ? tail.slice(-6) : id.slice(-6)
488}
489
490/** One readable label per agent: its name, else its type, with a short id only where two would read the same. */
491export function agentLabels(agents: readonly { id: string; name?: string; type: string }[]): Map<string, string> {
492 const base = (agent: { name?: string; type: string }) => agent.name ?? agent.type
493 const counts = new Map<string, number>()
494
495 for (const agent of agents) counts.set(base(agent), (counts.get(base(agent)) ?? 0) + 1)
496
497 return new Map(agents.map(agent => [agent.id, (counts.get(base(agent)) ?? 0) > 1 ? `${base(agent)}·${shortId(agent.id).slice(-4)}` : base(agent)]))
498}
499hooks/dispatch.ts 201 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 { bandReply, noticesReply, quietReply, median, p95 } from './notices'
22
23/** The engine's words when a registered command reaches it with no hook answering (Claude Code 2.1.287). */
24const NO_HOOK_ANSWERED = /registered \/ruflo but no command\.run hook answered/
25
26export type Delegate = () => Promise<{ text?: string } | undefined>
27
28/** The one-line answer of `/ruflo status`, with the measured render, refresh and frame costs. */
29export function statusLine(state: State): string {
30 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})`)
31
32 return [barText(state), stat('render', state.stats.renders), stat('refresh', state.stats.refreshes), stat('frame', state.stats.frames)].join(' · ')
33}
34
35const OWNER_HINT = {
36 '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.',
37 '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.',
38} as const
39
40async function open(control: Controller, state: State, label: string): Promise<{ text: string }> {
41 const opened = await control.open()
42
43 return { text: opened.isPlaced ? `ruflo console: ${label}` : `The ruflo console could not be shown: ${opened.reason}` }
44}
45
46const DUMP_WAIT_MS = 8_000
47
48/** The catalog this plugin ships (read once per session), or the built-in mod list when it is missing or another contract. */
49async function loadCatalog(control: Controller): Promise<Catalog> {
50 control.catalog ??= control.host.fs
51 .read(`${control.host.pluginRoot}/${CATALOG_PATH}`)
52 .then(text => parseCatalog(text) ?? FALLBACK, () => FALLBACK)
53
54 return control.catalog
55}
56
57/**
58 * A view as text, with its CLI probes run first and waited for (at most DUMP_WAIT_MS: a probe still running then reads
59 * "asking the ruflo CLI…", as it would on screen). The pane's own view is put back afterwards.
60 */
61async function dumpOf(control: Controller, state: State, view: State['view']): Promise<string> {
62 const shown = state.view
63
64 state.view = view
65
66 try {
67 await control.refresh()
68 await Promise.race([control.probe(true), new Promise(resolve => setTimeout(resolve, DUMP_WAIT_MS))])
69 // Self-Evolution draws from its own file read, which opening the view starts: a dump waits for it too.
70 if (view === 'evolve') await loadEvolve(state, control.host)
71 if (view === 'workflows') await refreshWorkflows(state, control.host, true)
72
73 return viewText({ state, nowMs: Date.now(), columns: 100, act: control.actions }, view)
74 } finally {
75 state.view = shown
76 }
77}
78
79export async function dispatch(control: Controller, state: State, args: string, delegate: Delegate): Promise<{ text: string }> {
80 const intent: Intent = parseRuflo(args)
81
82 switch (intent.kind) {
83 case 'open':
84 // Without a pane to show (claude -p, an SDK host), the view is answered as text in the command's row instead.
85 if (!state.isInteractive) return { text: await dumpOf(control, state, intent.view ?? state.view) }
86 // The BBS look lands on its main menu when the cockpit opens with no view named, like a board after login.
87 if (intent.view !== null) control.setView(intent.view)
88 else if (!state.pane.isOpen && state.options.look === 'bbs') control.setView('menu')
89
90 return open(control, state, VIEWS.find(view => view.id === state.view)?.label ?? 'Agent')
91 case 'help':
92 return { text: HELP }
93 case 'close':
94 await control.close()
95
96 return { text: 'ruflo console closed (/ruflo opens it again)' }
97 case 'status':
98 await control.refresh()
99
100 return { text: statusLine(state) }
101 case 'delegate': {
102 try {
103 const answer = await delegate()
104
105 // With nothing beneath, the engine answers in its own words that no hook answered: that is no answer either.
106 if (typeof answer?.text === 'string' && answer.text.trim() !== '' && !NO_HOOK_ANSWERED.test(answer.text)) return { text: answer.text }
107 } catch {
108 // Nothing beneath answers this command: fall through to the hint.
109 }
110
111 return { text: OWNER_HINT[intent.owner] }
112 }
113 case 'palette':
114 state.palette = { isOpen: true, query: plain(intent.query, 200), index: 0, context: 'all' }
115
116 return open(control, state, 'palette')
117 case 'run': {
118 // A headless budget ask checks the installed CLI's help before building a setter spec.
119 if (intent.paletteId === 'cost-budget' || intent.paletteId.startsWith('cost-budget-')) await dumpOf(control, state, 'cost')
120 const askedAtMs = Date.now()
121 const isRun = control.actions.run(intent.paletteId, intent.text)
122
123 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.` }
124
125 await control.open()
126 // A lab read answers with what it printed, so `/ruflo run mh-genome` works headless.
127 if (state.pending === null) await control.runner.settled()
128
129 if (state.pending !== null) {
130 const pending = state.pending
131
132 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') }
133 }
134
135 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')) }
136 }
137 case 'confirm':
138 if (state.pending === null) return { text: 'Nothing is waiting for a confirm.' }
139
140 if (!intent.isYes) {
141 control.runner.cancel()
142
143 return { text: 'Cancelled.' }
144 }
145
146 const confirmedAtMs = Date.now()
147
148 await control.runner.confirm()
149
150 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)' : ''}`) }
151 case 'agent': {
152 const who = intent.who.toLowerCase()
153 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))
154
155 if (agent === undefined) return { text: `No agent "${plain(intent.who, 40)}" in .claude-flow/agents/store.json.` }
156
157 control.drill(agent.id)
158
159 return open(control, state, `agent ${agent.name ?? agent.type}`)
160 }
161 case 'back':
162 control.actions.back()
163
164 return open(control, state, VIEWS.find(view => view.id === state.view)?.label ?? 'back')
165 case 'select':
166 control.actions.select(intent.by)
167
168 return { text: `selection moved (${intent.by > 0 ? 'next' : 'prev'}) on ${state.view}` }
169 case 'filter':
170 state.eventFilter = intent.filter
171 control.setView('events')
172
173 return open(control, state, `events · ${intent.filter}`)
174 case 'dump': {
175 const view = intent.view ?? state.view
176 const shown = state.view
177
178 return { text: await dumpOf(control, state, view) }
179 }
180 case 'band':
181 control.host.invalidate()
182
183 return { text: bandReply(state, intent.arg) }
184 case 'notices':
185 return { text: noticesReply(state, Date.now(), intent.isClear) }
186 case 'quiet':
187 control.host.invalidate()
188
189 return { text: quietReply(state, Date.now(), intent.arg) }
190 case 'autopilot': {
191 const apHost = hostOf(state)
192
193 return { text: apHost === undefined ? 'autopilot is not wired into this console yet' : await autopilotCommand(state, apHost, intent.arg) }
194 }
195 case 'commands':
196 return { text: commandsText(await loadCatalog(control), intent.query) }
197 case 'unknown':
198 return { text: `Unknown: "${plain(intent.word, 30)}". /ruflo help lists the views (${VIEWS.map(view => view.id).join(', ')}) and commands.` }
199 }
200}
201hooks/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 73 lines1import type { CommandSpec, HookStream, PaneOpenArgs, ProcessRunResult, ProcessSpawnChunk, ProcessSpawnResult, Timer, UiBlitArgs } from 'claude-code'
2
3import type { ReaderFs } from './data/files'
4import type { RufloRoute, RufloSnapshot } from '../types'
5
6/** What `$.ui.open` answers: drawn, or held back with the reason. A build that answers nothing has drawn it. */
7export type OpenResult = { isPlaced: boolean; reason?: string } | void
8
9/**
10 * The engine as `session.start` bound it. Every later hook, timer and button reaches the engine through this, so the
11 * controller is plain functions over an interface a test can stand in for. Any member may be refused (an administrator
12 * removed the affordance, a policy mod said no): every caller catches, and a refusal is a missing fact, never a crash.
13 */
14export type Host = {
15 fs: ReaderFs
16 every: (ms: number, fn: () => void) => Timer
17 after: (ms: number, fn: () => void) => Timer
18 storeGet: (key: string) => Promise<unknown>
19 storeSet: (key: string, value: unknown) => Promise<void>
20 /** A GET through the host (never the plugin's own network; an administrator's policy may refuse it): the status and the body text. */
21 fetchText: (url: string) => Promise<{ ok: boolean; status: number; text: string }>
22 /** The engine's own choice dialog: the label chosen. Rejects when dismissed, and when nobody can be asked (a -p run). */
23 askChoice: (question: string, options: readonly string[]) => Promise<string>
24 /** A short note over the transcript's corner; it leaves the transcript and the model untouched. */
25 toast: (text: string, timeoutMs?: number) => void
26 invalidate: () => void
27 /** 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. */
28 scrollTop: () => void
29 /** Moves a pane's focus ring onto an element it drew (a field), while the pane holds the keys. */
30 focus: (paneId: string, key: string) => Promise<unknown>
31 /** Fire and forget: a blit resolves only once painted, and blits between frames fold anyway. */
32 blit: (args: UiBlitArgs) => void
33 openPane: (pane: PaneOpenArgs) => Promise<OpenResult>
34 closePane: (id: string) => Promise<void>
35 panes: () => Promise<readonly { id: string; isShown: boolean; isFocused: boolean }[]>
36 registerCommand: (spec: CommandSpec) => Promise<unknown>
37 run: (argv: readonly string[], timeoutMs: number, stdin?: string) => Promise<ProcessRunResult>
38 /** Starts a command and streams what it writes; `input` goes to its stdin, which is then closed. */
39 spawn: (argv: readonly string[], input?: string) => HookStream<ProcessSpawnChunk, ProcessSpawnResult>
40 usage: () => Promise<{ costUsd?: number; contextPercent?: number }>
41 /** The ruflo / claude-flow MCP tools the model can call now, and the servers they come from. */
42 rufloTools: () => Promise<{ tools: number; servers: string[] }>
43 settings: () => Promise<unknown>
44 home: () => Promise<string | undefined>
45 configDir: () => Promise<string | undefined>
46 /** This plugin's folder: where its own files (the command catalog) are. */
47 pluginRoot: string
48 rufloSnapshot: () => Promise<RufloSnapshot>
49 rufloRoute: () => Promise<RufloRoute | null>
50 rufloSegment: (text: string | null) => Promise<void>
51 /** Submits a prompt to the primary Claude session as a visible turn of its own (once idle). */
52 submitPrompt: (text: string) => Promise<void>
53 /** Puts text in the prompt box as the draft (the person presses Enter); false where there is no box. */
54 fillPrompt: (text: string) => Promise<boolean>
55 /** The names of the slash commands the session offers now (built-in, plugin and MCP alike). */
56 listCommands: () => Promise<string[]>
57 /** Runs a slash command as if typed (built-in, plugin or MCP); queued until the session is idle. */
58 runSlash: (command: string, args: string) => Promise<{ text?: string } | void>
59 /**
60 * 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
61 * (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.
62 * Optional: a build that does not bind it makes the control actions fall back to a prompt-box prefill (ADR-465).
63 */
64 toolCall?: (input: { tool: string } & Record<string, unknown>) => Promise<ToolReply>
65 /** The engine's permission verdict for a tool call now, with nothing run and no dialog ($.tool.check). */
66 toolCheck?: (tool: string, input: unknown) => Promise<{ decision: 'allow' | 'ask' | 'deny'; reason?: string }>
67 /** 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. */
68 httpSend?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; status: number; text: string }>
69}
70
71/** What a tool call answered: the refusal, or the output (text and structure) and whether it was an error. */
72export type ToolReply = { deny?: string; text?: string; result?: unknown; isError?: boolean }
73hooks/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 449 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 { emptyMemoryLab, type MemoryLabState } from './memory-lab'
14import { emptyVector, type VectorState } from './data/vector'
15import type { Snapshot } from './data/snapshot'
16import type { RufloRoute, RufloSnapshot } from '../types'
17import type { Notice } from './notices'
18import { emptyWf, type WfState } from './wf-state'
19
20export const PLUGIN_NAME = 'ruflo-console'
21export const PANE_ID = 'ruflo-console'
22
23/** 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. */
24export type NavStyle = 'auto' | 'icons' | 'brief' | 'full'
25export const NAV_STYLES: readonly NavStyle[] = ['auto', 'icons', 'brief', 'full']
26export const NAV_KEY = 'nav-style'
27
28export 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'
29
30/**
31 * The views in tab order, each with its hotkey and the inline height it asks for. Digits are the first nine; the three
32 * management views take letters no other control uses. `agent` is the drill-down, reached from a selection, not a tab.
33 */
34/**
35 * `icon` is an emoji with default emoji presentation (no variation selector, so it renders as one 2-cell glyph
36 * everywhere), shown in the tab bar; the
37 * current tab adds its label, and `blurb` is the one line under the bar that says what the view is for.
38 */
39export const VIEWS: readonly { id: ViewId; key: string; label: string; short: string; icon: string; blurb: string; rows: number }[] = [
40 // 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
41 // 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.
42 { 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 },
43 { 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 },
44 { id: 'overview', key: '2', label: 'Overview', short: 'Ovr', icon: '🏠', blurb: 'what ruflo is doing here: subsystems, mods, health alerts and live activity', rows: 26 },
45 { 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 },
46 { 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 },
47 { 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 },
48 { 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 },
49 { 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 },
50 { id: 'plugins', key: '6', label: 'Plugins', short: 'Plg', icon: '🧩', blurb: 'ruflo plugins: installed, enabled, in the marketplace clone, and loaded as mods', rows: 30 },
51 { id: 'learning', key: '7', label: 'Learning', short: 'Lrn', icon: '🧠', blurb: 'router picks and outcomes, and the RETRIEVE → JUDGE → DISTILL → CONSOLIDATE pipeline', rows: 30 },
52 { 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 },
53 { 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 },
54 { 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 },
55 { 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 },
56 { id: 'approvals', key: 'q', label: 'Approvals', short: 'Apv', icon: '✅', blurb: 'decisions waiting for a person: votes, stealable claims, refused mods, budget', rows: 24 },
57 { 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 },
58 { 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 },
59 { 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 },
60 { 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 },
61 { 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 },
62 { 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 },
63 { id: 'perf', key: 'f', label: 'Performance', short: 'Prf', icon: '📈', blurb: 'metrics, profile, benchmarks, bottlenecks and a latency sparkline from each run', rows: 30 },
64 { 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 },
65 { 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 },
66 { 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 },
67 { 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 },
68 { 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 },
69 { id: 'sandbox', key: '', label: 'Sandbox', short: 'Sbx', icon: '🧫', blurb: 'isolated places to try things: tmux sessions, RVF copy-on-write branches, RVM', rows: 40 },
70 { 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 },
71 { 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 },
72]
73
74export const AGENT_VIEW = { id: 'agent' as const, rows: 28 }
75
76export const rowsOf = (view: ViewId): number => (view === 'agent' ? AGENT_VIEW.rows : (VIEWS.find(entry => entry.id === view)?.rows ?? 24))
77
78/** A view by id, digit, label, or a prefix of three letters or more. */
79export const viewOf = (word: string): ViewId | null => {
80 const lower = word.trim().toLowerCase()
81
82 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
83}
84
85/**
86 * `$.store` is the plugin's, not the folder's: the key carries the working directory, so a view chosen in one project
87 * never follows the person into another (the ruflo-swarm leak).
88 */
89export const storeKeyOf = (cwd: string): string => `ruflo-console/ui:${cwd}`
90
91/** How actions reach the ruflo CLI: each a fixed argv prefix. Only `npx` may download. */
92export const CLI_PREFIXES = {
93 'npx-offline': ['npx', '--offline', '-y', '@claude-flow/cli@latest'],
94 npx: ['npx', '-y', '@claude-flow/cli@latest'],
95 ruflo: ['ruflo'],
96 'claude-flow': ['claude-flow'],
97} as const satisfies Record<string, readonly string[]>
98
99export type CliChoice = keyof typeof CLI_PREFIXES
100
101export type Options = GuardOptions & ConvoOptions & {
102 cli: CliChoice
103 /** How often the disk is re-read while the pane or band shows (seconds, 2-60). */
104 refreshSeconds: number
105 /** The animation's frame cap while the pane is shown and focused (0 turns motion off; at most 12). */
106 fps: number
107 /** `auto`: the band shows in a ruflo project; `on`: always; `off`: never. */
108 bar: 'auto' | 'on' | 'off'
109 /** `auto` opens the cockpit at session start where it can dock (never taking the keys); `command` only on /ruflo; `off` never. */
110 panel: 'auto' | 'command' | 'off'
111 /** Lets the federation view ask the public relay for the roster. Off by default: no network without consent. */
112 federationNetwork: boolean
113 /** `bbs`: the neon ASCII-art look (default); `plain`: the terminal theme's own colours and plain rules. */
114 look: 'bbs' | 'plain'
115 /** With the bbs look, a short dial-up boot screen when the cockpit opens. */
116 boot: boolean
117}
118
119const num = (value: unknown, fallback: number, lo: number, hi: number): number => {
120 const n = typeof value === 'number' ? value : Number(value)
121
122 return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : fallback
123}
124
125/** The options as the settings hold them, each one checked: a value the plugin does not know is its default. */
126export function optionsOf(raw: PluginOptions | undefined): Options {
127 const value = (raw ?? {}) as Record<string, unknown>
128
129 return {
130 cli: typeof value.cli === 'string' && value.cli in CLI_PREFIXES ? (value.cli as CliChoice) : 'npx-offline',
131 refreshSeconds: num(value.refreshSeconds, 3, 2, 60),
132 fps: num(value.fps, 8, 0, 12),
133 bar: value.bar === 'on' || value.bar === 'off' ? value.bar : 'auto',
134 panel: value.panel === 'command' || value.panel === 'off' ? value.panel : 'auto',
135 federationNetwork: value.federationNetwork === true,
136 look: value.look === 'plain' ? 'plain' : 'bbs',
137 boot: value.boot !== false,
138 ...guardOptionsOf(raw),
139 ...convoOptionsOf(raw),
140 }
141}
142
143/** A mutating action waiting for the person's second press; `shows` is the command line when it is not a ruflo one. */
144export type Pending = { 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' }
145
146/** The MetaHarness lab's last run: what it was, how it exited, its cost note, and its output as lines to scroll. */
147export type LabResult = { id: string; label: string; ok: boolean; exitCode: number | null; note?: string; lines: string[]; atMs: number }
148
149/** The harnesses the terminal view can ask; `swarm` asks codex and claude at once. */
150export type HarnessId = 'codex' | 'claude' | 'ruflo' | 'swarm'
151/** What actually runs: a swarm is a codex run and a claude run side by side. */
152export type AgentId = Exclude<HarnessId, 'swarm'>
153
154/**
155 * One line of the terminal's scrollback: what was asked (`in`), an agent starting its answer (`head`), what came back,
156 * a tool it used (`tool`), how its turn ended (`end`), or
157 * the console's own note (`sys`); `from` names the agent when more than one is talking.
158 */
159export type TermLine = { kind: 'in' | 'head' | 'out' | 'err' | 'sys' | 'tool' | 'end'; text: string; from?: AgentId }
160
161/** A conversation kept per project: codex's thread id, claude's session id, so a follow-up resumes it. */
162export type TermSessions = { codex?: string; claude?: string }
163
164export const termStoreKeyOf = (cwd: string): string => `ruflo-console/term:${cwd}`
165
166/** What an action did: what ran, how it exited, whether the disk shows the change, and anything it printed to show. */
167export type Outcome = { label: string; ok: boolean; verified: 'yes' | 'no' | 'n/a'; detail: string; atMs: number; lines?: string[] }
168
169/** One module seen registering since the console loaded, as the engine's scan named it. */
170export type ModSeen = { name: string; provenance: string; isLoaded: boolean; reason?: string; atMs: number }
171
172/** A tool call refused by a permission verdict this session, as `tool.check` answered it. */
173export type Denied = { tool: string; reason: string; atMs: number }
174
175/** One sample of a measured series, with when it was taken. */
176export type Sample = { atMs: number; value: number }
177
178/** One thing Claude did with the console's tools (ADR-444): what, and how it came out. */
179export type ControlEntry = { atMs: number; tool: string; summary: string; outcome: 'ok' | 'waiting' | 'denied' | 'error'; detail: string }
180
181export type State = {
182 options: Options
183 cwd: string
184 home: string | null
185 /** Session evidence from a confirmed JOIN, kept without background key access. */
186 nostrKeyVerifiedAtMs: number | null
187 /** Claude Code's config directory: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. Its plugin records are read from here. */
188 configDir: string | null
189 /** False in a session with no pane to show (claude -p, an SDK host): views are then answered as text. */
190 isInteractive: boolean
191 /** When this module loaded: "since the console loaded" series and stall times count from here. */
192 loadedAtMs: number
193 view: ViewId
194 /** The view to go back to from the drill-down. */
195 back: ViewId
196 isHelp: boolean
197 /** ruHelp: the question typed, and the guide open (null: the index). */
198 help: { query: string; topic: string | null }
199 snapshot: Snapshot | null
200 cache: ReadCache
201 probes: Map<string, ProbeResult>
202 ruflo: { snapshot: RufloSnapshot | null; route: RufloRoute | null; error: string | null }
203 usage: { costUsd?: number; contextPercent?: number } | null
204 /** The custom budget field, kept across redraws. */
205 costBudgetDraft: string
206 rufloTools: { tools: number; servers: string[] } | null
207 mods: ModSeen[]
208 denied: Denied[]
209 /** Tool calls the console saw, per 5 s bucket, newest last; and per agent (Claude Code's ids) for the timeline. */
210 activity: number[]
211 toolsByAgent: Map<string, { atMs: number; tool: string }[]>
212 /** ruflo state files changed per refresh, newest last. */
213 writes: number[]
214 /** Measured series since the console loaded: patterns learned, session spend. */
215 history: { patterns: Sample[]; spend: Sample[]; outcomes: number }
216 events: ConsoleEvent[]
217 /** Each ruflo agent's status as the console saw it change, oldest first: the timeline's spans. */
218 statusLog: Map<string, { atMs: number; status: string }[]>
219 eventFilter: 'all' | ConsoleEvent['kind']
220 /** When the newest learning point arrived: the curve draws it in from there. */
221 curveGrewAtMs: number
222 /** Kinds of action the person said never to ask about again, with a sample label (saved; Settings forgets them). */
223 allowed: Map<string, string>
224 /** The main nav's style, saved across sessions. */
225 nav: NavStyle
226 /** 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. */
227 updates: UpdatesMode
228 /** What the last update check found, in a line, for Settings; empty until one has run. */
229 updateNote: string
230 /** A published version the person has not taken ("Not now"), shown on the band as a link to Settings; empty when there is none. */
231 updateAvailable: string
232 /** The nav group whose pages are showing, picked on this page (it follows the open page again once the page changes). */
233 navPick: { group: string; view: ViewId } | null
234 /** The nav search words (empty: no search). */
235 navQuery: string
236 /** The slash command names the session offered when last asked (for the mission skills). */
237 commandNames: string[]
238 /** True while the primary Claude session is running a turn (the band reports it each draw). */
239 turnActive: boolean
240 /** Notices the band announced (notices.ts): the newest 30, a running id, and a time before which the notice row stays quiet. */
241 notices: Notice[]
242 noticeSeq: number
243 noticesQuietUntilMs: number
244 /** `/ruflo band`: the band's mode for this session over the plugin option (null = the option), and whether it shows one row. */
245 bandMode: 'auto' | 'on' | 'off' | null
246 bandCompact: boolean
247 /** When the person-facing turn began (the band shows how long Claude has been working), null between turns. */
248 turnStartedMs: number | null
249 /** Collapsible sections the person flipped from their default (`<view>/<id>`): open ones closed, closed ones open. */
250 sections: Set<string>
251 /** What one-shot entry fields hold while typed (cleared on Enter), by field key. */
252 fieldText: Map<string, string>
253 /** The dock width asked for (RUFLO_CONSOLE_COLUMNS, 40 to 400); 0 leaves the engine's share. A request: a dragged width wins. */
254 dockColumns: number
255 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 }
256 /** The size of each Raster as last mounted, by key: a blit of any other size is refused, so none is sent. */
257 mounted: Map<string, { columns: number; rows: number }>
258 select: { claim: number; agent: number; task: number; item: number }
259 /** The drill-down's agent and what `agent logs` printed for it. */
260 drill: { agentId: string | null; logs: string[] | null; logsAtMs: number }
261 palette: { isOpen: boolean; query: string; index: number; context: 'all' | 'selection' }
262 pending: Pending | null
263 /** 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). */
264 lastPressed: string | null
265 origin: string | null
266 outcome: Outcome | null
267 isActing: boolean
268 /** The MetaHarness lab: its last result, and the run in flight (j/k scroll the result through `select.item`). */
269 lab: { result: LabResult | null; running: { id: string; label: string; startedAtMs: number } | null }
270 /**
271 * The x.ruv.io board: its own result panel (j/k scroll it too), this node's Nostr pubkey once a result named it
272 * (the key file is never read), and whether RUFLO_X_ADMIN_TOKEN is set (only that boolean is kept; null: not asked).
273 */
274 xruv: { result: LabResult | null; running: { id: string; label: string; startedAtMs: number } | null; pubkey: string | null; hasAdminToken: boolean | null }
275 isRefreshing: boolean
276 /** When the band above the prompt last drew: the disk is re-read on the fast cadence only while it is seen. */
277 barDrawnAtMs: number
278 /** The terminal view: the harness picked, the field's text, the scrollback, and the runs in flight. */
279 terminal: {
280 harness: HarnessId
281 draft: string
282 lines: TermLine[]
283 /** One run per agent at most; codex and claude may run at the same time. */
284 runs: Map<AgentId, { label: string; startedAtMs: number; stop: () => void }>
285 /** The conversations to resume, and which of them the person has said yes to in this Claude Code session. */
286 sessions: TermSessions
287 isLive: { codex: boolean; claude: boolean }
288 /** Turns and spend this session, as the agents reported them. */
289 turns: { codex: number; claude: number }
290 costUsd: number
291 /** How many terminal results actually reported dollars, including measured zero. */
292 costReports: number
293 /** Screen rows scrolled up from the newest (0 follows the tail), and how many lines arrived while scrolled up. */
294 scroll: number
295 unseen: number
296 /** The text the last Enter asked about: Enter on the same text again confirms it. */
297 asked: { key: string; label: string } | null
298 }
299 /** The skills view: installed skills, the last search, and the change running now. */
300 skills: SkillsState
301 /** The Memory Lab's fields and picks (its last run is `lab.result`, under a mem- id). */
302 memoryLab: MemoryLabState
303 /** The Automation and Learning Lab views: the lists a click asked for, and this session's training runs. */
304 auto: AutoState
305 /** The Vector Lab's fields; its runs land in `lab` under vec- ids. */
306 vector: VectorState
307 /** The Self-Evolution view: the flywheel files as last read, and what its checks answered. */
308 evolve: EvolveState
309 /** The Dev Tools view: what is typed in its fields (its runs share the lab result panel, ids dt-*). */
310 devtools: { fields: DevFields; /** Whether tmux is on this machine, from a probe when the Sandbox page opens. */ tmux: 'unknown' | 'present' | 'missing' }
311 timers: Map<string, Timer>
312 stats: { renders: number[]; refreshes: number[]; frames: number[] }
313 /** The Workflows page: the last read of Claude Code's run folders, the cursor, the inspector tab (wf-state.ts). */
314 wf: WfState
315 /** Claude's control of the console (ADR-444): paused by the person, the call counts, and the log the dashboard shows. */
316 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; /** 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 }
317}
318
319export function newState(raw: PluginOptions | undefined): State {
320 return {
321 options: optionsOf(raw),
322 cwd: '',
323 home: null,
324 nostrKeyVerifiedAtMs: null,
325 configDir: null,
326 isInteractive: true,
327 loadedAtMs: Date.now(),
328 view: 'overview',
329 back: 'overview',
330 isHelp: false,
331 help: { query: '', topic: null },
332 snapshot: null,
333 cache: new Map(),
334 probes: new Map(),
335 ruflo: { snapshot: null, route: null, error: null },
336 usage: null,
337 costBudgetDraft: '',
338 rufloTools: null,
339 mods: [],
340 denied: [],
341 activity: [],
342 toolsByAgent: new Map(),
343 writes: [],
344 history: { patterns: [], spend: [], outcomes: 0 },
345 events: [],
346 statusLog: new Map(),
347 eventFilter: 'all',
348 curveGrewAtMs: 0,
349 dockColumns: 0,
350 nav: 'auto',
351 updates: 'ask',
352 updateNote: '',
353 updateAvailable: '',
354 navPick: null,
355 navQuery: '',
356 turnActive: false,
357 notices: [],
358 noticeSeq: 0,
359 noticesQuietUntilMs: 0,
360 bandMode: null,
361 bandCompact: false,
362 turnStartedMs: null,
363 commandNames: [],
364 allowed: new Map(),
365 sections: new Set(),
366 fieldText: new Map(),
367 pane: { isOpen: false, isShown: false, isFocused: false, columns: 0, rows: 0, placement: 'inline', isClosedByPerson: false, autoTried: false, autoReason: '', bootAtMs: 0, menuAtMs: 0, viewAtMs: 0 },
368 mounted: new Map(),
369 select: { claim: 0, agent: 0, task: 0, item: 0 },
370 drill: { agentId: null, logs: null, logsAtMs: 0 },
371 palette: { isOpen: false, query: '', index: 0, context: 'all' },
372 pending: null,
373 lastPressed: null,
374 origin: null,
375 outcome: null,
376 isActing: false,
377 lab: { result: null, running: null },
378 xruv: { result: null, running: null, pubkey: null, hasAdminToken: null },
379 isRefreshing: false,
380 barDrawnAtMs: 0,
381 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 },
382 skills: emptySkills(),
383 memoryLab: emptyMemoryLab(),
384 auto: emptyAuto(),
385 vector: emptyVector(),
386 evolve: emptyEvolve(),
387 devtools: { fields: emptyFields(), tmux: 'unknown' },
388 timers: new Map(),
389 stats: { renders: [], refreshes: [], frames: [] },
390 wf: emptyWf(),
391 control: { paused: false, calls: 0, turnCalls: 0, used: {}, log: [], drivingUntilMs: 0, viaModel: false },
392 }
393}
394
395/** Keeps the newest `max` samples of a series. */
396export function push<T>(series: T[], value: T, max = 120): void {
397 series.push(value)
398
399 if (series.length > max) {
400 series.splice(0, series.length - max)
401 }
402}
403
404/** What is written to `$.store`: the person's choices only. */
405export type Persisted = { view: ViewId; isClosedByPerson: boolean }
406
407export function restore(state: State, value: unknown): void {
408 const held = value !== null && typeof value === 'object' ? (value as Partial<Persisted>) : {}
409
410 if (typeof held.view === 'string' && VIEWS.some(view => view.id === held.view)) {
411 state.view = held.view
412 }
413
414 state.pane.isClosedByPerson = held.isClosedByPerson === true
415}
416
417const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/
418
419/** The terminal's saved conversations: only id-shaped strings come back, since each one becomes an argv element. */
420export function restoreSessions(state: State, value: unknown): void {
421 const held = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
422
423 for (const agent of ['codex', 'claude'] as const) {
424 const id = held[agent]
425
426 if (typeof id === 'string' && SESSION_ID.test(id)) state.terminal.sessions[agent] = id
427 }
428}
429
430export const isSessionId = (id: string): boolean => SESSION_ID.test(id)
431
432/** The BBS boot screen's span: at least BOOT_MIN_MS, longer while the first read is still out, never past BOOT_MAX_MS. */
433export const BOOT_MIN_MS = 5_400
434export const BOOT_MAX_MS = 8_000
435
436export function isBooting(state: State, nowMs: number): boolean {
437 if (state.options.look !== 'bbs' || !state.options.boot || state.pane.bootAtMs === 0) return false
438
439 const age = nowMs - state.pane.bootAtMs
440
441 return age >= 0 && age < BOOT_MAX_MS && (age < BOOT_MIN_MS || state.snapshot === null)
442}
443
444/**
445 * Compact: an inline pane the layout could not make as tall as the view asks. It drops the banner and moves the
446 * controls up so they stay on screen. A docked pane scrolls, so it always gets the full frame, banner and title.
447 */
448export const isCompactPane = (state: State): boolean => state.pane.placement === 'inline' && state.pane.rows > 0 && state.pane.rows < rowsOf(state.view)
449hooks/views/bar.ts 289 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, approvalsOf } 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
96export function barParts(state: State, nowMs: number = Date.now()): BarPart[] {
97 const snap = state.snapshot
98 const parts: BarPart[] = []
99
100 // What needs a person: approvals waiting and warn/bad alerts. Info alerts (a claim held for days) stay in the pane.
101 const approvals = approvalsOf(state).length
102 const alerts = alertsOf(state, nowMs, state.loadedAtMs).filter(alert => alert.level !== 'info').length
103
104 if (approvals > 0) parts.push({ text: `${approvals} to approve (q)`, tone: 'attention', go: 'approvals' })
105 if (alerts > 0) parts.push({ text: `⚠ ${alerts} alert${alerts === 1 ? '' : 's'}`, tone: 'attention', go: 'overview' })
106
107 // The active mission: how far along, and the task Claude is on (or that it is paused); a click opens Mission Control.
108 const missing = missionPart(state)
109
110 if (missing !== null) parts.push(missing)
111
112 // 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.
113 if (state.turnActive && state.turnStartedMs !== null) parts.push({ text: `▶ Claude working${since(state.turnStartedMs, nowMs)}`, tone: 'live', go: 'events' })
114
115 parts.push(...workingParts(state, nowMs))
116
117 for (const [agent, run] of state.terminal.runs) parts.push({ text: `💻 ${agent} answering${since(run.startedAtMs, nowMs)}`, tone: 'live', go: 'terminal' })
118
119 const latest = state.events.at(-1)
120 const isFresh = latest !== undefined && nowMs - latest.atMs < FRESH_MS
121
122 if (isFresh) parts.push({ text: `${clip(latest.text, 44)} ·${since(latest.atMs, nowMs)} ago`, tone: 'plain', go: 'events' })
123
124 // Nothing moving: say so, with how many agents stand ready. (When something did happen, the last event is a standing part below.)
125 if (!parts.some(part => part.tone === 'live') && !isFresh && snap?.swarm != null) {
126 const ready = snap.agents.length
127
128 parts.push({ text: ready > 0 ? `idle · ${ready} agent${ready === 1 ? '' : 's'} ready` : 'swarm, no agents', tone: 'plain', go: 'swarm' })
129 }
130
131 // 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
132 // last did with it), claims held, this session's spend, what the last scan found, and a published update not yet taken.
133 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` })
134
135 const bars = activityBars(state.events, nowMs)
136
137 if (bars !== null) parts.push({ text: `${bars} tool calls, ${ACTIVITY_MINUTES}m`, tone: 'plain', go: 'events', row: 'standing', compact: bars })
138
139 const claims = snap?.claims ?? []
140
141 if (claims.length > 0) {
142 const stealable = claims.filter(claim => claim.isStealable).length
143
144 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'}` })
145 }
146
147 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) })
148
149 // The context window filling: quiet until it matters, amber when it is close, with the hint that acts on it.
150 const context = state.usage?.contextPercent
151
152 if (context !== undefined && context >= 60) {
153 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)}%` })
154 }
155
156 const findings = secMemo(state).findings
157 const serious = findings === null ? 0 : findings.counts.critical + findings.counts.high
158
159 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}` })
160 if (state.updateAvailable !== '') parts.push({ text: `⬆ ${state.updateAvailable} available`, tone: 'attention', go: 'settings', row: 'standing' })
161
162 return parts
163}
164
165/** The band's words, for `/ruflo status` and anything that wants it as one line. */
166export function barText(state: State, nowMs: number = Date.now()): string {
167 return ['ruflo', ...barParts(state, nowMs).map(part => part.text)].join(' · ')
168}
169
170/**
171 * 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
172 * 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
173 * 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.)
174 */
175export const PANEL = { ground: '#1c1c1c', border: '#5f5faf', text: '#d0d0d0', dim: '#8a8a8a', attention: '#ffaf00', live: '#5fd75f', bad: '#ff5f5f' } as const
176
177/** Links at the end of the standing row, each opening the console on that view: where to go next, whatever is happening. */
178export const BAND_LINKS: readonly { label: string; go: ViewId }[] = [
179 { label: 'Missions', go: 'missions' },
180 { label: 'Swarm', go: 'swarm' },
181 { label: 'Security', go: 'secure' },
182 { label: 'Memory', go: 'memory' },
183 { label: 'Cost', go: 'cost' },
184 { label: 'Menu', go: 'menu' },
185]
186
187/**
188 * 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,
189 * something that needs a person is amber, Claude at work is green, and a quiet band keeps its usual purple.
190 */
191export type BandTone = 'bad' | 'attention' | 'live' | 'idle'
192
193export function bandTone(parts: readonly BarPart[], notice: Notice | null): BandTone {
194 if (notice?.level === 'bad') return 'bad'
195 if (parts.some(part => part.tone === 'attention') || notice?.level === 'warn') return 'attention'
196 if (parts.some(part => part.tone === 'live')) return 'live'
197
198 return 'idle'
199}
200
201const BORDER: Record<BandTone, string> = { bad: PANEL.bad, attention: PANEL.attention, live: PANEL.live, idle: PANEL.border }
202const NOTICE_MARK = { ok: '✓', info: 'ℹ', warn: '⚠', bad: '✖' } as const
203const NOTICE_COLOR = { ok: PANEL.live, info: PANEL.text, warn: PANEL.attention, bad: PANEL.bad } as const
204
205const toneColor = (tone: BarPart['tone']): string => (tone === 'attention' ? PANEL.attention : tone === 'live' ? PANEL.live : PANEL.text)
206
207/**
208 * The band, in a bordered panel with a background, two rows. The first is what is happening now (what needs a person, the mission,
209 * who is working, a fresh event). The second is what stands: the last tool call and how long ago, claims, spend, findings, an update,
210 * 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
211 * link: a click opens the console on the view it is about. `onGo` opens the console there; `onOpen` opens it as it was.
212 */
213export function barView(kit: Kit, state: State, columns: number, mark: RenderElement | null, onOpen: () => void, onGo?: (view: ViewId) => void, onDismiss?: () => void): RenderElement {
214 // A stale marketplace clone is one of the alerts, so it already turns the band's attention part on.
215 const parts = barParts(state)
216 const notice = visibleNotice(state, Date.now())
217 const inner = Math.max(16, columns - 4)
218 const sep = (): RenderElement => kit.Text({ color: PANEL.dim, children: ' · ' })
219
220 // One part: a button to its view where it has one, else words in its tone's colour.
221 const partElement = (part: BarPart, key: string, room: number): RenderElement => {
222 const go = part.go
223 const label = clip(part.text, room - 3)
224
225 if (go !== undefined && onGo !== undefined) return kit.Button({ key, label, plain: true, ...(part.tone === 'plain' && { dimColor: true }), onPress: () => onGo(go) })
226
227 return kit.Text({ wrap: 'truncate-end', color: toneColor(part.tone), children: label })
228 }
229 // 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
230 // its own choice of words, not cut in the middle of one.
231 // `bare` is a row whose lead already ends in its own space (the standing row's "↳ "): its first part needs no separator before it.
232 const fill = (lead: RenderElement[], room: number, shown: BarPart[], from: number, bare = false): { children: RenderElement[]; room: number } => {
233 const children = [...lead]
234 const tight = shown.reduce((sum, part) => sum + part.text.length + 3, 0) > room
235 const forms = shown.map(part => (tight && part.compact !== undefined ? { ...part, text: part.compact } : part))
236
237 for (const [i, part] of forms.entries()) {
238 if (room <= 6) break
239 children.push(...(bare && i === 0 ? [] : [sep()]), partElement(part, `band-${from + i}`, room))
240 room -= part.text.length + (bare && i === 0 ? 0 : 3)
241 }
242
243 return { children, room }
244 }
245
246 const status = parts.filter(part => part.row !== 'standing')
247 const standing = parts.filter(part => part.row === 'standing')
248 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)
249
250 if (!state.pane.isOpen) first.children.push(kit.Text({ children: ' ' }), kit.Button({ key: 'open-console', label: 'open console', plain: true, onPress: onOpen }))
251
252 // The second row: the standing facts, then the links with what room is left (a link that does not fit is dropped, not cut).
253 const second = fill([kit.Text({ color: PANEL.dim, children: '↳ ' })], inner - 2, standing, status.length, true)
254 let room = second.room
255
256 for (const [i, link] of BAND_LINKS.entries()) {
257 if (room < link.label.length + 5) break
258 // A bar sets the links off from the facts before them; between links, a space.
259 if (i > 0) second.children.push(kit.Text({ children: ' ' }))
260 else if (standing.length > 0) second.children.push(kit.Text({ color: PANEL.dim, children: ' │ ' }))
261
262 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 }))
263 room -= link.label.length + (i === 0 ? 3 : 1)
264 }
265
266 // 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.
267 const noticeRow = notice === null ? [] : [
268 kit.Box({
269 flexDirection: 'row',
270 children: [
271 kit.Text({ bold: true, color: NOTICE_COLOR[notice.level], children: `${NOTICE_MARK[notice.level]} ` }),
272 kit.Text({ color: NOTICE_COLOR[notice.level], wrap: 'truncate-end', children: clip(notice.text, Math.max(10, inner - 24)) }),
273 kit.Text({ children: ' ' }),
274 ...(notice.go !== undefined && onGo !== undefined ? [kit.Button({ key: 'band-notice-go', label: 'view', plain: true, onPress: () => onGo(notice.go as ViewId) })] : []),
275 ...(onDismiss !== undefined ? [kit.Text({ children: ' ' }), kit.Button({ key: 'band-notice-dismiss', label: '✕', plain: true, dimColor: true, onPress: onDismiss })] : []),
276 ],
277 }),
278 ]
279
280 return kit.Box({
281 flexDirection: 'column',
282 borderStyle: 'round',
283 borderColor: BORDER[bandTone(parts, notice)],
284 backgroundColor: PANEL.ground,
285 paddingX: 1,
286 children: [kit.Box({ flexDirection: 'row', children: first.children }), ...(state.bandCompact ? [] : [kit.Box({ flexDirection: 'row', children: second.children })]), ...noticeRow],
287 })
288}
289hooks/notices.ts 186 lines1/**
2 * Notices: short announcements the band raises when something changes while nobody is looking at it: new approvals or alerts, a mission
3 * finishing, a long Claude turn ending, Project Anatole blocking a call. A notice is recorded (a ring of 30, listed by `/ruflo notices`),
4 * shown on the band's notice row for a short while, and linked to the view it is about. They come only from changes the console observed
5 * between two reads: nothing is synthesised, and the first read announces nothing. Pure, apart from the helpers that edit `state`.
6 */
7import { alertsOf, approvalsOf } from './data/alerts'
8import { agentLabels } from './data/parse'
9import { activeMission, progressOf } from './mission-control'
10import type { State, ViewId } from './state'
11
12export type NoticeLevel = 'ok' | 'info' | 'warn' | 'bad'
13
14export type Notice = { id: number; atMs: number; level: NoticeLevel; text: string; key: string; go?: ViewId; seen: boolean }
15
16/** What a notice is raised from, before it gets an id and a time. */
17export type NoticeDraft = { level: NoticeLevel; text: string; key: string; go?: ViewId }
18
19export const MAX_NOTICES = 30
20/** How long an unseen notice stays on the band's notice row. */
21export const SHOW_MS = 20_000
22/** The same kind of notice (its key) is not raised again inside this window. */
23export const DEDUPE_MS = 60_000
24
25/** The few counts a notice is decided from, read off the state at one moment. */
26export type Facts = {
27 approvals: number
28 alerts: number
29 mission: { id: string; done: number; total: number } | null
30 anatole: { blocked: number; critical: number; degraded: string | null } | null
31}
32
33export function factsOf(state: State, nowMs: number = Date.now()): Facts {
34 const mission = activeMission(state)
35 const tasks = state.snapshot?.tasks ?? []
36 const progress = mission === null || mission.cancelled ? null : progressOf(mission, tasks)
37 const status = state.snapshot?.anatole?.status ?? null
38
39 return {
40 approvals: approvalsOf(state).length,
41 alerts: alertsOf(state, nowMs, state.loadedAtMs).filter(alert => alert.level !== 'info').length,
42 mission: mission === null || progress === null ? null : { id: mission.id, done: progress.done, total: progress.total },
43 anatole: status === null ? null : { blocked: status.blocked, critical: status.open.critical, degraded: status.degraded === false ? null : String(status.degraded).slice(0, 60) },
44 }
45}
46
47const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
48
49/** What changed between two reads that a person would want announced. The newest Anatole alert's rule rides on a block. */
50export function noticesBetween(before: Facts, after: Facts, newestRule: string | null = null): NoticeDraft[] {
51 const out: NoticeDraft[] = []
52
53 if (after.approvals > before.approvals) out.push({ level: 'warn', text: `${plural(after.approvals - before.approvals, 'new approval')} waiting (q)`, key: 'approvals', go: 'approvals' })
54 if (after.alerts > before.alerts) out.push({ level: 'warn', text: `${plural(after.alerts - before.alerts, 'new alert')}`, key: 'alerts', go: 'overview' })
55
56 if (before.mission !== null && before.mission.done < before.mission.total && after.mission !== null && after.mission.id === before.mission.id && after.mission.done >= after.mission.total) {
57 out.push({ level: 'ok', text: `🎯 mission finished: ${after.mission.done}/${after.mission.total} tasks`, key: 'mission-done', go: 'missions' })
58 }
59
60 if (before.anatole !== null && after.anatole !== null) {
61 if (after.anatole.blocked > before.anatole.blocked) out.push({ level: 'bad', text: `🛡 Anatole blocked ${plural(after.anatole.blocked - before.anatole.blocked, 'call')}${newestRule === null ? '' : ` · ${newestRule}`}`, key: 'anatole-blocked', go: 'secure' })
62 else if (after.anatole.critical > before.anatole.critical) out.push({ level: 'bad', text: '🛡 Anatole: a new critical alert', key: 'anatole-critical', go: 'secure' })
63 if (after.anatole.degraded !== null && before.anatole.degraded === null) out.push({ level: 'warn', text: `🛡 Anatole is degraded: ${after.anatole.degraded}`, key: 'anatole-degraded', go: 'secure' })
64 }
65
66 return out
67}
68
69/** After a read: announces what changed since `before` (the newest open Anatole alert's rule rides on a block). */
70export function announceChanges(state: State, before: Facts, nowMs: number): void {
71 const open = (state.snapshot?.anatole?.alerts ?? []).filter(alert => alert.state === 'open')
72
73 for (const draft of noticesBetween(before, factsOf(state, nowMs), open.at(-1)?.rule ?? null)) addNotice(state, draft, nowMs)
74}
75
76/** Records a notice, unless one with the same key was raised a minute ago. The ring keeps the newest 30. Returns whether it was recorded. */
77export function addNotice(state: State, draft: NoticeDraft, nowMs: number = Date.now()): boolean {
78 if (state.notices.some(notice => notice.key === draft.key && nowMs - notice.atMs < DEDUPE_MS)) return false
79
80 state.noticeSeq += 1
81 state.notices.push({ id: state.noticeSeq, atMs: nowMs, level: draft.level, text: draft.text.slice(0, 120), key: draft.key, ...(draft.go !== undefined && { go: draft.go }), seen: false })
82 if (state.notices.length > MAX_NOTICES) state.notices.splice(0, state.notices.length - MAX_NOTICES)
83
84 return true
85}
86
87/** The notice the band shows now: the newest unseen one still young, none while the person has asked for quiet. */
88export function visibleNotice(state: State, nowMs: number): Notice | null {
89 if (nowMs < state.noticesQuietUntilMs) return null
90
91 for (let i = state.notices.length - 1; i >= 0; i--) {
92 const notice = state.notices[i] as Notice
93
94 if (!notice.seen && nowMs - notice.atMs < SHOW_MS) return notice
95 }
96
97 return null
98}
99
100/** Dismisses what is showing (and anything older and unseen), so the next notice shows on its own. */
101export function dismissNotices(state: State): void {
102 for (const notice of state.notices) notice.seen = true
103}
104
105const AGO = (ms: number): string => (ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 3_600_000)}h`)
106
107/** `/ruflo notices`: the last ten, newest first; listing them marks them seen. `clear` empties the ring. */
108export function noticesReply(state: State, nowMs: number, clear: boolean): string {
109 if (clear) {
110 const n = state.notices.length
111
112 state.notices.length = 0
113
114 return n === 0 ? 'no notices to clear' : `cleared ${plural(n, 'notice')}`
115 }
116
117 const recent = state.notices.slice(-10).reverse()
118
119 dismissNotices(state)
120
121 if (recent.length === 0) return 'no notices yet: the band raises one when approvals or alerts arrive, a mission finishes, a long turn ends, or Anatole blocks a call'
122
123 const mark = { ok: '✓', info: 'ℹ', warn: '⚠', bad: '✖' } as const
124
125 return [`notices (newest first${nowMs < state.noticesQuietUntilMs ? `; quiet for ${AGO(state.noticesQuietUntilMs - nowMs)} more` : ''}):`, ...recent.map(notice => ` ${mark[notice.level]} ${notice.text} · ${AGO(nowMs - notice.atMs)} ago${notice.go === undefined ? '' : ` · /ruflo ${notice.go}`}`)].join('\n')
126}
127
128/** `/ruflo quiet [minutes|off]`: silence the notice row for a while (notices are still recorded and listed). */
129export function quietReply(state: State, nowMs: number, arg: string): string {
130 const word = arg.trim().toLowerCase()
131
132 if (word === 'off' || word === '0') {
133 state.noticesQuietUntilMs = 0
134
135 return 'notices are on again'
136 }
137
138 if (word === '') return nowMs < state.noticesQuietUntilMs ? `notices are quiet for ${AGO(state.noticesQuietUntilMs - nowMs)} more (/ruflo quiet off)` : 'notices are on (/ruflo quiet <minutes> silences the band\'s notice row)'
139
140 const minutes = Number(word)
141
142 if (!Number.isFinite(minutes) || minutes < 1 || minutes > 1440) return 'quiet takes a number of minutes from 1 to 1440, or off'
143
144 state.noticesQuietUntilMs = nowMs + Math.round(minutes) * 60_000
145
146 return `notices are quiet for ${Math.round(minutes)}m: they are still recorded (/ruflo notices lists them)`
147}
148
149/** `/ruflo band [auto|on|off|compact|full|reset]`: the band's show/hide and size for this session, over the option. */
150export function bandReply(state: State, arg: string): string {
151 const word = arg.trim().toLowerCase()
152
153 if (word === 'auto' || word === 'on' || word === 'off') state.bandMode = word
154 else if (word === 'compact') state.bandCompact = true
155 else if (word === 'full') state.bandCompact = false
156 else if (word === 'reset') {
157 state.bandMode = null
158 state.bandCompact = false
159 } else if (word !== '') return 'band takes auto, on, off, compact, full or reset'
160
161 return `band: ${state.bandMode ?? `${state.options.bar} (the plugin option)`} · ${state.bandCompact ? 'compact (one row)' : 'full (two rows)'}`
162}
163
164/** A readable name for a subagent in the event stream: the ruflo agent's label when it is one, else "subagent" and the id's first six characters. */
165export function agentName(state: State, agentId: string | undefined): string {
166 if (agentId === undefined) return 'claude'
167
168 const agents = state.snapshot?.agents ?? []
169 const known = agentLabels(agents).get(agentId)
170
171 return known !== undefined ? known : `subagent ${agentId.replace(/[^a-z0-9]/gi, '').slice(0, 6)}`
172}
173
174const sorted = (values: readonly number[]) => [...values].sort((a, b) => a - b)
175
176export const median = (values: readonly number[]) => sorted(values)[Math.floor(values.length / 2)] ?? 0
177export const p95 = (values: readonly number[]) => sorted(values)[Math.min(values.length - 1, Math.floor(values.length * 0.95))] ?? 0
178
179/** With the band off, the console's words ride ruflo-mods' status line instead: claims and a stale marketplace only. */
180export function segmentOf(state: State): string | null {
181 const claims = state.snapshot?.claims ?? []
182 const parts = [claims.length > 0 ? `${claims.length} claims` : '', state.snapshot?.plugins.missingFromClone.length ? 'marketplace stale' : ''].filter(Boolean)
183
184 return parts.length === 0 ? null : parts.join(' · ')
185}
186