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
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (18 | import { HEADS, mark as marked } from './marks' ^ error: No matching export in "slop-empty:/home/runner/work/slopshopper/slopshopper/.cache/src/ab
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.

In a terminal of its own: ruflo tui (ADR-489)

The same views the mod draws, on five pages (Overview, Cost, Swarm, Events, Missions), drawn by a small standalone terminal host instead of Claude Code, and a sixth, AI, that asks Codex or OpenRouter. The five are read-only; the AI page shows a card of exactly what would be sent and sends only after a y; npx ruflo tui (the ruflo package and @claude-flow/cli carry the bundle).

npx ruflo tui                    # interactive: 1-6 or o c s e m a pick a page, Tab/Shift-Tab cycle, j/k, PgUp/PgDn, g/G, r refresh, q quit
npx ruflo tui --view swarm       # start on the Swarm page
npx ruflo tui --view ai --model openai/gpt-4o-mini --max-spend-usd 0.1   # ask OpenRouter on a cheap model, stopping at $0.10 reported (key in $OPENROUTER_API_KEY, never shown)
npx ruflo tui --no-ai            # without the AI page
npx ruflo tui --plain | head -40 # one plain frame, exit 0: for scripts and CI
npx ruflo tui | cat              # no terminal and no --plain: a one-line hint on stderr, exit 2

From a checkout, build the bundle first: node plugins/ruflo-console/scripts/build-tui.mjs. Room is not in the terminal (it is a message box and Claude sessions). AI page: Ctrl-T picks Codex or OpenRouter, Enter shows the card, y sends, n or Esc cancels, Ctrl-S stops a running answer, Esc hands the keys back to the page bar. Codex runs read-only and only as the pinned 0.162.0 release (never current or PATH); OpenRouter goes to openrouter.ai over https, or to a host a --convo-targets entry names (endpoint:<name>=<https-base-url>|<KEY_ENV>|<model>); a key is read by its variable name and never drawn. Details and what is not done yet: ADR-489 section 4. A terminal narrower than 60 columns gets a short page bar, and under 40 a one-line notice.

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.
  • Needs you, Sessions, Preview (ADR-486): approvals, questions, failures and finished work across Claude Code, Codex and Ruflo sessions, including ones started outside Ruflo; the sessions grouped by repository and worktree; and a preview of the one you pick (latest response, current tool, edited files, test result, cost labelled reported, estimated or unavailable). Discovery is local and read-only: no model call, no process, nothing woken (the one write is a four-count summary ruflo-mods can show). The Sessions list starts folded; jumping to a session from the queue opens it. A finished session leaves the queue only after its preview was drawn; a session whose identity is ambiguous goes to an unassigned bucket and raises nothing. Transcript text is never saved, never sent to a model and never included in a text answer. Options sessionWorkspace and sessionPreview (both on).
  • 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).

Orbital theme

An optional dark telemetry look inspired by compact system monitors: black panel background, square red borders, warm red headings, muted readable values and green health indicators. It uses the existing live views and command palette; no extra polling, simulated metrics or model calls. BBS remains the default.

Choose Settings → ruflo-console → Look → orbital, or run in your shell:

printf '%s\n' '{"look":"orbital"}' | claude plugin configure ruflo-console@ruflo --values-stdin

Reload plugins after changing the option. Select bbs or plain to restore those looks. A dark terminal background gives the closest match; terminal graphics and host controls keep their existing renderer. Narrow panes retain the existing responsive layout.

Orbital keeps the original Ruflo block mark visible after boot on every page, above navigation. Wide headers use an eight-row composition with a shaded block extrusion, moving highlight, rotating ASCII Earth and travelling scan. Smaller panes retain the original two-row block mark inside a compact header. The optional boot intro uses the same Earth rotation (one turn per 12 seconds), with actual self-check counts below it. It runs for 1.5 seconds when data is ready, at most 6 seconds while data is pending. Set fps to 0 for still headers and no boot intro. Existing frame scheduling stops motion in hidden or unfocused panes. The sweep and globe are decoration, not measured activity or a progress percentage.

Orbital adapts to the pane allocation. Below 48 drawing columns or 30 available rows, boot uses a compact check summary of at most nine rows. Failure totals remain near the top, with the first failing area shown when a third row fits. The persistent header uses its expanded layout from 78 drawing columns with eight available header rows. Short panes keep two rows; the native host's text-only mode below 44 pane columns retains a text Ruflo identity. Header width reserves the existing toolbar icon space and is capped at 120 columns. Resize dimensions are recorded before the header is mounted so the animation loop continues at the new size. The existing live topology remains in the Swarm page body; Theme Studio provides the decorative orchestration preview.

Theme Studio

Open Main Menu → Tools → Theme Studio, the Tools navigation group, or /ruflo themes. Settings also links to the studio. Choose Classic BBS, Terminal plain or Orbital. Studio selections persist and override the plugin's configured look until Use configured look is selected.

Six bundled Color presets load with one click and persist across sessions. They retain the animated Ruflo header, globe, stars and topology at standard speed. Bundled presets do not consume the twelve custom theme slots. Every primary foreground role meets at least 4.5:1 contrast against its background; decorative shading may be dimmer.

PresetPalette
ArcticIce cyan, seafoam and deep navy
SynthwaveOrchid, mint and dark violet
Amber CRTWarm amber, parchment and dark bronze
EmeraldJade, pale green and forest black
Solar FlarePeach, gold and dark terracotta
MoonlightPeriwinkle, silver and midnight blue

Select a preset to load it immediately. Edit colors or motion and choose Save and apply to create a custom copy; bundled originals remain available. Portable version 1 JSON presets also load through AI → Import theme JSON, followed by Save and apply. Use configured look restores the plugin setting.

Reproduce the theme workflow demo

The demo drives actual console view handlers through boot, Main Menu, all six presets, custom color and motion, save, fresh state restoration from a local JSON store, portable JSON import, reduced motion, and 80/116 column layouts. It checks each transition and writes receipt.json. This is a renderer integration demo, not a native Claude host recording: other console actions are not exercised, the FPS setting is changed by the harness, and no AI provider is invoked.

From the repository root, with Node:

npx tsx plugins/ruflo-console/scripts/theme-demo.mts /tmp/ruflo-demo-new

Use a fresh output directory. The capture refuses to overwrite an existing demo-session.json; omitting the directory creates a unique temporary directory and prints its path. Outputs are frames.json (the captured terminal cells for every step, with a caption, palette and column count) and receipt.json (what was and was not exercised: no native host, no AI provider). Turning the frames into a GIF or video is left to your own renderer; the repository ships none, so no Python or image library is needed.

For a genuine animated vector export of those same captured frames, run (Node only):

node plugins/ruflo-console/scripts/render-theme-svg.mjs /tmp/ruflo-demo-new

ruflo-console-demo.svg contains text runs, block rectangles and reusable row symbols (the font is your system monospace), replayed with native SVG discrete animation over the same 38 seconds. It has no embedded images, video, scripts or other external assets. The complete 456-frame replay is approximately 20 MB. It scales through its viewBox; open the file in an SVG animation capable browser when a document preview shows only a still frame.

The terminal color picker provides swatches and exact #RRGGBB entry for headings, success, failure, warning, information and background. Preview the globe and swarm motion, adjust speed and decorative layers, then Save preview and apply. Up to twelve named custom presets are stored locally through the host; reusing a name replaces that preset. Failed saves leave the active theme unchanged. Settings fps: 0 still disables animation. Layouts retain the compact/narrow fallbacks.

Generate with Claude or Generate with Codex runs the bundled generate-console-theme skill through AI Terminal with its existing confirmation and model controls. Enter the design brief first, generate a preset, then paste the returned JSON into Import theme JSON and review before saving. The generator produces palette and motion variations of the existing Orbital renderer, not executable animation code. No additional API credentials are requested. Installed Claude plugins discover the bundled skill; Codex can read plugins/ruflo-console/skills/generate-console-theme/SKILL.md directly in this checkout.

Imports are limited to 4096 characters and a strict version 1 data schema. Status labels and actual check results remain unchanged. Saving requires at least 3:1 foreground/background contrast; the generator aims for 4.5:1.

Theme Studio uses focused Colors, Motion and AI tabs. At 100 content columns it places the editor beside a persistent globe and routing preview; narrower panes stack both panels with a compact routing strip. A single color role picker replaces the repeated swatch lists. Minimum contrast is shown before saving, and Discard draft changes restores the active palette without deleting saved themes.

Source 91 files
hooks/register.ts 500 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { tolerantPress } from './press-guard'
3import { ANSWER_KEYS } from './views/attention'
4import { rootedPath } from './data/abs-path'
5import { createController, type Controller } from './controller'
6import { startSessions } from './sessions'
7import { record } from './data/events'
8import { plain } from './data/parse'
9import { dispatch } from './dispatch'
10import { markPicture } from './gfx/pictures'
11import type { Host } from './host'
12import { ownerLine, ownerOf } from './tool-owner'
13import { newState, PANE_ID, restore, restoreSessions, storeKeyOf, termStoreKeyOf } from './state'
14import { BAR_KEY, barView } from './views/bar'
15import { addNotice, dismissNotices } from './notices'
16import { setBootChecks } from './boot-checks'
17import { startWriteFlavorDetection } from './data/write-flavor'
18import { buildOf, isOurCheckout, setBuild } from './build'
19import { runUpdateCheck } from './update-flow'
20import { announceModelTools, parseControlEnv, serveModelTools } from './model-tools'
21import { loadAiPrefs, setControlCap } from './settings'
22import { hydrateWhatsNew } from './whatsnew'
23import { prefsFromStore, recordToast, saveToastPrefs, TOASTS_KEY } from './toasts'
24import { contextSection, onPromptSubmit, onTurnComplete } from './mission-claude'
25import { parseMode, RECHECK_EVERY_MS, UPDATES_KEY } from './updates'
26import { selfCheckResults } from './self-check'
27import { kitForSurface, type Kit } from './views/common'
28import { createToaster, type Digest, type ToastLevel, type ToastPrefs } from './toast-policy'
29import { picturesOf } from './views/frames'
30import { withClearing } from './views/clearing'
31import { NARROW, paneView } from './views/pane'
32
33const RUFLO_TOOL = /^mcp__(claude-flow|ruflo|plugin_ruflo[\w-]*)__/
34
35/**
36 * Binds a Host from `$`, every member spelled `$.noun.method(...)` here and nowhere else, so the engine reads what
37 * the module calls off this one place. Calls that answer nothing are wrapped: a refused draw is not a crashed hook.
38 */
39/** True while the console itself scrolls the pane to its top, so the terminal's own wheel handling does not take that for the person's wheel. */
40let isResettingScroll = false
41
42function hostOf($: EngineInterface, cwd: string, toasts: { prefs: () => ToastPrefs; record: (digest: Digest) => void }): Host {
43  const rooted = (path: string) => rootedPath(cwd, path)
44  const quietly = (fn: () => unknown) => {
45    try {
46      const result = fn()
47
48      if (result instanceof Promise) result.catch(() => undefined)
49    } catch {
50      // Refused: there is nothing to do about a draw nobody may make.
51    }
52  }
53
54  // ADR-477: every toast of the console goes through the shared policy (levels, one clean line, de-duplication, a rate limit, the person's
55  // Toasts setting); drawn or not, each is recorded for the Events page. A refused draw is a refused toast, never a crash.
56  const toaster = createToaster({
57    source: 'console',
58    now: () => Date.now(),
59    show: (line, options) => $.ui.toast(line, options),
60    after: (ms, fn) => $.clock.after(ms, fn),
61    prefs: toasts.prefs,
62    persist: toasts.record,
63  })
64
65  return {
66    fs: { read: async path => $.fs.read(rooted(path)), stat: async path => $.fs.stat(rooted(path)), list: async path => $.fs.list(rooted(path)) },
67        every: (ms, fn) => $.clock.every(ms, fn),
68    after: (ms, fn) => $.clock.after(ms, fn),
69    storeGet: async key => $.store.get(key),
70    storeSet: async (key, value) => $.store.set(key, value as never),
71    fetchText: async url => {
72      const response = await $.http.fetch(url)
73
74      return { ok: response.ok, status: response.status, text: response.text }
75    },
76    askChoice: async (question, options) => $.ui.ask(question, options),
77    toast: (text, timeoutMs, level: ToastLevel = 'info') => quietly(() => void toaster.toast({ level, text, ...(timeoutMs !== undefined && { timeoutMs }) })),
78    invalidate: () => quietly(() => $.ui.invalidate('ui.render')),
79    // Once now and once after the new page has drawn: a page taller than the one before keeps the old offset until it is moved.
80    scrollTop: () => {
81      const go = () =>
82        quietly(() => {
83          isResettingScroll = true
84
85          return Promise.resolve($.ui.scroll({ to: 'start', in: PANE_ID })).finally(() => {
86            isResettingScroll = false
87          })
88        })
89
90      go()
91      $.clock.after(80, go)
92    },
93    focus: async (paneId, key) => $.ui.focus({ requestId: paneId, key }),
94    blit: args => quietly(() => $.ui.blit(args)),
95    openPane: async pane => $.ui.open(pane),
96    closePane: async id => $.ui.close({ id }),
97    panes: async () => $.ui.panes(),
98    registerCommand: async spec => $.command.register(spec),
99    run: async (argv, timeoutMs, stdin) => $.process.run(argv, { cwd, timeoutMs, ...(stdin !== undefined && { stdin }) }),
100    spawn: (argv, input) => $.process.spawn({ argv, cwd, ...(input !== undefined && { input }) }),
101    usage: async () => {
102      const usage = await $.session.usage()
103
104      return { ...(usage.cost?.usd !== undefined && { costUsd: usage.cost.usd }), ...(usage.context?.percent !== undefined && { contextPercent: usage.context.percent }) }
105    },
106    rufloTools: async () => {
107      const names = (await $.tool.list()).flatMap(tool => RUFLO_TOOL.exec(tool.name)?.slice(1, 2) ?? [])
108
109      return { tools: names.length, servers: [...new Set(names)].sort() }
110    },
111    settings: async () => $.settings.read(),
112    home: async () => $.env.get('HOME'),
113    configDir: async () => $.env.get('CLAUDE_CONFIG_DIR'),
114    pluginRoot: $.plugin.root,
115    // `$.ruflo` exists only where ruflo-mods is seated; validate refuses feature-detecting a noun, so these are
116    // async: a missing noun throws inside the promise and every caller's catch sees a rejection.
117    rufloSnapshot: async () => $.ruflo.snapshot(),
118    rufloRoute: async () => $.ruflo.lastRoute(),
119    rufloSegment: async text => $.ruflo.segment({ id: 'console', text }),
120    // Both wait on the turn, so neither may be called from inside a command.run hook (`/ruflo yes` is one): they run from a clock
121    // tick, a later event of their own.
122    submitPrompt: text =>
123      new Promise<void>((resolve, reject) => {
124        $.clock.after(1, () => void $.prompt.submit({ text }).then(() => resolve(), reject))
125      }),
126    fillPrompt: async text => (await $.prompt.fill({ text, mode: 'replace' })).isFilled,
127    runSlash: (command, args) =>
128      new Promise((resolve, reject) => {
129        $.clock.after(1, () => void $.command.run({ command, args }).then(resolve, reject))
130      }),
131    listCommands: async () => (await $.command.list()).map(command => command.name),
132    // ADR-465. A tool call waits on the turn like submitPrompt, so it starts from a clock tick, never inside the hook that asked.
133    toolCall: input =>
134      new Promise((resolve, reject) => {
135        $.clock.after(1, () => void $.tool.call(input as never).then(reply => resolve(reply as never), reject))
136      }),
137    toolCheck: async (tool, input) => $.tool.check({ tool, input }),
138    httpSend: async (url, init) => {
139      const response = await $.http.fetch(url, init)
140
141      return { ok: response.ok, status: response.status, text: response.text }
142    },
143  }
144}
145
146/**
147 * ruflo-console: ruflo's cockpit inside Claude Code, and the home of `/ruflo`. A pane of views over ruflo's state on
148 * disk and the ruflo CLI's local answers, a band above the prompt, a command palette, and management views (agent
149 * drill-down, timeline, approvals, events). Every change goes through the ruflo CLI with fixed argv after a confirm.
150 */
151export const register: Register = (on, raw: PluginOptions) => {
152  // The boot log reports this check, so an [ OK ] on screen means the area's commands resolved. It spawns nothing and takes a
153  // moment; a failure of the check itself leaves the log drawing as it did, never stops the console.
154  try {
155    setBootChecks(selfCheckResults())
156  } catch {
157    setBootChecks(undefined)
158  }
159
160  const state = newState(raw)
161  let host: Host | null = null
162  let control: Controller | null = null
163
164  // Claude's console tools (ADR-444): answered only for their own names, and only when the person's setting lets them exist.
165  serveModelTools(on, () => (control === null ? null : { state, control }))
166
167  on('session.start', async ($, e, next) => {
168    control?.stop()
169    host = hostOf($, e.cwd, { prefs: () => state.toastPrefs, record: digest => recordToast(state, digest) })
170    // Which write argv the host takes (GNU dd/install on Linux, the constant sh scripts on macOS/BSD). Started before the controller (whose
171    // timers write) exists; every write awaits it (writeFlavorReady), so none is built with a guessed flavor.
172    const detecting = host
173    void startWriteFlavorDetection((argv, timeoutMs) => detecting.run(argv, timeoutMs))
174    state.cwd = e.cwd
175    state.nostrKeyVerifiedAtMs = null
176    state.isInteractive = e.isInteractive !== false
177    control = createController(state, host)
178
179    // Which build is this? Only a checkout of this plugin in its repository is read (an installed copy inside some other repo is not
180    // that repo's commit); read-only, $0, and any failure leaves the header at its version alone.
181    const here = host
182    const root = here.pluginRoot
183
184    const built = here
185      .run(['git', '-C', root, 'rev-parse', '--show-prefix'], 3_000)
186      .then(prefix => (prefix.exitCode === 0 && isOurCheckout(prefix.stdout) ? here.run(['git', '-C', root, '--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', 'describe', '--always', '--dirty', '--abbrev=7'], 3_000) : null))
187      .then(described => {
188        setBuild(described !== null && described.exitCode === 0 ? buildOf(described.stdout) : '')
189        here.invalidate()
190      })
191      .catch(() => undefined)
192
193    // The update mode is the person's, kept in the plugin's store; then, once the build is known (a development checkout is never offered
194    // an update) and the screen has settled, one check for a newer published version. It never throws and never blocks the console.
195    const moded = here.storeGet(UPDATES_KEY).then(
196      value => {
197        state.updates = parseMode(value)
198        here.invalidate()
199      },
200      () => undefined,
201    )
202
203    // The Toasts setting is the person's, kept in the plugin's store and mirrored to a file the other plugins read (ADR-477).
204    const toasted = here.storeGet(TOASTS_KEY).then(
205      value => {
206        state.toastPrefs = prefsFromStore(value)
207        if (state.toastPrefs.mode !== 'all' || state.toastPrefs.muted.length > 0) void saveToastPrefs(state, here)
208      },
209      () => undefined,
210    )
211
212    // What's new (ADR-478): the record of what was looked at, read once; the controller's first disk read then takes the baseline.
213    const looked = hydrateWhatsNew(state, here).catch(() => undefined)
214
215    void toasted
216    void looked
217    void Promise.all([built, moded]).then(() => {
218      if (!state.isInteractive) return
219
220      here.after(2_500, () => void runUpdateCheck(state, here))
221      // A session left open for days re-asks too, quietly (no dialog mid-work); the daily gate keeps the network to once a day.
222      state.timers.set('update-recheck', here.every(RECHECK_EVERY_MS, () => void runUpdateCheck(state, here, { quiet: true })))
223    })
224
225    const bound = host
226
227    state.home = (await bound.home().catch(() => undefined)) ?? null
228    state.configDir = (await bound.configDir().catch(() => undefined)) ?? (state.home === null ? null : `${state.home}/.claude`)
229    // A recording or a wide screen can ask for a wider dock: RUFLO_CONSOLE_COLUMNS, whole columns, 40 to 400.
230    const asked = Number(await (async () => $.env.get('RUFLO_CONSOLE_COLUMNS'))().catch(() => ''))
231
232    // RUFLO_CONSOLE_PANEL=command|off overrides the panel option for this session (a recording that shows /ruflo opening it).
233    const panel = await (async () => $.env.get('RUFLO_CONSOLE_PANEL'))().catch(() => undefined)
234
235    if (panel === 'command' || panel === 'off') state.options.panel = panel
236    state.dockColumns = Number.isInteger(asked) && asked >= 40 && asked <= 400 ? asked : 0
237    // The x.ruv.io board's admin rows: only whether the token is set is kept, never its value.
238    state.xruv.hasAdminToken = await (async () => $.env.get('RUFLO_X_ADMIN_TOKEN'))().then(
239      value => typeof value === 'string' && value !== '',
240      () => null,
241    )
242    await Promise.all([
243      bound
244        .registerCommand({ name: 'ruflo', description: 'ruflo: the cockpit (views, palette, agents, approvals) and every ruflo mod command — /ruflo help', argumentHint: '[view|palette|agent <id>|mods|swarm <sub>|help]' })
245        .catch(() => undefined),
246      // Kept for good (ADR-406: no command is removed or renamed): `/ruflo-console` is the same command as `/ruflo`.
247      bound.registerCommand({ name: 'ruflo-console', description: 'Same as /ruflo: the ruflo console', argumentHint: '[view|palette|help]' }).catch(() => undefined),
248      bound.storeGet(storeKeyOf(e.cwd)).then(value => restore(state, value), () => undefined),
249      bound.storeGet(termStoreKeyOf(e.cwd)).then(value => restoreSessions(state, value), () => undefined),
250      bound.rufloTools().then(counted => void (state.rufloTools = counted), () => undefined),
251    ])
252    control.start()
253    startSessions(state, bound)
254    await control.refresh()
255    control.autoOpen()
256
257    // Declare the console tools to the model when control is on: the saved setting, or this session's RUFLO_CONSOLE_CONTROL=<level>:<ask|auto>, which can only lower it.
258    await loadAiPrefs(state, bound).catch(() => undefined)
259
260    const forced = parseControlEnv(await (async () => $.env.get('RUFLO_CONSOLE_CONTROL'))().catch(() => undefined))
261
262    // The override may only lower what the person saved (ADR-450 T12): a project's settings env must not raise Claude's control.
263    // It is kept as session state and applied on every load and save of the preferences, so opening Settings cannot lift it (#3814).
264    setControlCap(state, forced === null ? null : { level: forced.level, confirm: forced.confirm })
265    await announceModelTools(tool => $.tool.register(tool), state).catch(() => 0)
266
267    return next(e)
268  })
269
270  on('session.end', async ($, e, next) => {
271    control?.stop()
272
273    return next(e)
274  })
275
276  /**
277   * `/ruflo`: the console's own subcommands are answered here; `mods` and `swarm <sub>` go to the plugins beneath that
278   * hook the same command (ruflo-mods, ruflo-swarm), and are answered with a hint when neither does.
279   */
280  on('command.run', { command: 'ruflo' }, async ($, e, next) => {
281    if (control === null) return next(e)
282
283    return dispatch(control, state, e.args, async () => (await next(e)) as { text?: string } | undefined)
284  })
285
286  /** `/ruflo-console` is the same command: ruflo-mods and ruflo-swarm hook it as they hook `/ruflo`. */
287  on('command.run', { command: 'ruflo-console' }, async ($, e, next) => {
288    if (control === null) return next(e)
289
290    return dispatch(control, state, e.args, async () => (await next(e)) as { text?: string } | undefined)
291  })
292
293  // Which element was pressed or submitted, before its own closure runs: the runner reads it as the origin of the ask that follows, so
294  // the page puts the confirm and the answer right under it (views/attention.ts). Answering a confirm never moves the origin.
295  on('ui.press', { component: 'Pane' }, ($, e, next) => {
296    if (!ANSWER_KEYS.has(e.element)) state.lastPressed = e.element
297
298    // A click that reaches the engine after its drawing was replaced (a resize) finds no handler: answered quietly (press-guard.ts).
299    return tolerantPress(() => next(e), { element: e.element })
300  })
301
302  on('ui.input', { component: 'Pane' }, ($, e, next) => {
303    if (e.kind === 'submit') state.lastPressed = e.element
304
305    return next(e)
306  })
307
308  on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
309    if (control === null) {
310      return next(e)
311    }
312
313    const started = Date.now()
314    const table = kitForSurface($.ui.resolve(e) as unknown as Kit, e.surface)
315    const columns = Math.max(20, Math.floor(Number(e.props.bodyColumns) || 0) - 1)
316    const isNarrow = columns < NARROW
317    const kit: Kit = isNarrow ? { Box: table.Box, Text: table.Text, Button: table.Button, ...(table.Input !== undefined && { Input: table.Input }) } : table
318
319    if (!state.pane.isOpen) state.pane.bootAtMs = Date.now()
320    state.pane.isOpen = true
321    state.pane.isFocused = e.props.isFocused === true
322    state.pane.columns = columns
323    state.pane.placement = e.props.placement
324    // Height-dependent art must use this render's allocation before its Raster size is recorded.
325    state.pane.rows = Math.max(0, Math.floor(Number(e.props.scroll?.bodyRows) || 0))
326    // A reload while the pane stayed up: timers are gone, so resume them from here.
327    if (!state.timers.has('watch')) control.resume()
328
329    const pictures = isNarrow ? new Map() : picturesOf(state, columns, Date.now(), Date.now(), kit.Raster === undefined)
330
331    state.mounted = kit.Raster === undefined ? new Map() : new Map([...pictures].map(([key, grid]) => [key, { columns: grid.columns, rows: grid.rows }]))
332    state.pictured = pictures.size
333    control.animate()
334
335    const tree = paneView({ kit: withClearing(kit, state, control.actions.clearField, { columns, repaint: () => host?.invalidate() }), state, nowMs: Date.now(), columns, pictures, act: control.actions })
336
337    state.stats.renders.push(Date.now() - started)
338    if (state.stats.renders.length > 200) state.stats.renders.shift()
339
340    return tree
341  })
342
343  // The AI terminal's conversation is its own window: the wheel and the page keys over the pane move it, so the header,
344  // tabs and the field below stay where they are (the engine would scroll the whole pane).
345  on('ui.scroll', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
346    if (control === null || state.view !== 'terminal' || e.by === 0 || isResettingScroll) return next(e)
347
348    const lines = Math.abs(e.by) >= e.bodyRows ? Math.max(1, Math.round(e.bodyRows / 2)) : Math.abs(e.by) * 3
349
350    control.actions.term.scroll(e.by < 0 ? lines : -lines)
351
352    // The pane itself stays put: ask the engine for the offset it already has.
353    return next({ ...e, offset: e.offset - e.by })
354  })
355
356  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
357    const mode = state.bandMode ?? state.options.bar
358    const show = mode === 'on' || (mode === 'auto' && state.snapshot?.isRufloProject === true)
359
360    if (control === null || e.props.hasSurvey || !show) {
361      return next(e)
362    }
363
364    const table = kitForSurface($.ui.resolve(e) as unknown as Kit, e.surface)
365    const bound = control
366
367    state.barDrawnAtMs = Date.now()
368
369    const mark = table.Raster !== undefined ? table.Raster(markPicture(e.props.isWorking, Date.now()).toRaster(BAR_KEY)) : null
370
371    state.turnActive = e.props.isWorking === true
372    bound.markFrame(e.requestId, e.props.isWorking && mark !== null)
373
374    // A click on a part opens the console on its view, with the keys, so the person can act there at once.
375    return barView(table, state, Math.floor(Number(e.props.bodyColumns) || 80), mark, () => void bound.open(false), view => {
376      bound.setView(view)
377      void bound.open(true)
378    }, () => {
379      dismissNotices(state)
380      try {
381        $.ui.invalidate('ui.render')
382      } catch {
383        // A refused redraw leaves the notice showing until the next one.
384      }
385    })
386  })
387
388  // A tool row that ran while one mission task was running says which: one dim line under the engine's own row.
389  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
390    const owner = ownerOf(state, e.props.tool_use_id, e.props.isRunning === true)
391
392    if (owner === null) return next(e)
393
394    const table = $.ui.resolve(e) as unknown as Kit
395    const own = await next(e)
396
397    return table.Box({ key: `owner-${e.props.tool_use_id}`, flexDirection: 'column', children: [own, table.Text({ dimColor: true, children: `  ${ownerLine(owner)}` })] })
398  })
399
400  /** The band's mark pulses during a turn: a redraw at its start, and the loop stopped at its end, whatever redraws. */
401  on('turn.start', ($, e, next) => {
402    // A new turn: the per-turn cap on Claude's console actions starts over.
403    state.control.turnCalls = 0
404    if (e.agentId === undefined) state.turnStartedMs = Date.now()
405
406    try {
407      $.ui.invalidate('ui.render')
408    } catch {
409      // A refused redraw leaves the mark at rest.
410    }
411
412    return next(e)
413  })
414
415  on('turn.complete', ($, e, next) => {
416    if (e.agentId === undefined && state.turnStartedMs !== null) {
417      // A long turn that ends while nobody watches is worth saying: the band announces it (a short one is not news).
418      const took = Date.now() - state.turnStartedMs
419
420      if (took >= 30_000) addNotice(state, { level: 'ok', text: `✓ Claude finished a turn · ${took < 60_000 ? `${Math.round(took / 1000)}s` : `${Math.floor(took / 60_000)}m ${Math.round((took % 60_000) / 1000)}s`}`, key: 'turn-done' })
421    }
422    if (e.agentId === undefined) state.turnStartedMs = null
423    if (e.agentId === undefined) control?.markFrame('', false)
424    if (e.agentId === undefined && host !== null) {
425      try {
426        onTurnComplete(state, host, e.reason)
427      } catch {
428        // A note that could not be recorded never changes the turn.
429      }
430    }
431
432    return next(e)
433  })
434
435  // The mission Claude is working on rides in the system prompt (ADR-443); the text changes only when the task does.
436  on('prompt.compose', async ($, e, next) => {
437    const result = await next(e)
438    const section = contextSection(state)
439
440    return section === null ? result : { sections: [...result.sections, section] }
441  })
442
443  // A prompt carrying a mission's loop marker is that loop's tick.
444  on('prompt.submit', ($, e, next) => {
445    if (host !== null) {
446      try {
447        onPromptSubmit(state, host, e.text)
448      } catch {
449        // Counting a tick never blocks the prompt.
450      }
451    }
452
453    return next(e)
454  })
455
456  on('ui.close', async ($, e, next) => {
457    const result = await next(e)
458
459    if (e.id === PANE_ID && result.deny === undefined) {
460      state.pane.isOpen = false
461      state.pane.isShown = false
462      if (e.origin.kind === 'person') control?.closedByPerson()
463      control?.animate()
464    }
465
466    return result
467  })
468
469  /** Observes only: every call goes on unchanged; the count feeds the activity sparkline. */
470  on('tool.call', ($, e, next) => {
471    control?.noteToolCall(e.agentId, e.tool)
472
473    return next(e)
474  })
475
476  /** Observes only: a deny any verdict reached is listed in the approvals queue; the verdict passes on unchanged. */
477  on('tool.check', async ($, e, next) => {
478    const verdict = await next(e)
479
480    if (verdict.decision === 'deny') {
481      state.denied.push({ tool: plain(String(e.tool), 40), reason: plain(verdict.reason ?? 'no reason given', 160), atMs: Date.now() })
482      if (state.denied.length > 20) state.denied.shift()
483      record(state.events, [{ atMs: Date.now(), kind: 'mods', text: `${plain(String(e.tool), 40)} denied: ${plain(verdict.reason ?? '', 80)}` }])
484    }
485
486    return verdict
487  })
488
489  /** Observes only, never refuses: which mods the engine admitted or refused after the console, for the plugins view. */
490  on('plugin.register', async ($, e, next) => {
491    const result = await next(e)
492
493    state.mods.push({ name: plain(e.name, 40), provenance: plain(e.provenance, 80), isLoaded: result.refuse === undefined, ...(result.refuse !== undefined && { reason: plain(result.refuse, 120) }), atMs: Date.now() })
494    if (state.mods.length > 50) state.mods.shift()
495    record(state.events, [{ atMs: Date.now(), kind: 'mods', text: `${plain(e.name, 40)} ${result.refuse === undefined ? 'loaded' : 'REFUSED'} (${plain(e.provenance, 60)})` }])
496
497    return result
498  })
499}
500
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/data/abs-path.ts 6 lines
1/** Absolute on either family: `/posix`, a drive path (`C:\x` or `C:/x`) or a UNC share (`\\server\share`). A bare `C:` or `C:x` is drive-relative, so it is not. */
2export const isAbsolutePath = (path: string): boolean => path.startsWith('/') || path.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(path)
3
4/** The path under the working directory unless it already is absolute (`under()` builds `C:\dev\app/.claude-flow/...` on Windows: prefixing that again was #4175). */
5export const rootedPath = (cwd: string, path: string): string => (isAbsolutePath(path) ? path : `${cwd.replace(/[\\/]+$/, '')}/${path}`)
6
hooks/controller.ts 500 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 { sayGroundingHint } from './grounding'
18import { markPicture } from './gfx/pictures'
19import type { Host } from './host'
20import { agentLogs } from './ops'
21import { landingRefusal } from './model-tools'
22import { createRunner, EXPIRY_TIMER, type Runner } from './runner'
23import { advance, mcOf } from './mission-control'
24import { hasLiveWork } from './mission-list'
25import { loadSaved } from './startup-loads'
26import { openLoaders } from './view-open'
27import { listSkills } from './skills'
28import { readDrillLogs } from './drill-logs'
29import { entryAge } from './menu-entry'
30import { BOOT_MIN_MS, CLI_PREFIXES, isBooting, NAV_KEY, NAV_STYLES, PANE_ID, push, rowsOf, storeKeyOf, type State } from './state'
31import type { Actions } from './views/common'
32import { picturesOf } from './views/frames'
33import { pulseDue } from './pulse'
34import { refreshWorkflows } from './wf-live'
35import { syncWhatsNew } from './whatsnew'
36import { syncAdrDigest } from './adr-mission'
37
38const ACTIVITY_BUCKET_MS = 5_000
39const PANE_WATCH_MS = 1_000
40const MAX_PARALLEL_PROBES = 2
41const ALL_PROBES = [...PROBES, ...X_PROBES, ...COST_PROBES, memmapProbe, memoryHealthProbe] // CLI probes, the x.ruv.io board's network reads, cost, the memory map's list: one cadence and option gate
42const BAR_FRESH_MS = 10_000
43const IDLE_REFRESH_MS = 30_000
44const TOOLS_RECOUNT_MS = 30_000
45
46export type Controller = {
47  refresh: () => Promise<void>
48  probe: (force?: boolean) => Promise<void>
49  start: () => void
50  /** Restarts the pane's watch after a reload left the pane up and the timers gone. */
51  resume: () => void
52  stop: () => void
53  open: (focus?: boolean) => Promise<{ isPlaced: boolean; reason: string }>
54  /** At session start, with `panel: auto`: opens where it docks, never taking the keys; else leaves a hint. */
55  autoOpen: () => void
56  /** The person closed the pane (Esc, its mark): auto-open stands down until /ruflo opens it again. */
57  closedByPerson: () => void
58  close: () => Promise<void>
59  setView: (view: State['view']) => void
60  drill: (agentId: string) => void
61  animate: () => void
62  noteToolCall: (agentId: string | undefined, tool: string) => void
63  actions: Actions
64  runner: Runner
65  host: Host
66  /** The command catalog, once `/ruflo commands` has read it. */
67  catalog?: Promise<Catalog>
68  /** Blits the band's mark while Claude works; the band calls it with its requestId. */
69  markFrame: (requestId: string, isWorking: boolean) => void
70}
71
72export function createController(state: State, host: Host): Controller {
73  let activityCount = 0
74  let markRequest: string | null = null
75  let lastSegment: string | null | undefined
76  let lastSpend: number | undefined
77  let hasDrawn = false
78  let toolsCountedAt = 0
79  let inflight: Promise<void> | null = null
80  const lastAttempt = new Map<string, number>()
81
82  const persist = () => void host.storeSet(storeKeyOf(state.cwd), { view: state.view === 'agent' ? state.back : state.view, isClosedByPerson: state.pane.isClosedByPerson }).catch(() => undefined)
83  const isVisible = () => state.pane.isOpen && state.pane.isShown
84
85  function refresh(): Promise<void> {
86    inflight ??= readAll().finally(() => {
87      inflight = null
88    })
89
90    return inflight
91  }
92
93  /** A read that starts after this call: what an action checks, since a read already running may predate its write. */
94  async function freshRead(): Promise<void> {
95    await inflight?.catch(() => undefined)
96
97    // A file the action just created must not wait out the missing-file backoff.
98    for (const [path, held] of state.cache) {
99      if ('missingUntilMs' in held) state.cache.delete(path)
100    }
101
102    await refresh()
103  }
104
105  async function readAll(): Promise<void> {
106    state.isRefreshing = true
107
108    const started = Date.now()
109
110    try {
111      // Claude Code connects MCP servers after the session starts: count the ruflo tools again now and then.
112      if (Date.now() - toolsCountedAt >= TOOLS_RECOUNT_MS) {
113        toolsCountedAt = Date.now()
114        void host.rufloTools().then(counted => void (state.rufloTools = counted), () => undefined)
115      }
116
117      const [settings, usage, ruflo, route] = await Promise.all([
118        host.settings().catch(() => null),
119        host.usage().catch(() => null),
120        host.rufloSnapshot().catch((error: unknown) => {
121          state.ruflo.error = plain(String(error), 120)
122
123          return null
124        }),
125        host.rufloRoute().catch(() => null),
126      ])
127      const previous = state.snapshot
128      const now = Date.now()
129      const snapshot = await readSnapshot(host.fs, state.cache, state.cwd, state.home, settings, now, state.configDir, state.options.federationNetwork)
130      if (snapshot.hasNostrKey === false) state.nostrKeyVerifiedAtMs = null
131      // What changed since the last read is announced on the band (the first read announces nothing).
132      const before = previous === null ? null : factsOf(state, now)
133
134      state.snapshot = snapshot
135      syncWhatsNew(state, host)
136      void syncAdrDigest(state, host).catch(() => undefined)
137      sayGroundingHint(state, host)
138      record(state.events, diffEvents(previous, snapshot, now))
139
140      // A mission that finished or lost a task is said in a toast too: the band's notice row reaches only a person looking at the console.
141      if (before !== null) for (const draft of announceChanges(state, before, now)) if (TOASTED_KEYS.has(draft.key)) host.toast(draft.text.slice(0, 120), 8000, draft.level === 'bad' ? 'error' : draft.level)
142
143      if (route !== null && route.agent !== state.ruflo.route?.agent) record(state.events, [{ atMs: now, kind: 'learning', text: `router picked ${route.agent} (${Math.round(route.confidence * 100)}%)` }])
144
145      state.usage = usage
146      state.ruflo.snapshot = ruflo
147      state.ruflo.route = route ?? ruflo?.lastRoute ?? null
148      push(state.writes, snapshot.changed)
149      for (const agent of snapshot.agents.slice(0, 200)) {
150        const log = state.statusLog.get(agent.id) ?? []
151
152        if (log.at(-1)?.status !== agent.status) push(log, { atMs: now, status: agent.status }, 100)
153        state.statusLog.set(agent.id, log)
154      }
155
156      const patterns = snapshot.neural?.patterns
157      if (patterns !== undefined && state.history.patterns.at(-1)?.value !== patterns) push(state.history.patterns, { atMs: now, value: patterns })
158      if (usage?.costUsd !== undefined && state.history.spend.at(-1)?.value !== usage.costUsd) push(state.history.spend, { atMs: now, value: usage.costUsd })
159      if ((snapshot.outcomes?.total ?? 0) > state.history.outcomes) {
160        if (state.history.outcomes > 0) state.curveGrewAtMs = now
161        state.history.outcomes = snapshot.outcomes?.total ?? 0
162      }
163
164      if (state.options.bar === 'off') {
165        const text = segmentOf(state)
166
167        if (text !== lastSegment) {
168          lastSegment = text
169          void host.rufloSegment(text).catch(() => undefined)
170        }
171      }
172    } catch (error) {
173      state.ruflo.error = plain(String(error), 120)
174    } finally {
175      state.isRefreshing = false
176      push(state.stats.refreshes, Date.now() - started, 200)
177
178      // Redraw only for something new while the pane is closed: the band need not repaint an unchanged line.
179      const spend = state.usage?.costUsd
180
181      if (state.pane.isOpen || (state.snapshot?.changed ?? 0) > 0 || spend !== lastSpend || !hasDrawn) {
182        hasDrawn = true
183        lastSpend = spend
184        host.invalidate()
185      }
186    }
187  }
188
189  const probesInFlight = new Map<string, Promise<void>>()
190
191  /** One probe run at a time per probe: a second ask while it runs joins it. */
192  function runProbe(probe: (typeof ALL_PROBES)[number]): Promise<void> {
193    const held = probesInFlight.get(probe.id)
194
195    if (held !== undefined) return held
196
197    const run = runProbeOnce(probe).finally(() => probesInFlight.delete(probe.id))
198
199    probesInFlight.set(probe.id, run)
200
201    return run
202  }
203
204  async function runProbeOnce(probe: (typeof ALL_PROBES)[number]): Promise<void> {
205    const held: ProbeResult = state.probes.get(probe.id) ?? { value: null, okAtMs: null, error: null, errorAtMs: null, isRunning: false }
206
207    state.probes.set(probe.id, { ...held, isRunning: true })
208    lastAttempt.set(probe.id, Date.now())
209
210    try {
211      const argv = probeArgv(probe, state.options.cli, state)
212      const result = await host.run(argv, probe.timeoutMs)
213      const value = result.exitCode === 0 ? (probe.parse(result.stdout) as unknown) : null
214      state.probes.set(
215        probe.id,
216        value !== null
217          ? { value, okAtMs: Date.now(), error: null, errorAtMs: held.errorAtMs, isRunning: false }
218          : {
219              ...held,
220              isRunning: false,
221              errorAtMs: Date.now(),
222              error: probeError(argv, result),
223            },
224      )
225    } catch (error) {
226      state.probes.set(probe.id, { ...held, isRunning: false, errorAtMs: Date.now(), error: plain(error instanceof Error ? error.message : String(error), 100) || 'refused' })
227    } finally {
228      host.invalidate()
229    }
230  }
231
232  /** Runs the probes the view in front draws, each no more often than its cadence; `force` ignores the cadence. */
233  async function probe(force = false): Promise<void> {
234    const now = Date.now()
235    const due = ALL_PROBES.filter(
236      entry =>
237        (isVisible() || force) &&
238        entry.views.includes(state.view) &&
239        (!entry.isNetwork || state.options.federationNetwork) && probeReady(entry, state) &&
240        (force || (state.probes.get(entry.id)?.isRunning !== true && now - (lastAttempt.get(entry.id) ?? 0) >= entry.everyMs)),
241    )
242
243    for (let i = 0; i < due.length; i += MAX_PARALLEL_PROBES) {
244      await Promise.all(due.slice(i, i + MAX_PARALLEL_PROBES).map(entry => runProbe(entry)))
245    }
246  }
247
248  function every(name: string, ms: number, fn: () => void): void {
249    if (!state.timers.has(name)) state.timers.set(name, host.every(ms, fn))
250  }
251
252  function cancel(name: string): void {
253    state.timers.get(name)?.cancel()
254    state.timers.delete(name)
255  }
256
257  // Whether the last frame drew the boot screen: when it ends the whole pane redraws once, and an unfocused pane's loop stops again.
258  let wasBooting = false
259
260  /** One frame of every picture of the view in front, each blitted only at the size it was mounted. */
261  function frame(): void {
262    const started = Date.now()
263    const booting = isBooting(state, started)
264
265    if (wasBooting && !booting) {
266      wasBooting = false
267      state.pane.menuAtMs = Date.now()
268      host.invalidate()
269      animate()
270
271      return
272    }
273
274    wasBooting = booting
275
276    if (pulseDue(state.view, started) || (state.view === 'menu' && entryAge({ look: state.options.look, boot: state.options.boot, ...state.pane }, started, BOOT_MIN_MS) !== null)) host.invalidate()
277
278    // Only a mounted Raster is blitted, so with none (a surface that draws Svg, which redraws with the pane) nothing is rebuilt here.
279    for (const [key, grid] of state.mounted.size === 0 ? [] : picturesOf(state, state.pane.columns, Date.now(), Date.now())) {
280      const mounted = state.mounted.get(key)
281
282      if (mounted !== undefined && mounted.columns === grid.columns && mounted.rows === grid.rows) {
283        host.blit({ requestId: PANE_ID, key, cells: grid.encode(), columns: grid.columns, rows: grid.rows })
284      }
285    }
286
287    push(state.stats.frames, Date.now() - started, 200)
288  }
289
290  /** Runs the frame loop while the pane is shown and holds the keys (or plays the boot screen), at `fps`; stops it otherwise. */
291  function animate(): void {
292    // Something in progress moves its spinner, pictured or not: a lab action in flight, or a live mission, task or guidance run on the Missions page.
293    const moving = state.lab.running !== null || (state.view === 'missions' && hasLiveWork(state.snapshot?.missions?.missions ?? [], mcOf(state).guidance?.status === 'running'))
294
295    if (!(state.options.fps > 0 && isVisible() && (state.pane.isFocused || isBooting(state, Date.now())) && (state.pictured > 0 || moving))) return cancel('frames')
296
297    every('frames', Math.round(1000 / state.options.fps), frame)
298  }
299
300  /** Watches whether the pane is shown and focused, so the loop stops behind another tab and resumes in front. */
301  async function watchPane(): Promise<void> {
302    const panes = await host.panes().catch(() => null)
303    const mine = panes?.find(pane => pane.id === PANE_ID)
304
305    if (panes !== null) {
306      state.pane.isOpen = mine !== undefined
307      state.pane.isShown = mine?.isShown === true
308      state.pane.isFocused = mine?.isFocused === true
309    }
310
311    if (!state.pane.isOpen) cancel('watch')
312
313    animate()
314  }
315
316  function start(): void {
317    let lastIdleMs = 0
318
319    // The AI terminal's saved model and budget apply from the first turn, not only once Settings was opened.
320    loadSaved(state, host)
321    void host.storeGet(NAV_KEY).then(saved => {
322      const style = NAV_STYLES.find(candidate => candidate === saved)
323
324      if (style !== undefined) state.nav = style
325    }, () => undefined)
326
327    every('refresh', state.options.refreshSeconds * 1000, () => {
328      const now = Date.now()
329      const isSeen = state.pane.isOpen || now - state.barDrawnAtMs < BAR_FRESH_MS
330
331      // Nothing on screen reads the disk: re-read only on the idle cadence, so a closed console costs nearly nothing.
332      if (isSeen || now - lastIdleMs >= IDLE_REFRESH_MS) {
333        lastIdleMs = now
334        void refresh().then(() => {
335          void probe()
336          void refreshWorkflows(state, host)
337          advance(state, host)
338        })
339      }
340    })
341    every('activity', ACTIVITY_BUCKET_MS, () => {
342      push(state.activity, activityCount)
343      activityCount = 0
344    })
345  }
346
347  const resume = () => every('watch', PANE_WATCH_MS, () => void watchPane())
348
349  function stop(): void {
350    for (const timer of state.timers.values()) timer.cancel()
351    state.timers.clear()
352  }
353
354  /** `closeOnEscape` false: take the keys but leave Esc handing them back, as an auto-opened pane does. */
355  async function open(focus = true, closeOnEscape = focus): Promise<{ isPlaced: boolean; reason: string }> {
356    try {
357      const result = await host.openPane({ id: PANE_ID, title: 'ruflo', rows: rowsOf(state.view), ...(state.dockColumns > 0 && { columns: state.dockColumns }), ...(focus && { focus: true, holdToasts: true }), ...(closeOnEscape && { closeOnEscape: true }) })
358      const isPlaced = result === undefined || result.isPlaced !== false
359
360      if (isPlaced && !state.pane.isOpen) state.pane.bootAtMs = Date.now()
361      state.pane.isOpen = isPlaced
362      state.pane.isShown = isPlaced
363      if (focus) state.pane.isClosedByPerson = false
364      if (isPlaced) persist()
365      resume()
366      void refresh().then(() => probe(true))
367
368      return { isPlaced, reason: result?.reason ?? '' }
369    } catch (error) {
370      return { isPlaced: false, reason: plain(error instanceof Error ? error.message : String(error), 160) }
371    }
372  }
373
374  function autoOpen(): void {
375    if (state.options.panel !== 'auto' || state.snapshot?.isRufloProject !== true || state.pane.isOpen || state.pane.isClosedByPerson || state.pane.autoTried) return
376
377    state.pane.autoTried = true
378    // From a timer, never a render hook; without `focus`, so the prompt keeps the keys. The engine seats an unasked pane
379    // only where it docks (144 columns and up) and answers why not otherwise: the band then says "/ruflo to open".
380    state.timers.set(
381      'auto-open',
382      host.after(50, () => {
383        state.timers.delete('auto-open')
384        // /ruflo <view> may have opened it in the meantime: that choice stands.
385        if (state.pane.isOpen) return
386        // The BBS look opens on its main menu, as a board does after login.
387        if (state.options.look === 'bbs') state.view = 'menu'
388        void open(false).then(result => {
389          state.pane.autoReason = result.isPlaced ? '' : result.reason || 'not placed'
390          host.invalidate()
391        })
392      }),
393    )
394  }
395
396  async function close(): Promise<void> {
397    state.pane.isOpen = false
398    state.pane.isShown = false
399    state.pane.isClosedByPerson = true
400    persist()
401    // A waiting card's expiry timer does not outlive the pane either: past its window it is cleared at the next ask or tool call instead.
402    for (const name of ['frames', 'watch', EXPIRY_TIMER]) cancel(name)
403    await host.closePane(PANE_ID).catch(() => undefined)
404  }
405
406  function setView(view: State['view']): void {
407    state.isHelp = false
408    state.palette.isOpen = false
409
410    if (view !== state.view) {
411      if (view === 'agent' || state.view !== 'agent') state.back = state.view === 'agent' ? state.back : state.view
412      state.view = view
413      // A group picked on one page (the menu's pages row) does not follow you to the next, or back to this one.
414      state.navPick = null
415      state.pane.viewAtMs = Date.now()
416      state.select.item = 0
417      state.mounted.clear()
418      state.pictured = 0
419      persist()
420      // A new view asks for its own height inline; the dock ignores it.
421      if (state.pane.isOpen) void host.openPane({ id: PANE_ID, title: 'ruflo', rows: rowsOf(view), ...(state.dockColumns > 0 && { columns: state.dockColumns }) }).catch(() => undefined)
422      void probe(true)
423      host.scrollTop()
424    }
425
426    host.invalidate()
427    // The terminal is for typing: its field takes the keys as it opens, so letters reach it, not the pane's hotkeys.
428    if (view === 'terminal') focusField('term-input')
429    // Opening the skills view is the person asking for its lists (npx skills reaches the network, so never unasked).
430    if (view === 'skills') {
431      void listSkills(state, host)
432      focusField('skills-search')
433    }
434    openLoaders(state, host, view)
435  }
436
437  /**
438   * Puts the keys in one of the pane's fields. A pane that opened by itself (panel=auto) does not hold the keys, and a
439   * mouse click on a tab does not give them, so a person who clicked their way to the terminal would type into
440   * Claude's prompt instead. Here the pane takes the keys first (an open with focus), then the ring moves to the field.
441   */
442  function focusField(key: string): void {
443    if (!state.pane.isOpen) return
444
445    const toField = () => void host.focus(PANE_ID, key).catch(() => undefined)
446
447    if (state.pane.isFocused) toField()
448    else void open(true, false).then(result => result.isPlaced && toField())
449  }
450
451  function drill(agentId: string): void {
452    const agent = state.snapshot?.agents.find(entry => entry.id === agentId)
453
454    state.drill = { agentId, logs: null, logsAtMs: 0 }
455    setView('agent')
456
457    const spec = agent === undefined ? null : agentLogs(agent)
458
459    if (spec !== null) readDrillLogs(state, host, agentId, spec.args)
460  }
461
462  const runner = createRunner(state, host, {
463    freshRead, setView, drill,
464    command: name => (name === 'refresh' ? actions.refresh() : name === 'help' ? actions.help() : actions.close()),
465    landingRefusal: pending => landingRefusal(state, pending),
466  })
467  const actions: Actions = actionsOf(state, host, runner, { freshRead, probe, setView, drill, close, animate })
468
469  function markFrame(requestId: string, isWorking: boolean): void {
470    markRequest = requestId
471
472    if (isWorking && state.options.fps > 0) {
473      every('mark', Math.round(1000 / state.options.fps), () => {
474        if (markRequest !== null) host.blit({ requestId: markRequest, key: 'mark', cells: markPicture(true, Date.now()).encode(), columns: 2, rows: 1 })
475      })
476    } else {
477      cancel('mark')
478    }
479  }
480
481  function noteToolCall(agentId: string | undefined, tool: string): void {
482    activityCount += 1
483
484    const who = agentId ?? 'main'
485    const list = state.toolsByAgent.get(who) ?? []
486
487    push(list, { atMs: Date.now(), tool: plain(tool, 40) }, 200)
488    state.toolsByAgent.set(who, list)
489    if (state.toolsByAgent.size > 50) state.toolsByAgent.delete(state.toolsByAgent.keys().next().value as string)
490    record(state.events, [{ atMs: Date.now(), kind: 'tools', text: `${agentName(state, agentId)}: ${plain(tool, 40)}` }])
491  }
492
493  const closedByPerson = () => {
494    state.pane.isClosedByPerson = true
495    persist()
496  }
497
498  return { refresh, probe, start, resume, stop, open, autoOpen, closedByPerson, close, setView, drill, animate, noteToolCall, actions, runner, markFrame, host }
499}
500
hooks/sessions.ts 295 lines
1/**
2 * The session workspace's runtime (ADR-486): one Workspace per console state (a side table, so nothing a transcript said can reach the state
3 * that is snapshotted, exported or shown to a model), the scan that fills it, the selection, and the "read means viewed" commit.
4 *
5 * Scans are stat-first and incremental (data/harness-track.ts): a fast pass once a second while the pane is shown, a full relist every 15 s,
6 * and almost nothing while it is closed. Nothing here starts a process, makes a model call, or touches a session it lists.
7 */
8import { buildIndex, type Index, type Scanned } from './data/sessions-index'
9import { attentionOf, countsOf, markViewed, needsRewrite, viewedOf, type AttentionItem, type Viewed } from './data/sessions-attention'
10import { claudeAdapter } from './data/harness-claude'
11import { codexAdapter } from './data/harness-codex'
12import { passBudget } from './data/harness-track'
13import { rufloAdapter, type LedgerFacts, type RufloInput } from './data/harness-ruflo'
14import type { HarnessAdapter, SessionFs } from './data/harness'
15import { mcOf } from './mission-control'
16import { replaceFile } from './activity-io'
17import type { Host } from './host'
18import type { State } from './state'
19
20/** The fold key of the Sessions section (views/common.ts `section`: present means flipped from its default, which is closed). */
21export const SESSIONS_FOLD = 'room/sessions'
22export const VIEWED_KEY = 'ruflo-console/sessions-viewed'
23const FAST_MS = 1000
24const FULL_MS = 15_000
25const CLOSED_MS = 30_000
26const SHOWN_TTL_MS = 10_000
27
28export type Workspace = {
29  adapters: HarnessAdapter[]
30  index: Index | null
31  selected: string | null
32  /** Which attention row the cursor is on (an index into the queue). */
33  cursor: number
34  viewed: Viewed | null
35  scanning: boolean
36  lastScanMs: number
37  lastFullMs: number
38  scanTimes: number[]
39  /** Rows whose preview text a frame actually drew, with when: the page's evidence that content was displayed (data/sessions-attention.ts). */
40  shown: Map<string, number>
41  /** A line about the last action that could not run, with its reason. */
42  note: string
43  signature: string
44  /** The counts last written to the summary file, so it is rewritten only when one changes or the heartbeat is due. */
45  summary: string
46  /** When the summary file was last written. */
47  summaryAtMs: number
48  /** After a failed summary write: how long the current wait is, and when the next attempt may run. */
49  writeBackoffMs: number
50  writeRetryAtMs: number
51  timer: { cancel: () => void } | null
52}
53
54const spaces = new WeakMap<State, Workspace>()
55
56export function rufloInputOf(state: State, nowMs: number): RufloInput {
57  const ledger = new Map<string, LedgerFacts>()
58
59  for (const mission of mcOf(state).missions.values()) ledger.set(mission.id, { ...(mission.adrs !== undefined && { adrs: mission.adrs }), tasks: mission.tasks.map(task => ({ id: task.id, dependsOn: task.dependsOn, ...(task.rufloTaskId !== undefined && { rufloTaskId: task.rufloTaskId }) })) })
60
61  return { cwd: state.cwd, missions: state.snapshot?.missions ?? null, agents: state.snapshot?.agents ?? [], nowMs, ledger, claimedIssues: new Set((state.snapshot?.claims ?? []).filter(claim => claim.status !== 'released').map(claim => claim.issueId)) }
62}
63
64export function workspaceOf(state: State): Workspace {
65  let found = spaces.get(state)
66
67  if (found === undefined) {
68    found = { adapters: [claudeAdapter(), codexAdapter(), rufloAdapter(() => rufloInputOf(state, Date.now()))], index: null, selected: null, cursor: 0, viewed: null, scanning: false, lastScanMs: 0, lastFullMs: 0, scanTimes: [], shown: new Map(), note: '', signature: '', summary: '', summaryAtMs: 0, writeBackoffMs: 0, writeRetryAtMs: 0, timer: null }
69    spaces.set(state, found)
70  }
71
72  return found
73}
74
75/** The native ids of the sessions the console itself started (the terminal's saved conversations): everything else is outside Ruflo. */
76export const ownIds = (state: State): ReadonlySet<string> => new Set(Object.values(state.terminal.sessions).filter((id): id is string => typeof id === 'string'))
77
78const codexDirOf = (state: State): string | null => (state.home === null ? null : `${state.home.replace(/\/+$/, '')}/.codex`)
79
80/** One pass over every adapter. Never rejects; a failing adapter leaves its report saying so and keeps its earlier rows. */
81export async function scanSessions(state: State, host: Pick<Host, 'fs' | 'invalidate'>, full: boolean, nowMs: number = Date.now()): Promise<void> {
82  const ws = workspaceOf(state)
83
84  if (ws.scanning) return
85
86  ws.scanning = true
87
88  const started = performance.now()
89
90  try {
91    // One read budget for the whole pass, shared by every adapter (ADR-486 §2.4).
92    const env = { fs: host.fs as SessionFs, claudeDir: state.configDir, codexDir: codexDirOf(state), nowMs, full, budget: passBudget() }
93    const scans: Scanned[] = await Promise.all(
94      ws.adapters.map(async adapter => ({
95        id: adapter.id,
96        label: adapter.label,
97        capabilities: adapter.capabilities,
98        result: await adapter.scan(env).catch(() => ({ state: 'failed' as const, rows: [], note: 'the scan threw; nothing was read' })),
99      })),
100    )
101
102    ws.index = buildIndex(scans, ownIds(state), nowMs)
103    ws.lastScanMs = nowMs
104    if (full) ws.lastFullMs = nowMs
105    if (ws.selected !== null && !ws.index.rows.some(row => row.key === ws.selected)) ws.selected = null
106    ws.scanTimes.push(performance.now() - started)
107    if (ws.scanTimes.length > 50) ws.scanTimes.shift()
108
109    const signature = `${ws.index.rows.map(row => `${row.key}:${row.updatedMs}:${row.status}:${row.stale ?? ''}`).join(',')}|${state.pending?.label ?? ''}`
110
111    if (signature !== ws.signature) {
112      ws.signature = signature
113      if (state.view === 'room' || state.view === 'overview') host.invalidate()
114    }
115  } finally {
116    ws.scanning = false
117  }
118}
119
120/** The queue as the page draws it (the console's own pending confirm included). Empty until the viewed state has loaded. */
121export function queueOf(state: State): AttentionItem[] {
122  const ws = workspaceOf(state)
123
124  if (ws.index === null || ws.viewed === null) return state.pending === null ? [] : attentionOf([], { baselineMs: Date.now(), seen: {} }, { label: state.pending.label, askedAtMs: state.pending.askedAtMs })
125
126  return attentionOf(ws.index.rows, ws.viewed, state.pending === null ? null : { label: state.pending.label, askedAtMs: state.pending.askedAtMs })
127}
128
129export const attentionCounts = (state: State) => countsOf(queueOf(state))
130
131/** Selecting a row changes only which row is looked at: no read, no process, no wake. */
132export function selectRow(state: State, key: string | null): void {
133  const ws = workspaceOf(state)
134
135  ws.selected = key !== null && ws.index?.rows.some(row => row.key === key) === true ? key : null
136  ws.note = ''
137}
138
139/** The view calls this when it drew a row's preview text. It only notes the fact; `commitViewed` acts on it. */
140export function noteShown(state: State, key: string, atMs: number = Date.now()): void {
141  workspaceOf(state).shown.set(key, atMs)
142}
143
144/**
145 * Read means viewed: an unread completion or a failure clears only for a row whose preview text a frame drew in the last few seconds while
146 * the Room was the page in front. A placeholder, an error line, a hidden pane or another page clears nothing. Returns whether anything cleared.
147 */
148export function commitViewed(state: State, host: Pick<Host, 'storeSet' | 'invalidate'>, nowMs: number = Date.now()): boolean {
149  const ws = workspaceOf(state)
150
151  for (const [key, at] of ws.shown) if (nowMs - at > SHOWN_TTL_MS) ws.shown.delete(key)
152
153  if (ws.viewed === null || state.view !== 'room' || !state.pane.isShown || ws.shown.size === 0) return false
154
155  const items = queueOf(state).filter(item => item.rowKey !== null && ws.shown.has(item.rowKey))
156  const next = markViewed(ws.viewed, items)
157
158  ws.shown.clear()
159
160  if (next === ws.viewed) return false
161
162  ws.viewed = next
163  void host.storeSet(VIEWED_KEY, next).catch(() => undefined)
164  host.invalidate()
165
166  return true
167}
168
169/** Where the counts-only summary ruflo-mods can show lives: the console's own state folder, in a ruflo project, rewritten when a count changes or the heartbeat is due. */
170export const ATTENTION_FILE = '.claude-flow/console/attention.json'
171/** The file is rewritten at least this often while the workspace runs, so a reader can tell a quiet queue from a console that stopped (ruflo-mods hooks/attention.ts ATTENTION_STALE_MS). */
172export const ATTENTION_HEARTBEAT_MS = 5 * 60_000
173/** The first wait after a failed write of the summary; it doubles up to ATTENTION_HEARTBEAT_MS. */
174export const WRITE_RETRY_MS = 30_000
175
176export async function mirrorAttention(state: State, host: Pick<Host, 'fs' | 'run'>, nowMs: number = Date.now()): Promise<void> {
177  const ws = workspaceOf(state)
178
179  if (ws.viewed === null || ws.index === null || state.snapshot?.isRufloProject !== true) return
180
181  const counts = attentionCounts(state)
182  const key = `${counts['needs-approval']}/${counts.question}/${counts.failed}/${counts['completed-unread']}`
183
184  if (key === ws.summary && nowMs - ws.summaryAtMs < ATTENTION_HEARTBEAT_MS) return
185  if (nowMs < ws.writeRetryAtMs) return
186
187  ws.summary = key
188  ws.summaryAtMs = nowMs
189  // Counts only: no title, path or transcript text is in this file.
190  const text = `${JSON.stringify({ schema: 'ruflo-console.attention/1', approve: counts['needs-approval'], question: counts.question, failed: counts.failed, unread: counts['completed-unread'], atMs: nowMs })}\n`
191
192  if ((await replaceFile(host, state.cwd, `${state.cwd.replace(/\/+$/, '')}/${ATTENTION_FILE}`, text)) === null) {
193    ws.writeBackoffMs = 0
194    ws.writeRetryAtMs = 0
195
196    return
197  }
198
199  // A failed write is retried, but not every tick: 30 s, doubling to 5 minutes, until one succeeds.
200  ws.summary = ''
201  ws.writeBackoffMs = ws.writeBackoffMs === 0 ? WRITE_RETRY_MS : Math.min(ATTENTION_HEARTBEAT_MS, ws.writeBackoffMs * 2)
202  ws.writeRetryAtMs = nowMs + ws.writeBackoffMs
203}
204
205export type SessionActions = {
206  select: (key: string) => void
207  /** Moves the selection through the rows in the order the page lists them. */
208  move: (by: number) => void
209  /** Selects the session an attention item is about (the console's own confirm has none). */
210  jump: (itemId: string) => void
211  refresh: () => void
212  /** Enter on a row: opens it natively only where the harness declared it can; otherwise says why not. */
213  open: (key: string) => void
214}
215
216export function sessionActions(state: State, host: Pick<Host, 'fs' | 'invalidate'>): SessionActions {
217  const ws = workspaceOf(state)
218  const order = () => ws.index?.groups.flatMap(group => group.rows) ?? []
219
220  return {
221    select: key => {
222      selectRow(state, key)
223      host.invalidate()
224    },
225    move: by => {
226      const rows = order()
227      const at = rows.findIndex(row => row.key === ws.selected)
228
229      if (rows.length > 0) selectRow(state, rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? (by > 0 ? -1 : rows.length) : at) + by))]?.key ?? null)
230      host.invalidate()
231    },
232    jump: id => {
233      const item = queueOf(state).find(candidate => candidate.id === id)
234
235      if (item?.rowKey != null) {
236        selectRow(state, item.rowKey)
237        // The browser starts folded so the Room's own sections stay in view; jumping to a session opens it.
238        state.sections.add(SESSIONS_FOLD)
239      }
240      host.invalidate()
241    },
242    refresh: () => void scanSessions(state, host, true),
243    open: key => {
244      const row = ws.index?.rows.find(candidate => candidate.key === key)
245      const caps = ws.index?.reports.find(report => report.id === row?.harness)?.capabilities
246
247      ws.note = row === undefined ? 'that session is gone' : caps?.resume.supported === true ? 'this harness can open sessions, but phase 1 wires no opener' : `open is unavailable for ${row.harness}: ${caps?.resume.why ?? 'not declared'}`
248      host.invalidate()
249    },
250  }
251}
252
253/**
254 * Loads the viewed map. The baseline is the first time this queue ever ran: older completions are history, not unread. It is stored so a
255 * restart keeps it. A stored value that was not a Viewed, or held a stamp or baseline in the future, is written back repaired, once.
256 */
257export async function loadViewed(state: State, host: Pick<Host, 'storeGet' | 'storeSet'>, nowMs: number = Date.now()): Promise<void> {
258  const ws = workspaceOf(state)
259  let raw: unknown
260
261  try {
262    raw = await host.storeGet(VIEWED_KEY)
263  } catch {
264    ws.viewed = viewedOf(null, nowMs)
265
266    return
267  }
268
269  ws.viewed = viewedOf(raw, nowMs)
270  if (needsRewrite(raw, ws.viewed)) await host.storeSet(VIEWED_KEY, ws.viewed).catch(() => undefined)
271}
272
273/** Starts the watcher once. A closed pane scans rarely; a shown one every second (stat-only unless something changed). */
274export function startSessions(state: State, host: Host): void {
275  const ws = workspaceOf(state)
276
277  if (ws.timer !== null) return
278
279  void loadViewed(state, host)
280
281  ws.timer = host.every(FAST_MS, () => {
282    if (!state.options.sessionWorkspace) return
283
284    const now = Date.now()
285    const isSeen = state.pane.isOpen && state.pane.isShown
286
287    if (!isSeen && now - ws.lastScanMs < CLOSED_MS) return
288
289    void scanSessions(state, host, now - ws.lastFullMs >= FULL_MS, now).then(() => {
290      commitViewed(state, host)
291      void mirrorAttention(state, host, now)
292    })
293  })
294}
295
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 501 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  // By instant, not text: "…T10:00+02:00" is older than "…T09:00Z" though it sorts after it as a string. Unparseable sorts last.
183  const byRecency = (a: SwarmInfo, b: SwarmInfo) => (msOf(b.updatedAt) ?? -Infinity) - (msOf(a.updatedAt) ?? -Infinity) || 0
184
185  return [...swarms.filter(swarm => swarm.status === 'running')].sort(byRecency)[0] ?? [...swarms].sort(byRecency)[0] ?? null
186}
187
188/** `.swarm/state.json`: the pointer `swarm init` (or `swarm start`) leaves. */
189export function parseSwarmPointer(text: string | null): { id: string; topology?: string; strategy?: string; status?: string } | null {
190  const value = jsonObject(text)
191  const id = idOf(value?.id) ?? idOf(value?.swarmId)
192
193  if (value === null || id === null) {
194    return null
195  }
196
197  const pointer: { id: string; topology?: string; strategy?: string; status?: string } = { id }
198  const topology = stringOf(value.topology, 40)
199  const strategy = stringOf(value.strategy, 40)
200  const status = stringOf(value.status, 40)
201
202  if (topology !== undefined) pointer.topology = topology
203  if (strategy !== undefined) pointer.strategy = strategy
204  if (status !== undefined) pointer.status = status
205
206  return pointer
207}
208
209/** `.claude-flow/agents/store.json`. */
210export function parseAgents(text: string | null): AgentRecord[] {
211  return valuesOf(jsonObject(text)?.agents).flatMap(entry => {
212    const agent = recordOf(entry)
213    const id = idOf(agent?.agentId)
214
215    if (agent === null || id === null) {
216      return []
217    }
218
219    const record: AgentRecord = { id, type: stringOf(agent.agentType, 40) ?? 'agent', status: stringOf(agent.status, 20) ?? 'unknown' }
220    const name = stringOf(agent.name, 40)
221    const health = ratioOf(agent.health)
222    const taskCount = countOf(agent.taskCount)
223    const createdAtMs = msOf(agent.createdAt)
224
225    if (name !== undefined) record.name = name
226    if (health !== undefined) record.health = health
227    if (taskCount !== undefined) record.taskCount = taskCount
228    if (createdAtMs !== undefined) record.createdAtMs = createdAtMs
229
230    return [record]
231  })
232}
233
234/** `.claude-flow/tasks/store.json`. */
235/** A task's result object as one bounded line of `key: value` pairs (strings and numbers only), or undefined. */
236function resultTextOf(value: unknown): string | undefined {
237  const result = recordOf(value)
238
239  if (result === null) return typeof value === 'string' ? plain(value, 400) || undefined : undefined
240
241  const text = Object.entries(result)
242    .slice(0, 8)
243    .flatMap(([key, v]) => (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? [`${plain(key, 24)}: ${plain(String(v), 160)}`] : []))
244    .join(' · ')
245
246  return text === '' ? undefined : text.slice(0, 500)
247}
248
249export function parseTasks(text: string | null): TaskRecord[] {
250  return valuesOf(jsonObject(text)?.tasks).flatMap(entry => {
251    const task = recordOf(entry)
252    const id = idOf(task?.taskId)
253
254    return task === null || id === null
255      ? []
256      : [
257          {
258            id,
259            type: stringOf(task.type, 40) ?? 'task',
260            description: plain(task.description, 200),
261            status: stringOf(task.status, 20) ?? 'unknown',
262            assignedTo: (Array.isArray(task.assignedTo) ? task.assignedTo : []).slice(0, 50).flatMap(agent => (idOf(agent) !== null ? [agent as string] : [])),
263            ...(msOf(task.createdAt) !== undefined && { createdAtMs: msOf(task.createdAt) }),
264            tags: (Array.isArray(task.tags) ? task.tags : []).slice(0, 12).flatMap(tag => (typeof tag === 'string' && /^[A-Za-z0-9_.:-]{1,90}$/.test(tag) ? [tag] : [])),
265            ...(msOf(task.startedAt) !== undefined && { startedAtMs: msOf(task.startedAt) }),
266            ...(msOf(task.completedAt) !== undefined && { completedAtMs: msOf(task.completedAt) }),
267            ...(resultTextOf(task.result) !== undefined && { resultText: resultTextOf(task.result) }),
268          },
269        ]
270  })
271}
272
273function claimantOf(value: unknown): Claimant | null {
274  const claimant = recordOf(value)
275
276  if (claimant === null) {
277    return null
278  }
279
280  const isAgent = claimant.type === 'agent'
281  const id = idOf(isAgent ? claimant.agentId : claimant.userId)
282
283  if (id === null) {
284    return null
285  }
286
287  const who: Claimant = { kind: isAgent ? 'agent' : 'human', id }
288  const agentType = stringOf(claimant.agentType, 40)
289  const name = stringOf(claimant.name, 40)
290
291  if (agentType !== undefined) who.agentType = agentType
292  if (name !== undefined) who.name = name
293
294  return who
295}
296
297/** `.claude-flow/claims/claims.json`: issue claims (who works on what), not the authorization file `.claude-flow/claims.json`. */
298export function parseClaims(text: string | null): ClaimRecord[] {
299  const store = jsonObject(text)
300  const stealable = recordOf(store?.stealable) ?? {}
301
302  return valuesOf(store?.claims).flatMap(entry => {
303    const claim = recordOf(entry)
304    const issueId = idOf(claim?.issueId)
305    const claimant = claimantOf(claim?.claimant)
306
307    if (claim === null || issueId === null || claimant === null) {
308      return []
309    }
310
311    const handoff = recordOf(claim.handoffTo)
312    const handoffTo = idOf(handoff?.agentId ?? handoff?.userId)
313    const record: ClaimRecord = {
314      issueId,
315      status: stringOf(claim.status, 30) ?? 'unknown',
316      claimant,
317      isStealable: claim.status === 'stealable' || Object.hasOwn(stealable, issueId),
318    }
319    const progress = numberOf(claim.progress)
320    const claimedAtMs = msOf(claim.claimedAt)
321    const changedAtMs = msOf(claim.statusChangedAt)
322    const expiresAtMs = msOf(claim.expiresAt)
323    const context = stringOf(claim.context, 120)
324
325    if (progress !== undefined) record.progress = Math.max(0, Math.min(100, progress))
326    if (handoffTo !== null) record.handoffTo = handoffTo
327    if (claimedAtMs !== undefined) record.claimedAtMs = claimedAtMs
328    if (changedAtMs !== undefined) record.changedAtMs = changedAtMs
329    if (expiresAtMs !== undefined) record.expiresAtMs = expiresAtMs
330    if (context !== undefined) record.context = context
331
332    return [record]
333  })
334}
335
336const votesOf = (value: unknown): { votesFor: number; votesAgainst: number; ballots: Ballot[] } => {
337  const ballots = Object.entries(recordOf(value) ?? {})
338    .slice(0, MAX_RECORDS)
339    .flatMap(([voter, vote]) => (idOf(voter) !== null && typeof vote === 'boolean' ? [{ voter, isFor: vote }] : []))
340
341  return { votesFor: ballots.filter(ballot => ballot.isFor).length, votesAgainst: ballots.filter(ballot => !ballot.isFor).length, ballots }
342}
343
344const idsOf = (value: unknown, max = 50): string[] => (Array.isArray(value) ? value : []).slice(0, max).flatMap(entry => (idOf(entry) !== null ? [entry as string] : []))
345
346/** A proposal's value as one bounded line: a string as it is, anything else as its JSON. */
347function valueText(value: unknown): string | undefined {
348  if (typeof value === 'string') return stringOf(value, 120)
349  if (value === undefined || value === null) return undefined
350
351  try {
352    return stringOf(JSON.stringify(value).slice(0, 400), 120)
353  } catch {
354    return undefined
355  }
356}
357
358function proposalOf(entry: unknown): Proposal | null {
359  const proposal = recordOf(entry)
360  const id = idOf(proposal?.proposalId)
361
362  if (proposal === null || id === null) return null
363
364  const out: Proposal = {
365    id,
366    type: stringOf(proposal.type, 40) ?? 'proposal',
367    status: stringOf(proposal.status, 20) ?? 'pending',
368    strategy: stringOf(proposal.strategy, 20) ?? 'unknown',
369    ...votesOf(proposal.votes),
370    byzantine: idsOf(proposal.byzantineVoters),
371  }
372  const value = valueText(proposal.value)
373  const proposedBy = idOf(proposal.proposedBy)
374  const proposedAtMs = msOf(proposal.proposedAt)
375  const term = numberOf(proposal.term)
376  const timeoutAtMs = msOf(proposal.timeoutAt)
377  const quorumPreset = stringOf(proposal.quorumPreset, 20)
378
379  if (value !== undefined) out.value = value
380  if (proposedBy !== null) out.proposedBy = proposedBy
381  if (proposedAtMs !== undefined) out.proposedAtMs = proposedAtMs
382  if (term !== undefined) out.term = term
383  if (timeoutAtMs !== undefined) out.timeoutAtMs = timeoutAtMs
384  if (quorumPreset !== undefined) out.quorumPreset = quorumPreset
385
386  return out
387}
388
389function decisionOf(entry: unknown): Decision | null {
390  const decision = recordOf(entry)
391  const id = idOf(decision?.proposalId)
392  const votes = recordOf(decision?.votes)
393
394  if (decision === null || id === null) return null
395
396  const out: Decision = {
397    id,
398    type: stringOf(decision.type, 40) ?? 'proposal',
399    result: stringOf(decision.result, 20) ?? 'unknown',
400    votesFor: countOf(votes?.for) ?? 0,
401    votesAgainst: countOf(votes?.against) ?? 0,
402    byzantine: idsOf(decision.byzantineDetected).length,
403  }
404  const strategy = stringOf(decision.strategy, 20)
405  const term = numberOf(decision.term)
406  const decidedAtMs = msOf(decision.decidedAt)
407
408  if (strategy !== undefined) out.strategy = strategy
409  if (term !== undefined) out.term = term
410  if (decidedAtMs !== undefined) out.decidedAtMs = decidedAtMs
411
412  return out
413}
414
415/** `sharedMemory.broadcasts`, the last 20, each field bounded: the message is free text another process wrote. */
416function broadcastsOf(value: unknown): Broadcast[] {
417  return (Array.isArray(value) ? value : []).slice(-20).flatMap(entry => {
418    const message = recordOf(entry)
419    const id = idOf(message?.messageId)
420    const text = stringOf(message?.message, 160)
421
422    if (message === null || id === null || text === undefined) return []
423
424    const atMs = msOf(message.timestamp)
425
426    return [{ id, message: text, priority: stringOf(message.priority, 12) ?? 'normal', from: idOf(message.fromId) ?? 'system', ...(atMs !== undefined && { atMs }) }]
427  })
428}
429
430/** `.claude-flow/hive-mind/state.json`, without its capability token. */
431export function parseHive(text: string | null): HiveInfo | null {
432  const hive = jsonObject(text)
433
434  if (hive === null || hive.initialized !== true) {
435    return null
436  }
437
438  const queen = recordOf(hive.queen)
439  const queenId = idOf(queen?.agentId)
440  const consensus = recordOf(hive.consensus)
441  const shared = recordOf(hive.sharedMemory)
442  const info: HiveInfo = {
443    topology: stringOf(hive.topology, 40) ?? 'unknown',
444    workers: idsOf(hive.workers, MAX_RECORDS),
445    pending: (Array.isArray(consensus?.pending) ? consensus.pending : []).slice(-50).flatMap(entry => proposalOf(entry) ?? []),
446    history: (Array.isArray(consensus?.history) ? consensus.history : []).slice(-50).flatMap(entry => decisionOf(entry) ?? []),
447    broadcasts: broadcastsOf(shared?.broadcasts),
448    memoryKeys: Object.keys(shared ?? {}).slice(0, 200).flatMap(key => (idOf(key) !== null ? [key] : [])),
449  }
450  const strategy = stringOf(hive.consensusStrategy, 30)
451  const term = numberOf(queen?.term)
452  const electedAtMs = msOf(queen?.electedAt)
453  const createdAtMs = msOf(hive.createdAt)
454  const updatedAtMs = msOf(hive.updatedAt)
455
456  if (strategy !== undefined) info.strategy = strategy
457  if (queenId !== null) info.queen = queenId
458  if (term !== undefined) info.queenTerm = term
459  if (electedAtMs !== undefined) info.queenElectedAtMs = electedAtMs
460  if (createdAtMs !== undefined) info.createdAtMs = createdAtMs
461  if (updatedAtMs !== undefined) info.updatedAtMs = updatedAtMs
462
463  return info
464}
465
466/** `.claude-flow/agents.json`: the workers `hive-mind spawn` writes (the other agent tools use agents/store.json), with their hive role. */
467export function parseHiveAgents(text: string | null): HiveAgentRecord[] {
468  const roles = new Map(
469    valuesOf(jsonObject(text)?.agents).flatMap(entry => {
470      const agent = recordOf(entry)
471      const id = idOf(agent?.agentId)
472      const role = stringOf(recordOf(agent?.config)?.hiveRole, 20)
473
474      return id !== null && role !== undefined ? [[id, role] as const] : []
475    }),
476  )
477
478  return parseAgents(text).map(agent => {
479    const role = roles.get(agent.id)
480
481    return role === undefined ? agent : { ...agent, role }
482  })
483}
484
485/** The tail of an id a person can tell apart at a glance: `agent-1790954653916-od014i` → `od014i`. */
486export function shortId(id: string): string {
487  const tail = id.split(/[-_:]/).pop() ?? id
488
489  return tail.length >= 4 ? tail.slice(-6) : id.slice(-6)
490}
491
492/** One readable label per agent: its name, else its type, with a short id only where two would read the same. */
493export function agentLabels(agents: readonly { id: string; name?: string; type: string }[]): Map<string, string> {
494  const base = (agent: { name?: string; type: string }) => agent.name ?? agent.type
495  const counts = new Map<string, number>()
496
497  for (const agent of agents) counts.set(base(agent), (counts.get(base(agent)) ?? 0) + 1)
498
499  return new Map(agents.map(agent => [agent.id, (counts.get(base(agent)) ?? 0) > 1 ? `${base(agent)}·${shortId(agent.id).slice(-4)}` : base(agent)]))
500}
501
hooks/dispatch.ts 209 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        // The answer puts this card in front of the person, so a bare /ruflo yes may answer it (and only it).
134        state.shownCard = pending.id ?? null
135
136        return { text: [`Asked: ${pending.label}. Confirm with /ruflo yes (or y in the pane), cancel with /ruflo no.`, ...(pending.shows === undefined ? [] : [`runs: ${pending.shows}`, pending.note ?? ''])].filter(Boolean).join('\n') }
137      }
138
139      return { text: (missionAnswer(state, intent.paletteId) ?? xruvAnswer(state, intent.paletteId, askedAtMs) ?? labAnswer(state, intent.paletteId, askedAtMs) ?? skillsAnswer(state, intent.paletteId, askedAtMs)) ?? (state.outcome !== null && !state.outcome.ok ? `${state.outcome.label}: ${state.outcome.detail}` : (state.outcome?.label ?? 'done')) }
140    }
141    case 'confirm':
142      if (state.pending === null) return { text: 'Nothing is waiting for a confirm.' }
143
144      if (!intent.isYes) {
145        control.runner.cancel()
146
147        return { text: 'Cancelled.' }
148      }
149
150      const confirmedAtMs = Date.now()
151
152      // A typed yes names no card: the runner refuses it while the card is too new or just replaced one (press the card's own Yes then).
153      await control.runner.confirmUnbound(intent.card)
154
155      return { text: xruvAnswer(state, null, confirmedAtMs) ?? labAnswer(state, null, confirmedAtMs) ?? (state.outcome === null ? 'Ran.' : `${state.outcome.ok ? '✓' : '✗'} ${state.outcome.label}: ${state.outcome.detail}${state.outcome.verified === 'yes' ? ' (on disk)' : state.outcome.verified === 'no' ? ' (not on disk yet)' : ''}`) }
156    case 'agent': {
157      const who = intent.who.toLowerCase()
158      const agent = state.snapshot?.agents.find(entry => entry.id.toLowerCase() === who || entry.name?.toLowerCase() === who) ?? state.snapshot?.agents.find(entry => entry.id.toLowerCase().endsWith(who))
159
160      if (agent === undefined) return { text: `No agent "${plain(intent.who, 40)}" in .claude-flow/agents/store.json.` }
161
162      control.drill(agent.id)
163
164      return open(control, state, `agent ${agent.name ?? agent.type}`)
165    }
166    case 'back':
167      control.actions.back()
168
169      return open(control, state, VIEWS.find(view => view.id === state.view)?.label ?? 'back')
170    case 'select':
171      control.actions.select(intent.by)
172
173      return { text: `selection moved (${intent.by > 0 ? 'next' : 'prev'}) on ${state.view}` }
174    case 'filter':
175      state.eventFilter = intent.filter
176      control.setView('events')
177
178      return open(control, state, `events · ${intent.filter}`)
179    case 'dump': {
180      const view = intent.view ?? state.view
181      const shown = state.view
182
183      return { text: await dumpOf(control, state, view) }
184    }
185    case 'band':
186      control.host.invalidate()
187
188      return { text: bandReply(state, intent.arg) }
189    case 'notices':
190      return { text: noticesReply(state, Date.now(), intent.isClear) }
191    case 'quiet':
192      control.host.invalidate()
193
194      return { text: quietReply(state, Date.now(), intent.arg) }
195    case 'autopilot': {
196      const apHost = hostOf(state)
197
198      return { text: apHost === undefined ? 'autopilot is not wired into this console yet' : await autopilotCommand(state, apHost, intent.arg) }
199    }
200    case 'commands':
201      return { text: commandsText(await loadCatalog(control), intent.query) }
202    case 'events':
203    case 'timeline':
204      return watchCommand(control, state, intent)
205    case 'unknown':
206      return { text: `Unknown: "${plain(intent.word, 30)}". /ruflo help lists the views (${VIEWS.map(view => view.id).join(', ')}) and commands.` }
207  }
208}
209
hooks/gfx/pictures.ts 458 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 type { ConsoleTheme } from '../themes'
8import { Braille, COLOR, Grid, mix, ramp, sparkline } from './raster'
9import { bigText } from './font'
10import { hash } from './boot-cyber'
11import { getBuild } from '../build'
12import { CONSOLE_VERSION } from '../version'
13
14export { bootPicture, BOOT_ROWS } from './boot'
15
16export type TopoNode = { id: string; label: string; status: string; isLeader: boolean; /** When the console last saw an event about it. */ pulseAtMs?: number }
17export type TopoModel = { topology: string; nodes: TopoNode[] }
18
19export const PULSE_MS = 1_400
20/** How long a work-in-flight dot takes from the leader to a busy agent. */
21export const FLIGHT_MS = 1400
22const isBusy = (status: string) => /busy|active|running|working/i.test(status)
23const isDown = (status: string) => /stop|terminat|offline|dead|error|fail/i.test(status)
24
25export function nodeColor(node: TopoNode, colors = COLOR): number {
26  if (node.isLeader) return colors.accent
27  if (isDown(node.status)) return /error|fail/i.test(node.status) ? colors.bad : colors.dim
28  if (isBusy(node.status)) return colors.warn
29
30  return colors.info
31}
32
33/**
34 * Where each node sits, in braille dots, by topology: a tree (rows of workers under the leader) for hierarchical and
35 * star, a circle for mesh and ring. Large swarms wrap into more rows rather than overprinting.
36 */
37export function layout(model: TopoModel, width: number, height: number): { x: number; y: number }[] {
38  const n = model.nodes.length
39  const topology = model.topology.toLowerCase()
40  const isCircle = (topology.includes('mesh') && !topology.includes('hierarchical')) || topology.includes('ring')
41
42  if (n === 0) return []
43
44  if (!isCircle) {
45    const workers = n - 1
46    const perRow = Math.max(1, Math.min(workers, Math.floor(width / 10)))
47    const tiers = Math.max(1, Math.ceil(workers / perRow))
48    const top = 3
49    const span = Math.max(4, height - 6 - top)
50
51    return model.nodes.map((_, i) => {
52      if (i === 0) return { x: width / 2, y: top }
53
54      const k = i - 1
55      const tier = Math.floor(k / perRow)
56      const inTier = Math.min(perRow, workers - tier * perRow)
57      const slot = k % perRow
58
59      return { x: ((slot + 0.5) / inTier) * (width - 8) + 4, y: top + 6 + (tiers === 1 ? span - 2 : (tier / Math.max(1, tiers - 1)) * (span - 2)) }
60    })
61  }
62
63  const cx = width / 2
64  const cy = height / 2
65  const r = Math.max(4, Math.min(width / 2 - 6, height / 2 - 3))
66
67  return model.nodes.map((_, i) => {
68    const angle = -Math.PI / 2 + (i / n) * Math.PI * 2
69
70    return { x: cx + Math.cos(angle) * r * 1.6, y: cy + Math.sin(angle) * r }
71  })
72}
73
74/** The edges a topology draws between node indexes (capped: a 100-agent mesh draws its first 300). */
75export function edges(model: TopoModel): [number, number][] {
76  const n = model.nodes.length
77  const out: [number, number][] = []
78  const topology = model.topology.toLowerCase()
79
80  if (n < 2) return out
81
82  if (topology.includes('mesh') && !topology.includes('hierarchical')) {
83    for (let a = 0; a < n && out.length < 300; a++) for (let b = a + 1; b < n && out.length < 300; b++) out.push([a, b])
84  } else if (topology.includes('ring')) {
85    for (let a = 0; a < n; a++) out.push([a, (a + 1) % n])
86  } else {
87    for (let b = 1; b < n; b++) out.push([0, b])
88    if (topology.includes('hierarchical-mesh')) for (let a = 1; a < n - 1; a++) out.push([a, a + 1])
89  }
90
91  return out
92}
93
94/**
95 * The swarm graph: nodes coloured by the status ruflo wrote (busy amber, idle blue, stopped grey), the leader starred.
96 * A dot runs from the leader to a node once each time the console sees an event about that agent (data); the leader's
97 * slow heartbeat is decoration.
98 */
99export function topologyPicture(model: TopoModel, columns: number, rows: number, t: number, depth = false, motionTime = t, palette?: ConsoleTheme['palette']): Grid {
100  const rgb = (hex: string) => parseInt(hex.slice(1), 16)
101  const colors = palette ? { ...COLOR, accent: rgb(palette.head), info: rgb(palette.info), ok: rgb(palette.ok), bad: rgb(palette.bad), warn: rgb(palette.warn), dim: mix(rgb(palette.background), rgb(palette.info), 0.45), line: mix(rgb(palette.background), rgb(palette.info), 0.3) } : COLOR
102  const grid = new Grid(columns, rows)
103  const canvas = new Braille(columns, rows)
104  const points = layout(model, canvas.width, canvas.height)
105  // Gentle perspective rocking preserves the model's real edges and labels.
106  if (depth) {
107    const yaw = Math.sin(motionTime / 2400) * 0.42
108    for (const point of points) {
109      const nx = (point.x - canvas.width / 2) / (canvas.width / 2)
110      const ny = (point.y - canvas.height / 2) / (canvas.height / 2)
111      const z = nx * Math.sin(yaw) + ny * 0.18
112      const scale = 2.5 / (2.5 - z)
113      point.x = canvas.width / 2 + nx * Math.cos(yaw) * scale * canvas.width * 0.4
114      point.y = canvas.height / 2 + ny * scale * canvas.height * 0.4
115    }
116  }
117  const links = edges(model)
118  const leader = points[0]
119
120  for (const [a, b] of links) {
121    const p = points[a]
122    const q = points[b]
123
124    if (p !== undefined && q !== undefined) canvas.line(p.x, p.y, q.x, q.y, colors.line)
125  }
126
127  // Work in flight: while ruflo has an agent busy, a dim amber dot keeps travelling down its edge from the leader.
128  // It runs only for as long as the status says busy, so it is data, not decoration; each agent has its own phase.
129  model.nodes.forEach((node, i) => {
130    const q = points[i]
131
132    if (i === 0 || leader === undefined || q === undefined || !isBusy(node.status)) return
133
134    const k = (((t / FLIGHT_MS + i * 0.37) % 1) + 1) % 1
135    const x = leader.x + (q.x - leader.x) * k
136    const y = leader.y + (q.y - leader.y) * k
137
138    canvas.dot(x, y, colors.warn)
139    canvas.dot(x + 1, y, colors.warn)
140  })
141
142  model.nodes.forEach((node, i) => {
143    const q = points[i]
144    const k = node.pulseAtMs === undefined ? -1 : (t - node.pulseAtMs) / PULSE_MS
145
146    if (i === 0 || leader === undefined || q === undefined || k < 0 || k > 1) return
147
148    const x = leader.x + (q.x - leader.x) * k
149    const y = leader.y + (q.y - leader.y) * k
150
151    // Two dots wide, so a pulse on a vertical edge stands out of the line rather than sitting on its dots.
152    canvas.dot(x, y, 0xffffff)
153    canvas.dot(x + 1, y, 0xffffff)
154  })
155
156  canvas.blitInto(grid, 0, 0)
157
158  const room = Math.floor(columns / Math.max(2, Math.min(model.nodes.length, Math.floor(canvas.width / 10))))
159
160  points.forEach((point, i) => {
161    const node = model.nodes[i]
162
163    if (node === undefined) return
164
165    const cx = Math.floor(point.x / 2)
166    const cy = Math.floor(point.y / 4)
167    const heartbeat = node.isLeader ? Math.max(0, Math.sin(t / 260)) ** 6 : 0
168    const flash = node.pulseAtMs !== undefined && t - node.pulseAtMs >= 0 && t - node.pulseAtMs < PULSE_MS + 600
169    // A busy agent breathes (brighter and back, about once every 2 s) so it reads as working, not just coloured.
170    const breath = !node.isLeader && isBusy(node.status) ? 0.45 * Math.sin(t / 330 + i) ** 2 : 0
171    const color = flash ? 0xffffff : node.isLeader ? mix(colors.accent, 0xffffff, heartbeat) : mix(nodeColor(node, colors), 0xffffff, breath)
172
173    if (depth) grid.set(cx + 1, cy + 1, '░', 0x49332c)
174    grid.set(cx, cy, depth ? (node.isLeader ? '◈' : isDown(node.status) ? '×' : isBusy(node.status) ? '◉' : '◇') : node.isLeader ? '★' : isBusy(node.status) ? '◉' : '●', color)
175
176    if (room >= 5 || node.isLeader) {
177      const label = node.label.slice(0, Math.max(3, room - 1))
178
179      grid.text(Math.max(0, Math.min(columns - label.length, cx - Math.floor(label.length / 2))), Math.min(rows - 1, cy + 1), label, node.isLeader ? colors.accent : nodeColor(node, colors))
180    }
181  })
182
183  if (palette) for (let i = 2; i < grid.cells.length; i += 3) grid.cells[i] = rgb(palette.background)
184  return grid
185}
186
187/**
188 * Two measured series as sparklines with their labels: tool calls the console saw per 5 s, and ruflo state files that
189 * changed per refresh. The newest bar glows while the pane animates: decoration over measured bars.
190 */
191export function activityPicture(series: readonly { label: string; values: readonly number[] }[], columns: number, t: number): Grid {
192  const grid = new Grid(columns, Math.max(1, series.length))
193  const labelWidth = Math.min(18, Math.max(8, ...series.map(entry => entry.label.length + 1)))
194  const width = Math.max(4, columns - labelWidth)
195
196  series.forEach((entry, row) => {
197    grid.text(0, row, entry.label.slice(0, labelWidth - 1), COLOR.dim)
198    sparkline(grid, labelWidth, row, width, entry.values, v => ramp(0.3 + v * 0.7))
199
200    const glow = 0.5 + 0.5 * Math.sin(t / 300)
201    const last = labelWidth + width - 1
202
203    if ((entry.values[entry.values.length - 1] ?? 0) > 0) grid.set(last, row, grid.glyph(last, row), mix(COLOR.info, 0xffffff, glow * 0.6))
204  })
205
206  return grid
207}
208
209/**
210 * The running success rate of routed tasks (routing-outcomes.json), oldest left, as a braille line over a 0-100% frame.
211 * When new outcomes arrive the newest stretch draws in over 900 ms from `grewAtMs`: that motion is data arriving.
212 */
213export function curvePicture(points: readonly boolean[], columns: number, rows: number, t: number, grewAtMs = 0): Grid {
214  const grid = new Grid(columns, rows)
215  const canvas = new Braille(Math.max(1, columns - 5), rows)
216  const n = points.length
217
218  for (let r = 0; r < rows; r++) grid.text(0, r, r === 0 ? '100%' : r === rows - 1 ? '  0%' : '    ', COLOR.dim)
219
220  if (n === 0) {
221    grid.text(6, Math.floor(rows / 2), 'no routed outcomes on disk yet', COLOR.dim)
222
223    return grid
224  }
225
226  let ok = 0
227  const rates = points.map((point, i) => {
228    ok += point ? 1 : 0
229
230    return ok / (i + 1)
231  })
232  const xOf = (i: number) => (n === 1 ? canvas.width / 2 : (i / (n - 1)) * (canvas.width - 1))
233  const yOf = (rate: number) => (1 - rate) * (canvas.height - 1)
234  const drawIn = grewAtMs > 0 ? Math.max(0, Math.min(1, (t - grewAtMs) / 900)) : 1
235  const shown = Math.max(1, Math.round(n * (0.8 + 0.2 * drawIn)))
236
237  for (let x = 0; x < canvas.width; x += 4) canvas.dot(x, yOf(0.5), COLOR.line)
238  for (let i = 1; i < shown; i++) canvas.line(xOf(i - 1), yOf(rates[i - 1] as number), xOf(i), yOf(rates[i] as number), ramp(rates[i] as number))
239  if (n === 1) canvas.dot(xOf(0), yOf(rates[0] as number), ramp(rates[0] as number))
240
241  canvas.blitInto(grid, 5, 0)
242
243  return grid
244}
245
246/** The band's mark: a diamond that pulses while Claude works and rests otherwise. */
247export function markPicture(isWorking: boolean, t: number): Grid {
248  const grid = new Grid(2, 1)
249  const k = isWorking ? 0.5 + 0.5 * Math.sin(t / 220) : 1
250
251  grid.set(0, 0, '◆', isWorking ? mix(COLOR.line, COLOR.accent, k) : COLOR.accent)
252
253  return grid
254}
255
256/** The pane's title strip: a highlight sweeps across it every few seconds while the pane is focused. Decoration only. */
257/** RUFLO in a two-row half-block font, the way a BBS splash spelled its name. */
258const LOGO = ['█▀█ █ █ █▀▀ █   █▀█', '█▀▄ █▄█ █▀  █▄▄ █▄█'] as const
259const NEON_MAGENTA = 0xff2a6d
260const NEON_CYAN = 0x05d9e8
261
262/** A header strikes in over this long when its page is switched to, and when the menu enters. */
263export const TITLE_ENTRY_MS = 1_200
264const GLITCH = '#%&@/\\|<>=+*'
265
266/**
267 * The strike-in shared by the page titles and the menu banner: from `from`, the letters appear left to right, a bright edge leading
268 * and block noise ahead of it; behind the edge a few settled cells flip for a frame to an ASCII character (pink or cyan, fading to none),
269 * and now and then a row slips one cell sideways. Hash-driven, so a frame is reproducible; nothing once `age` reaches TITLE_ENTRY_MS.
270 */
271function strikeIn(grid: Grid, from: number, age: number, t = 0): void {
272  if (age >= TITLE_ENTRY_MS) return occasionalGlitch(grid, from, t)
273
274  let last = from
275
276  for (let i = 0; i < grid.columns * grid.rows; i++) if (grid.cells[i * 3] !== 0x20) last = Math.max(last, i % grid.columns)
277
278  const span = last - from + 1
279  const lead = from + (age / TITLE_ENTRY_MS) * (span + 1)
280
281  for (let y = 0; y < grid.rows; y++) {
282    for (let x = from; x <= last; x++) {
283      if (grid.glyph(x, y) === 0x20) continue
284
285      if (x > lead + 1) grid.set(x, y, '░▒▓█'[hash(x * 7 + y + Math.floor(age / 60)) % 4] as string, mix(0x3a0f2e, NEON_CYAN, 0.35))
286      else if (x > lead - 1.5) grid.set(x, y, grid.glyph(x, y), 0xffffff)
287      else if (hash(x * 13 + y * 7 + Math.floor(age / 50)) % 100 < 4 * (1 - age / TITLE_ENTRY_MS)) grid.set(x, y, GLITCH[hash(x + y + Math.floor(age / 50)) % GLITCH.length] as string, hash(x + Math.floor(age / 50)) % 2 === 0 ? 0xff2a6d : 0x05d9e8)
288    }
289
290    const slip = hash(y * 5 + Math.floor(age / 80))
291
292    if (slip % 16 === 0 && age < TITLE_ENTRY_MS - 150) {
293      const by = (slip >>> 4) % 2 === 0 ? 1 : -1
294      const row = grid.cells.slice(y * grid.columns * 3, (y + 1) * grid.columns * 3)
295
296      for (let x = from; x <= last; x++) grid.cells.set(row.slice(Math.max(0, x - by) * 3, Math.max(0, x - by) * 3 + 3), (y * grid.columns + x) * 3)
297    }
298  }
299}
300
301/** One burst every BURST_EVERY_MS at a hash-chosen moment in its slot, lasting BURST_MS. */
302const BURST_EVERY_MS = 8_000
303const BURST_MS = 260
304
305/**
306 * After the entry, a header glitches now and then: for a quarter second, every eight seconds or so, a few of its cells flip to an ASCII
307 * character and a row may slip a cell. Quieter than the entry, and a function of the animation clock `t` alone, so a still frame (t = 0,
308 * fps 0) is never glitched.
309 */
310function occasionalGlitch(grid: Grid, from: number, t: number): void {
311  if (t <= 0) return
312
313  const slot = Math.floor(t / BURST_EVERY_MS)
314  const at = t - (slot * BURST_EVERY_MS + (hash(slot + 977) % (BURST_EVERY_MS - 1_000)))
315
316  if (at < 0 || at >= BURST_MS) return
317
318  const frame = Math.floor(at / 45)
319
320  for (let y = 0; y < grid.rows; y++) {
321    for (let x = from; x < grid.columns; x++) {
322      if (grid.glyph(x, y) === 0x20) continue
323      if (hash(x * 11 + y * 5 + frame * 31 + slot) % 100 < 3) grid.set(x, y, GLITCH[hash(x + y + frame) % GLITCH.length] as string, hash(x + frame) % 2 === 0 ? 0xff2a6d : 0x05d9e8)
324    }
325
326    if (hash(y * 3 + frame + slot) % 5 === 0) {
327      const row = grid.cells.slice(y * grid.columns * 3, (y + 1) * grid.columns * 3)
328
329      for (let x = from; x < grid.columns; x++) grid.cells.set(row.slice(Math.max(0, x - 1) * 3, Math.max(0, x - 1) * 3 + 3), (y * grid.columns + x) * 3)
330    }
331  }
332}
333
334/**
335 * The BBS banner: the logo in a magenta-to-cyan gradient with a scanline sweeping across it (decoration), a tag line,
336 * the project, and a blinking block cursor. Two rows.
337 */
338export function bannerPicture(project: string, columns: number, t: number, age = Infinity): Grid {
339  const grid = new Grid(columns, 2)
340  const width = LOGO[0].length
341  const sweep = ((t / 28) % (columns + 40)) - 20
342
343  LOGO.forEach((line, y) => {
344    ;[...line].forEach((ch, x) => {
345      if (ch === ' ' || x >= columns) return
346
347      const base = mix(NEON_MAGENTA, NEON_CYAN, x / Math.max(1, width - 1))
348      const glow = Math.max(0, 1 - Math.abs(x - sweep) / 4)
349
350      grid.set(x, y, ch, mix(base, 0xffffff, glow * 0.7))
351    })
352  })
353
354  const x0 = width + 2
355
356  if (columns > x0 + 4) {
357    // The version, and the git revision when the session knows it: the revision changes with every commit, so it shows which build is loaded.
358    // The title, with the version and build when they fit beside the logo; when they do not, the title whole rather than cut mid-word.
359    const title = '░▒▓ AGENT SWARM CONSOLE'
360    const full = `${title} v${CONSOLE_VERSION}${getBuild() === '' ? '' : ` · ${getBuild()}`}`
361    // The longest that fits, never cut mid-word: version and build, the title, a shorter title, the shortest.
362    const room = columns - x0
363    const shown = [full, title, '░▒▓ SWARM CONSOLE', '░▒▓ CONSOLE'].find(text => text.length <= room) ?? '░▒▓ CONSOLE'
364
365    grid.text(x0, 0, shown.slice(0, room), NEON_MAGENTA)
366
367    const line = `▸ npx ruflo · ${project}`
368    const node = line.length <= room - 2 ? line : `${line.slice(0, Math.max(1, room - 3))}…`
369
370    grid.text(x0, 1, node, NEON_CYAN)
371    if (Math.floor(t / 530) % 2 === 0 && x0 + node.length + 1 < columns) grid.set(x0 + node.length + 1, 1, '█', NEON_CYAN)
372  }
373
374  strikeIn(grid, 0, age, t)
375
376  return grid
377}
378
379const NEON_CORAL = 0xff7a59
380
381/**
382 * A view's BBS title: its name in the two-row half-block font, magenta to coral like the ANSI art boards, framed by
383 * dithered ░▒▓ ramps, with a slow shimmer down the letters (decoration). Two rows.
384 */
385export function titlePicture(name: string, columns: number, t: number, age = Infinity): Grid {
386  const grid = new Grid(columns, 2)
387  const [top, bottom] = bigText(name)
388  const edge = '░▒▓'
389  const x0 = edge.length + 1
390  const width = Math.max(top.length, bottom.length)
391  const shimmer = ((t / 40) % (width + 30)) - 15
392  // `RUFLO | PAGE`: the RUFLO letters move like the banner on the menu (a white glow sweeping a magenta to cyan ramp); the page's name keeps its slower coral shimmer.
393  const logo = name.toLowerCase().startsWith('ruflo |') ? bigText('ruflo')[0].length : 0
394  const sweep = ((t / 28) % (logo + 40)) - 20
395
396  for (let y = 0; y < 2; y++) {
397    ;[...edge].forEach((ch, i) => grid.set(i, y, ch, mix(0x3a0f2e, NEON_MAGENTA, (i + 1) / edge.length)))
398    ;[...(y === 0 ? top : bottom)].forEach((ch, i) => {
399      if (ch === ' ' || x0 + i >= columns) return
400
401      if (i < logo) {
402        const lit = Math.max(0, 1 - Math.abs(i - sweep) / 4)
403
404        grid.set(x0 + i, y, ch, mix(mix(NEON_MAGENTA, NEON_CYAN, i / Math.max(1, logo - 1)), 0xffffff, lit * 0.7))
405
406        return
407      }
408
409      const glow = Math.max(0, 1 - Math.abs(i - shimmer) / 3)
410
411      grid.set(x0 + i, y, ch, mix(mix(NEON_MAGENTA, NEON_CORAL, i / Math.max(1, width - 1)), 0xffffff, glow * 0.6))
412    })
413    // The line closes on the ramp the other way round, ░▒▓, mirroring how the dark ▓▒░ edge opened it.
414    ;[...'░▒▓'].forEach((ch, i) => {
415      const x = x0 + width + 1 + i
416
417      if (x < columns) grid.set(x, y, ch, mix(0x3a0f2e, NEON_MAGENTA, (i + 1) / edge.length))
418    })
419  }
420
421  strikeIn(grid, x0, age, t)
422
423  return grid
424}
425
426/**
427 * The menu's palette strip: one block of each colour across the width, and a band of light that sweeps along it and starts again,
428 * brightening the cells it passes (about 28 cells a second: three cells a frame at the default 8 fps, so it reads as motion, not a
429 * jump). At `t` = 0 the light is off the strip and the cells are exactly the colours, so a still frame (fps 0) is the plain strip.
430 * Decoration, like the boot's sign: it carries no data. A pure function of its size and the clock, as every picture here.
431 */
432export function palettePicture(columns: number, t: number, colors: readonly number[]): Grid {
433  const grid = new Grid(columns, 1)
434  const at = ((t / 36) % (columns + 24)) - 12
435
436  for (let x = 0; x < columns; x++) {
437    const base = colors[Math.min(colors.length - 1, Math.floor((x * colors.length) / columns))] ?? 0xffffff
438    const glow = Math.max(0, 1 - Math.abs(x - at) / 7)
439
440    grid.set(x, 0, '▀', mix(base, 0xffffff, glow * 0.8))
441  }
442
443  return grid
444}
445
446export function headerPicture(title: string, columns: number, t: number): Grid {
447  const grid = new Grid(columns, 1)
448  const at = ((t / 22) % (columns + 60)) - 20
449
450  ;[...title.slice(0, columns)].forEach((ch, x) => {
451    const glow = Math.max(0, 1 - Math.abs(x - at) / 6)
452
453    grid.set(x, 0, ch, mix(x < 2 ? COLOR.accent : COLOR.dim, 0xffffff, glow * 0.8))
454  })
455
456  return grid
457}
458
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