SLOPSHOPPER

ruflo-console

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…

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ruflo-console
│ ┃ ruflo ✕ › fix the failing auth test and add an audit log call │ ┃ ╔═══════════════════════════════════════════ │ ┃ ╚═══════════════════════════════════════════ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /ruflo │ ┃ ⎿ ruflo-console: ruflo console: Main Menu │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ruflo
╔═════════════════════════════════════════════════════╗ ╚═════════════════════════════════════════════════════╝
README

ruflo-console

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).

Drive the console from the command line

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.

ExitMeaning
0a console call ran and every --expect matched (or the run was skipped, see below)
1no console tool call ran
2usage error: bad level or an invalid --expect regex
3at least one --expect regex (case-insensitive, repeatable) matched no RESULT
4CONSOLE_DRIVE_INSTALL was set and the marketplace add or install failed
  • What it spends: about $0.10 per run with haiku, hard-capped at $0.40 (--max-budget-usd). Use one to three runs, not a sweep.
  • Isolated config: the run uses a throwaway 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.
  • Why the level is capped: the level is 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.
  • Seeding: 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.
  • Skips cleanly: without 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.
  • Extra plugins: any further arguments are added as --plugin-dir. That loads a plugin but does not install it, so claude plugin configure cannot see it.
  • Installed plugin (for option rows): 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."

Room and Mods

Open it with /ruflo room (menu: Safety → The Room).

  • Waiting for a yes: the one confirm that is pending, with the seconds left to answer it.
  • The feed: events, Claude's console actions and what you said, newest first. Filter by who, search, pause, page back. ⛔ blocked shows only what was refused or failed: Claude's actions the control log marked denied or error, and events that say denied. Press a line to open it; an event that came from a page of its own (swarm, claims, learning, plugins, missions) offers a jump button to that page.
  • Mods: one line per plugin mod that has written .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".

Project Anatole in Security & Doctor

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.

  • Reads .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.
  • Shows the mode, the baseline's maturity ("learning 62%"), open alerts by severity, blocked and 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.
  • Runs /protector run and /protector replay into the page's Result panel.
  • Edits the mode (off, learn, notify, enforce) and per-rule modes through /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).
Source 91 files
hooks/register.ts 464 lines
1import 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}
464
hooks/views/attention.ts 139 lines
1/**
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}
139
hooks/controller.ts 496 lines
1/**
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}
496
hooks/data/events.ts 145 lines
1/**
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}
145
hooks/data/parse.ts 499 lines
1/**
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}
499
hooks/dispatch.ts 201 lines
1/**
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}
201
hooks/gfx/pictures.ts 441 lines
1/**
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}
441
hooks/host.ts 73 lines
1import 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 }
73
hooks/tool-owner.ts 49 lines
1/**
2 * Which mission task a tool row belongs to. A call is attributed the first time its row is drawn while it runs: if the
3 * active mission is neither paused nor cancelled and exactly one of its tasks is running (the one handed to the
4 * session), that task owns the call, and keeps it after the task finishes. A call first seen already finished, or
5 * while no task or several tasks run, belongs to no task: the row says nothing rather than guess.
6 */
7import { activeMission, derive } from './mission-control'
8import type { State } from './state'
9
10export type Owner = { taskId: string; title: string; phase: string; missionId: string }
11
12const MAX_REMEMBERED = 500
13const owners = new WeakMap<State, Map<string, Owner | null>>()
14
15/** The one running task of the active mission, or null when none or more than one run (or the mission is paused or cancelled). */
16export function runningOwner(state: State): Owner | null {
17  const mission = activeMission(state)
18
19  if (mission === null || mission.paused || mission.cancelled) return null
20
21  const status = derive(mission, state.snapshot?.tasks ?? [])
22  const running = mission.tasks.filter(task => status.get(task.id) === 'running')
23  const [only] = running
24
25  return running.length === 1 && only !== undefined ? { taskId: only.id, title: only.title, phase: only.phase, missionId: mission.id } : null
26}
27
28/** The owner of one call by its tool-use id: decided at first sight and remembered. */
29export function ownerOf(state: State, toolUseId: string, isRunning: boolean): Owner | null {
30  let known = owners.get(state)
31
32  if (known === undefined) {
33    known = new Map()
34    owners.set(state, known)
35  }
36
37  if (known.has(toolUseId)) return known.get(toolUseId) ?? null
38
39  const owner = isRunning ? runningOwner(state) : null
40
41  known.set(toolUseId, owner)
42  if (known.size > MAX_REMEMBERED) known.delete(known.keys().next().value as string)
43
44  return owner
45}
46
47/** The one dim line a tool row carries under it, e.g. `↳ mission task: Write the tests (test)`. */
48export const ownerLine = (owner: Owner): string => `↳ mission task: ${owner.title} (${owner.phase})`
49
hooks/state.ts 449 lines
1import 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)
449
hooks/views/bar.ts 289 lines
1/**
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}
289
hooks/notices.ts 186 lines
1/**
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