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…

new
★ 74,184v0.40.3MITupdated 2026-10-09ruvnet/ruflo/plugins/ruflo-console
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (16 | import { HEADS, mark as marked } from './marks' ^ error: No matching export in "slop-empty:/home/runner/work/slopshopper/slopshopper/.cache/src/ru
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."

Your project's ADRs (ADR-480)

TOOLS → ADRs manages the Architecture Decision Records of the project you run Claude Code in (not ruflo's own).

  • Finding them. The console looks for docs/adr, docs/adrs, doc/adr, adr, docs/architecture/decisions, docs/decisions and architecture/decisions, or the folder in Settings → ADR folder. A folder that is a link is never read. With none, initialise ADRs here asks first, then creates the folder and 0001-record-architecture-decisions.md; nothing is overwritten.
  • Reading them. MADR front matter, Nygard / adr-tools ## Status sections (Superseded by 5. …), ruflo-style Status: lines, log4brains and plain markdown with no status all parse; a file that does not parse still lists. Filter by status, words and scope; the detail shows links both ways, the decision, and the missions it is attached to; the health strip is the lint.
  • Writing them. propose takes the next number and the style your ADRs already use (override in Settings: ADR style, ADR file name pattern). Accept, reject, deprecate or supersede from a record's detail. Each shows the exact file and the diff, writes only that on your Yes, and leaves a file that changed since alone.
  • Missions, loops, swarms. Attach ADRs to the active mission (the page suggests some from the goal's words; you decide). Claude's mission context, each task's instruction and spawned swarm agents (ruflo-swarm 0.3.5) are told the accepted decisions, masked and capped. At verify, changed files are compared with the paths the attached accepted ADRs name: a warning in the mission record. That compares paths only. It does not prove a change follows or breaks a decision, and it never blocks. When a mission is done, draft an ADR from this mission prepares a proposed record from its goal and tasks; the decision is yours to write.
  • For Claude. /ruflo run adr-propose <title>, adr-accept <n>, adr-supersede <old> <new>, adr-attach <n> and the rest are palette entries, so Claude reaches them through console_run at the write control level (reading the page is read); a change still waits for your Yes on the diff.

Toasts (ADR-477)

Settings → Interface and updates → Toasts sets what the ruflo plugins may show over the transcript: all, important (warnings and errors) or off, with a mute chip each for console, swarm, protector and mods. It is kept in the console's store and mirrored to .claude-flow/console/toast-prefs.json, which the other plugins read. Every toast, drawn or not, becomes an event on the Events page (toast <source> <glyph> <text> [off|muted|deduped|…]); the other plugins' digests are read from .claude-flow/console/toasts/<source>.jsonl, masked and capped. The console itself also toasts a mission that finishes (ok) or loses a task (error), and its update notes by level. Design and contract: ADR-477.

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