Agent teams, swarm coordination, Monitor streams, and worktree isolation — wraps 4 swarm_* + 8 agent_* MCP tools (12 total) plus 6 topologies (hierarchical /…

Agent teams, swarm coordination, Monitor streams, and worktree isolation.
/plugin marketplace add ruvnet/ruflo
/plugin install ruflo-swarm@ruflo
Monitor("npx @claude-flow/cli@latest swarm watch --stream")ruflo-core plugin (provides MCP server)@claude-flow/cli v3.6 major+minor.bash plugins/ruflo-swarm/scripts/smoke.sh is the contract.| Family | Count | Tools |
|---|---|---|
swarm_* | 4 | swarm_init, swarm_status, swarm_shutdown, swarm_health |
agent_* | 8 | agent_spawn, agent_execute, agent_terminate, agent_status, agent_list, agent_pool, agent_health, agent_update |
Sources: v3/@claude-flow/cli/src/mcp-tools/swarm-tools.ts:71, 145, 208, 270 and agent-tools.ts:182, 287, 319, 356, 395, 451, 573, 651.
This plugin pairs with Claude Code's native multi-agent tools (no MCP needed):
| Tool | Purpose |
|---|---|
Task | Spawn a sub-agent (use name: for addressability + run_in_background: true for parallel execution) |
SendMessage | Inter-agent comms (named agents only) |
TaskCreate / TaskList / TaskGet / TaskUpdate / TaskOutput / TaskStop | Shared task tracker for swarm pipelines |
Monitor | Live-stream events from a long-running process (persistent: true) — primary wake signal for /loop |
EnterWorktree / ExitWorktree | Git worktree isolation per agent |
For coding swarms, the canonical defaults that prevent agent drift:
| Setting | Value | Rationale |
|---|---|---|
topology | hierarchical | Coordinator catches divergence |
maxAgents | 6–8 | Smaller team = less drift |
strategy | specialized | Clear roles, no overlap |
consensus | raft | Leader maintains authoritative state |
memory | hybrid | SQLite + AgentDB for both fast + durable |
For 10+ agent teams, use hierarchical-mesh (queen + peer communication).
This plugin owns the swarm-state AgentDB namespace (kebab-case, follows the convention from ruflo-agentdb ADR-0001 §"Namespace convention"). Reserved namespaces (pattern, claude-memories, default) MUST NOT be shadowed.
swarm-state indexes active swarms, agent assignments, and topology snapshots. Accessed via memory_* (namespace-routed).
With Claude Code function hooks on (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, or the engine's own rollout), the plugin's hooks module (hooks/register.ts) draws a pane beside the transcript. Without function hooks nothing loads, and the commands, skills and agents above behave as they always have.
.claude-flow/agents/store.json, the hive queen, Claude Code's own subagents and teammates (from agent.spawn and $.agent.list()), and the main loop. The colour shows the state (idle, working, blocked, done, failed) and a word is always drawn beside it. A tile turns blue while its loop reads files and yellow while it writes. The leader is starred.$.session.usage(), and the router's last pick seen this session, with its score. A pick under routeThreshold is shown as below threshold. A figure that is not on disk, or that the engine did not give, is shown as n/a or listed under "not on disk", never as zero./ruflo swarm pane|status [json]|topology|claims|consensus, through ruflo-console's /ruflo. The old /ruflo-swarm-pane, /ruflo-swarm-status, /ruflo-swarm-topology, /ruflo-swarm-claims and /ruflo-swarm-consensus stay registered as aliases (ADR-406: no command is removed or renamed). /ruflo-swarm:watch also opens the pane./config): panel (command, the default since ruflo-console's cockpit is the pane that opens by itself | auto | off: /ruflo swarm pane refuses and /ruflo-swarm:watch does not open it), cli (npx-offline, the default, never touches the network), routeThreshold, injectSpawnContext (off by default: appends a one-line swarm note to spawned subagents' prompts) and audit (off by default: event names, tool names and agent ids into ruflo memory, never content).prompt.submit or tool.check hooks, and its tool.call hook only observes.modTrust: refuse-risky, the gate refuses this mod. The mod hooks tool.call and, with audit on, *, and it calls process.run. To keep it, add its provenance to modTrustAllow: ruflo-swarm@ruflo. The gate matches name@marketplace, not the plugin name. Under the default modTrust: observe the mod loads unchanged.Tests run on the engine's own kit: claude plugin test plugins/ruflo-swarm (run scripts/fetch-mod-types.sh once first if you want tsc -p plugins/ruflo-swarm to typecheck). The pure readers also run under vitest: npx vitest run --root plugins/ruflo-swarm tests/parse.spec.ts.
bash plugins/ruflo-swarm/scripts/smoke.sh
# Expected: "11 passed, 0 failed"
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/ruflo-swarm
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate --strict plugins/ruflo-swarm
ruflo-agentdb — namespace convention ownerruflo-autopilot — owns the 270s cache-aware /loop heartbeat for long-running swarmsruflo-intelligence — hooks_route powers swarm agent recommendation per taskhooks/register.ts 471 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2
3import { paneActionsOf, type Controller } from './actions/controller'
4import { AUDIT_FLUSH_MS, auditRow, noteAudit, takeFlush } from './audit'
5import { claimsText, COMMANDS, consensusText, isSwarmSub, spawnNote, statusText, SWARM_SUBS, topologyText, type SwarmSub } from './commands'
6import type { Host, OpenResult } from './host'
7import { isAnimating, LEAD, newActivity, noteCall, noteDone, noteListed, noteResult, noteSpawn } from './model/members'
8import { parseRoute, plain } from './reader/parse'
9import { readSnapshot, type ReadCache } from './reader/snapshot'
10import { CLI_PREFIXES, newState, PANE_ID, persistedOf, restore, storeKeyOf, type State } from './state'
11import { paneModelOf } from './views/model'
12import { paneView } from './views/pane'
13
14const REFRESH_DEBOUNCE_MS = 400
15const TICK_MS = 2_000
16/** While idle the disk is read this often: ruflo's own CLI may change it from another terminal. */
17const IDLE_READ_MS = 10_000
18const FRAME_MS = 200
19const RUFLO_COMMAND = /(?:^|[\s/'"])(?:ruflo|claude-flow|@claude-flow\/cli)(?:@[\w.-]+)?\s/
20const ROUTE_COMMAND = /\bhooks\s+route\b/
21const ROUTE_TOOL = /(?:^|__)hooks_route$/
22
23/**
24 * Binds a Host from `$`. Declared here, each member spelled `$.noun.event(...)`, so the engine reads what the module
25 * calls off its source. The calls that answer nothing are wrapped: a refused draw or toast is not a crashed hook.
26 */
27function hostOf($: EngineInterface, cwd: string): Host {
28 const rooted = (path: string) => (path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`)
29 const quietly = (fn: () => void) => {
30 try {
31 fn()
32 } catch {
33 // Refused: there is nothing to do about a draw nobody may make.
34 }
35 }
36
37 return {
38 fs: { read: path => $.fs.read(rooted(path)), stat: path => $.fs.stat(rooted(path)) },
39 now: () => $.clock.now(),
40 after: (ms, fn) => $.clock.after(ms, fn),
41 every: (ms, fn) => $.clock.every(ms, fn),
42 storeGet: key => $.store.get(key),
43 storeSet: (key, value) => $.store.set(key, value),
44 invalidate: () => quietly(() => $.ui.invalidate('ui.render')),
45 toast: (text, timeoutMs) => quietly(() => $.ui.toast(text, timeoutMs !== undefined ? { timeoutMs } : undefined)),
46 log: text => quietly(() => $.ui.log(text)),
47 openPane: pane => $.ui.open(pane) as Promise<OpenResult>,
48 closePane: id => $.ui.close({ id }),
49 registerCommand: spec => $.command.register(spec),
50 fillPrompt: input => $.prompt.fill(input),
51 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
52 usage: () => $.session.usage(),
53 agents: () => $.agent.list(),
54 }
55}
56
57/**
58 * The swarm mod: a live pane of the ruflo swarm on disk and of Claude Code's own agent loops, swarm commands, and,
59 * where the options ask, swarm context in spawned subagents and a content-free audit trail. `session.start` binds the
60 * engine; every hook after it works over that binding and one state object.
61 */
62export function register(on: On, raw: PluginOptions) {
63 const state: State = newState(raw, newActivity())
64 const cache: ReadCache = new Map()
65 let host: Host | null = null
66
67 const persist = () => {
68 void host?.storeSet(storeKeyOf(state.cwd), persistedOf(state)).catch(() => undefined)
69 }
70
71 async function refresh(): Promise<void> {
72 const bound = host
73
74 if (bound === null) {
75 return
76 }
77
78 if (state.isRefreshing) {
79 state.isRefreshQueued = true
80
81 return
82 }
83
84 state.isRefreshing = true
85
86 try {
87 const nowMs = Date.now()
88 const [snapshot, usage, listed] = await Promise.all([
89 readSnapshot(bound.fs, cache, nowMs),
90 bound.usage().catch(() => null),
91 bound.agents().catch(() => null),
92 ])
93 const isFirstSight = snapshot.hasSwarm && state.snapshot?.hasSwarm !== true
94
95 state.snapshot = snapshot
96 state.readError = null
97
98 if (usage !== null) {
99 state.usage = {
100 ...(usage.cost?.usd !== undefined && { costUsd: usage.cost.usd }),
101 ...(usage.context?.tokens !== undefined && { contextTokens: usage.context.tokens }),
102 ...(usage.context?.percent !== undefined && { contextPercent: usage.context.percent }),
103 ...(usage.context?.window !== undefined && { contextWindow: usage.context.window }),
104 readAtMs: nowMs,
105 }
106 }
107
108 if (listed !== null) {
109 noteListed(state.activity, listed, nowMs)
110 }
111
112 if (isFirstSight) {
113 maybeAutoOpen()
114 }
115 } catch (error) {
116 state.readError = plain(error instanceof Error ? error.message : String(error), 200)
117 } finally {
118 state.isRefreshing = false
119 bound.invalidate()
120
121 if (state.isRefreshQueued) {
122 state.isRefreshQueued = false
123 scheduleRefresh()
124 }
125 }
126 }
127
128 function scheduleRefresh(): void {
129 if (host === null || state.timers.has('refresh')) {
130 return
131 }
132
133 state.timers.set(
134 'refresh',
135 host.after(REFRESH_DEBOUNCE_MS, () => {
136 state.timers.delete('refresh')
137 void refresh()
138 }),
139 )
140 }
141
142 /** Redraws while a tile is lit, then stops: no timer runs for a pane with nothing moving. */
143 function animate(): void {
144 const bound = host
145
146 if (bound === null || state.timers.has('frames') || !state.pane.isOpen) {
147 return
148 }
149
150 state.timers.set(
151 'frames',
152 bound.every(FRAME_MS, () => {
153 bound.invalidate()
154
155 if (!state.pane.isOpen || !isAnimating(state.activity, Date.now())) {
156 state.timers.get('frames')?.cancel()
157 state.timers.delete('frames')
158 }
159 }),
160 )
161 }
162
163 async function openPane(): Promise<{ isPlaced: boolean; reason: string }> {
164 if (host === null) {
165 return { isPlaced: false, reason: 'the session has not started' }
166 }
167
168 try {
169 const result = await host.openPane({ id: PANE_ID, title: 'Swarm' })
170 const isPlaced = result === undefined || result.isPlaced !== false
171
172 state.pane.isOpen = isPlaced
173 state.pane.isClosedByPerson = false
174 persist()
175 void refresh()
176
177 return { isPlaced, reason: result?.reason ?? '' }
178 } catch (error) {
179 return { isPlaced: false, reason: plain(error instanceof Error ? error.message : String(error), 160) }
180 }
181 }
182
183 function maybeAutoOpen(viewport?: { isFullscreen?: boolean }): void {
184 state.viewport.isFullscreen = viewport?.isFullscreen ?? state.viewport.isFullscreen
185
186 const bound = host
187 const shouldOpen =
188 bound !== null &&
189 state.options.panel === 'auto' &&
190 !state.pane.isOpen &&
191 !state.pane.isClosedByPerson &&
192 !state.timers.has('auto-open') &&
193 state.snapshot?.hasSwarm === true &&
194 state.viewport.isFullscreen === true
195
196 if (shouldOpen && bound !== null) {
197 // From a timer: a render hook only draws.
198 state.timers.set(
199 'auto-open',
200 bound.after(50, () => {
201 state.timers.delete('auto-open')
202 void openPane()
203 }),
204 )
205 }
206 }
207
208 const control: Controller = { refresh, persist }
209
210 // ------------------------------------------------------------- lifecycle
211
212 on('session.start', async ($, e, next) => {
213 host = hostOf($, e.cwd)
214 state.cwd = e.cwd
215
216 for (const timer of state.timers.values()) {
217 timer.cancel()
218 }
219
220 state.timers.clear()
221 noteSpawn(state.activity, LEAD, 'lead', Date.now())
222
223 const bound = host
224
225 await Promise.all([
226 ...COMMANDS.map(spec => bound.registerCommand(spec).catch(() => undefined)),
227 bound
228 .storeGet(storeKeyOf(e.cwd))
229 .then(value => restore(state, value))
230 .catch(() => undefined),
231 ])
232
233 await refresh()
234
235 let lastReadMs = Date.now()
236
237 state.timers.set(
238 'tick',
239 bound.every(TICK_MS, () => {
240 const nowMs = Date.now()
241
242 if (state.pane.isOpen && (state.activity.isWorking || nowMs - lastReadMs >= IDLE_READ_MS)) {
243 lastReadMs = nowMs
244 scheduleRefresh()
245 }
246 }),
247 )
248
249 if (state.options.audit) {
250 state.timers.set(
251 'audit',
252 bound.every(AUDIT_FLUSH_MS, () => {
253 const flush = takeFlush(state, Date.now())
254
255 if (flush !== null) {
256 void bound.run([...CLI_PREFIXES[state.options.cli], ...flush.args], 30_000).catch(() => undefined)
257 }
258 }),
259 )
260 }
261
262 return next(e)
263 })
264
265 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
266 if (e.surface === 'terminal') {
267 maybeAutoOpen(e.viewport)
268 }
269
270 return next(e)
271 })
272
273 on('ui.render', { component: 'Pane' }, ($, e, next) => {
274 if (host === null || e.requestId !== PANE_ID) {
275 return next(e)
276 }
277
278 const table = $.ui.resolve(e)
279 const columns = Math.max(20, Math.floor(Number(e.props.bodyColumns) || 0) - 1)
280 const rows = Math.max(0, Math.floor(Number(e.props.scroll?.bodyRows) || 0))
281 const nowMs = Date.now()
282
283 state.pane.isOpen = true
284 state.pane.columns = columns
285 state.pane.rows = rows
286
287 if (isAnimating(state.activity, nowMs)) {
288 animate()
289 }
290
291 return paneView({ Box: table.Box, Text: table.Text, Button: table.Button }, paneModelOf(state, columns, rows, nowMs), paneActionsOf(state, host, control))
292 })
293
294 on('ui.close', async ($, e, next) => {
295 const result = await next(e)
296
297 if (e.id === PANE_ID && result.deny === undefined) {
298 state.pane.isOpen = false
299 state.pane.isClosedByPerson = e.origin.kind === 'person' || state.pane.isClosedByPerson
300 persist()
301 }
302
303 return result
304 })
305
306 // -------------------------------------------------------------- commands
307
308 /** The five swarm commands, each by its subcommand word: what `/ruflo swarm <sub>` and the old names both answer. */
309 async function answer(sub: SwarmSub, args: string): Promise<{ text: string }> {
310 const bound = host as Host
311
312 switch (sub) {
313 case 'pane': {
314 const arg = args.trim().toLowerCase()
315
316 if (arg === 'close' || (arg === '' && state.pane.isOpen)) {
317 state.pane.isOpen = false
318 state.pane.isClosedByPerson = true
319 persist()
320 await bound.closePane(PANE_ID).catch(() => undefined)
321
322 return { text: 'Swarm pane hidden' }
323 }
324
325 if (state.options.panel === 'off') {
326 return { text: 'The swarm pane is off (panel option); set panel to command or auto in /config to open it' }
327 }
328
329 const opened = await openPane()
330
331 return { text: opened.isPlaced ? 'Swarm pane shown' : `The swarm pane could not be shown: ${opened.reason}` }
332 }
333 case 'status':
334 await refresh()
335
336 return { text: statusText(state, Date.now(), args.trim().toLowerCase() === 'json') }
337 case 'topology':
338 await refresh()
339
340 return { text: topologyText(state, Date.now()) }
341 case 'claims':
342 await refresh()
343
344 return { text: claimsText(state) }
345 case 'consensus':
346 await refresh()
347
348 return { text: consensusText(state) }
349 }
350 }
351
352 /**
353 * `/ruflo swarm <pane|status|topology|claims|consensus>`: ruflo-console registers `/ruflo` for every ruflo mod, and
354 * this hook answers its swarm subcommands wherever it sits in the chain, passing every other word on.
355 */
356 // `/ruflo-console` is the same command as `/ruflo` (kept by ADR-406), so its swarm subcommands are answered too.
357 for (const command of ['ruflo', 'ruflo-console'] as const) {
358 on('command.run', { command }, async ($, e, next) => {
359 const [head = '', sub = '', ...rest] = e.args.trim().split(/\s+/)
360
361 if (host === null || head.toLowerCase() !== 'swarm' || !isSwarmSub(sub.toLowerCase())) {
362 return next(e)
363 }
364
365 return answer(sub.toLowerCase() as SwarmSub, rest.join(' '))
366 })
367 }
368
369 // The old names stay registered as aliases of `/ruflo swarm <sub>`: ADR-406 removes, renames or reassigns no command.
370 for (const sub of SWARM_SUBS) {
371 on('command.run', { command: `ruflo-swarm-${sub}` }, async ($, e, next) => (host === null ? next(e) : answer(sub, e.args)))
372 }
373
374 /** The plugin's markdown `/ruflo-swarm:watch` still runs as it always has; with the mod it opens the pane as well. */
375 on('command.run', { command: 'ruflo-swarm:watch' }, async ($, e, next) => {
376 if (host !== null && state.options.panel !== 'off') {
377 await openPane()
378 }
379
380 return next(e)
381 })
382
383 // ----------------------------------------------------------------- turns
384
385 on('turn.start', ($, e, next) => {
386 state.activity.isWorking = true
387 state.activity.turnId = e.turnId
388 host?.invalidate()
389
390 return next(e)
391 })
392
393 on('turn.complete', ($, e, next) => {
394 if (e.agentId !== undefined) {
395 noteDone(state.activity, e.agentId, e.reason, Date.now())
396 } else {
397 state.activity.isWorking = false
398 scheduleRefresh()
399 }
400
401 host?.invalidate()
402
403 return next(e)
404 })
405
406 // ---------------------------------------------------------------- agents
407
408 /** Claude Code's own subagents join the pane as members; with the option on, each is told which swarm it is in. */
409 on('agent.spawn', async ($, e, next) => {
410 const note = state.options.injectSpawnContext ? spawnNote(state.snapshot) : null
411 const result = await next(note !== null ? { ...e, prompt: `${e.prompt}${note}` } : e)
412
413 if (result.deny === undefined && result.agentId !== undefined) {
414 noteSpawn(state.activity, result.agentId, e.subagentType, Date.now(), e.name, plain(e.description, 120))
415 host?.invalidate()
416 }
417
418 return result
419 })
420
421 // ------------------------------------------------------------ tool calls
422
423 /** Observes only: the call goes on unchanged, and what it read or wrote lights its agent's tile. */
424 on('tool.call', async ($, e, next) => {
425 const args = e as unknown as Readonly<Record<string, unknown>>
426 const command = typeof args.command === 'string' ? args.command : undefined
427 const path = typeof args.file_path === 'string' ? args.file_path : typeof args.path === 'string' ? args.path : typeof args.pattern === 'string' ? args.pattern : undefined
428 const subject = plain(command ?? path ?? '', 80)
429 const pulse = noteCall(state.activity, e.agentId, e.tool, subject, Date.now(), command)
430
431 if (pulse !== null && state.pane.isOpen) {
432 animate()
433 host?.invalidate()
434 }
435
436 const result = await next(e)
437 const isError = result.deny !== undefined || result.isError === true
438
439 noteResult(state.activity, e.agentId, e.tool, subject, isError, Date.now())
440
441 if (!isError) {
442 const text = typeof result.text === 'string' ? result.text : ''
443
444 if ((command !== undefined && ROUTE_COMMAND.test(command)) || ROUTE_TOOL.test(e.tool)) {
445 const pick = parseRoute(text, Date.now())
446
447 if (pick !== null) {
448 state.route = pick
449 persist()
450 }
451 }
452
453 if ((command !== undefined && RUFLO_COMMAND.test(command)) || e.tool.includes('claude-flow') || e.tool.includes('ruflo')) {
454 scheduleRefresh()
455 }
456 }
457
458 return result
459 })
460
461 // ----------------------------------------------------------------- audit
462
463 if (state.options.audit) {
464 on('*', ($, e, next) => {
465 noteAudit(state, auditRow(next.event, e, Date.now()))
466
467 return next(e)
468 })
469 }
470}
471hooks/actions/controller.ts 291 lines1import type { Host } from '../host'
2import { LEAD, membersOf, type Member } from '../model/members'
3import { parseRoute, plain } from '../reader/parse'
4import { CLI_PREFIXES, type State } from '../state'
5import type { PaneActions } from '../views/pane'
6import { paneModelOf } from '../views/model'
7import {
8 agentLogs,
9 argvOf,
10 claimTask,
11 handoffTask,
12 markStealable,
13 reroute,
14 setClaimStatus,
15 stealTask,
16 stopAgent,
17 vote,
18 type ActionSpec,
19} from './argv'
20
21/** A CLI call from a button gets this long; the engine's own cap is ten minutes, and the pane does not hold a person that long. */
22const RUN_MS = 60_000
23/** A confirm prompt left unanswered this long is dropped, so a stale press never runs an old action. */
24export const CONFIRM_MS = 30_000
25
26export type Controller = {
27 /** Re-reads the disk now and resolves once the snapshot is fresh. */
28 refresh: () => Promise<void>
29 persist: () => void
30}
31
32const lastLines = (text: string, count: number): string[] =>
33 text
34 .split('\n')
35 .map(line => plain(line, 400))
36 .filter(line => line !== '')
37 .slice(-count)
38
39/** Runs one action through the configured CLI and says what happened, checked against the disk where the disk can show it. */
40export async function runAction(state: State, host: Host, control: Controller, spec: ActionSpec): Promise<void> {
41 const nowMs = () => Date.now()
42
43 state.isActing = true
44 state.confirm = null
45 host.invalidate()
46
47 try {
48 const result = await host.run(argvOf(CLI_PREFIXES[state.options.cli], spec), RUN_MS)
49 const ok = result.exitCode === 0
50 let verified: 'yes' | 'no' | 'n/a' = 'n/a'
51
52 if (spec.args[0] === 'hooks' && spec.args[1] === 'route') {
53 const pick = parseRoute(result.stdout, nowMs())
54
55 if (pick !== null) {
56 state.route = pick
57 control.persist()
58 }
59
60 verified = pick !== null ? 'yes' : 'no'
61 } else if (spec.args[0] === 'agent' && spec.args[1] === 'logs') {
62 state.detail = { title: spec.label, lines: lastLines(ok ? result.stdout : result.stderr || result.stdout, 40) }
63 }
64
65 if (ok && spec.verify !== undefined) {
66 await control.refresh()
67 verified = state.snapshot !== null && spec.verify(state.snapshot) ? 'yes' : 'no'
68 }
69
70 const said = lastLines(ok ? result.stdout : result.stderr || result.stdout, 1)[0] ?? ''
71
72 state.outcome = { label: spec.label, ok, verified, detail: ok ? (verified === 'no' ? `expected ${spec.expect}` : '') : `exit ${result.exitCode}${said !== '' ? `: ${said}` : ''}`, atMs: nowMs() }
73 } catch (error) {
74 // A refused `$.process.run` (removed by an administrator, or denied above this mod) is a sentence, not a crash.
75 state.outcome = { label: spec.label, ok: false, verified: 'n/a', detail: plain(error instanceof Error ? error.message : String(error), 160), atMs: nowMs() }
76 } finally {
77 state.isActing = false
78 host.invalidate()
79 }
80}
81
82/** Asks first for a destructive action (a second press confirms), runs a safe one at once, and says so when there is nothing to act on. */
83export function request(state: State, host: Host, control: Controller, spec: ActionSpec | null, why: string): void {
84 if (state.isActing) {
85 return
86 }
87
88 if (spec === null) {
89 state.outcome = { label: why, ok: false, verified: 'n/a', detail: 'nothing to act on', atMs: Date.now() }
90 host.invalidate()
91
92 return
93 }
94
95 if (spec.isDestructive) {
96 state.confirm = { label: spec.label, spec, askedAtMs: Date.now() }
97 state.timers.get('confirm')?.cancel()
98 state.timers.set(
99 'confirm',
100 host.after(CONFIRM_MS, () => {
101 state.timers.delete('confirm')
102 state.confirm = null
103 host.invalidate()
104 }),
105 )
106 host.invalidate()
107
108 return
109 }
110
111 void runAction(state, host, control, spec)
112}
113
114/** The activity a Claude Code loop showed this session, as lines: there is no transcript API, so this is what the hooks saw. */
115function activityLines(state: State, member: Member): string[] {
116 const recent = state.activity.recent.get(member.id) ?? []
117 const loop = state.activity.loops.get(member.id)
118
119 return [
120 ...(loop?.description !== undefined ? [`task: ${plain(loop.description, 200)}`] : []),
121 ...(recent.length === 0 ? ['no tool calls seen yet'] : recent.map(call => `${call.isError ? '✗' : '·'} ${call.tool} ${call.subject}`)),
122 ]
123}
124
125export function paneActionsOf(state: State, host: Host, control: Controller): PaneActions {
126 const model = () => paneModelOf(state, state.pane.columns, state.pane.rows, Date.now())
127 const members = () => membersOf(state.snapshot, state.activity, Date.now())
128
129 const step = (by: number) => {
130 const all = members()
131
132 if (all.length === 0) {
133 return
134 }
135
136 const at = Math.max(0, all.findIndex(member => member.id === state.selected))
137
138 state.selected = all[(at + by + all.length) % all.length]?.id ?? null
139 state.detail = null
140 control.persist()
141 host.invalidate()
142 }
143
144 const stepTask = (by: number) => {
145 const rows = model().board.rows
146
147 if (rows.length === 0) {
148 return
149 }
150
151 const at = Math.max(0, rows.findIndex(row => row.id === state.selectedTask))
152
153 state.selectedTask = rows[(at + by + rows.length) % rows.length]?.id ?? null
154 control.persist()
155 host.invalidate()
156 }
157
158 const selected = () => model().selected
159 const task = () => model().board.selected
160 const ruflo = (member: Member | null) => (member !== null && member.source === 'ruflo' ? member : null)
161 const claimOf = (member: Member | null) => (member === null ? undefined : state.snapshot?.claims.find(claim => claim.claimant.id === member.id && claim.status !== 'completed'))
162 const go = (spec: ActionSpec | null, why: string) => request(state, host, control, spec, why)
163
164 return {
165 hide: () => {
166 state.pane.isOpen = false
167 state.pane.isClosedByPerson = true
168 control.persist()
169 void host.closePane('ruflo-swarm').catch(() => undefined)
170 },
171 prev: () => step(-1),
172 next: () => step(1),
173 taskPrev: () => stepTask(-1),
174 taskNext: () => stepTask(1),
175 stop: () => {
176 const member = ruflo(selected())
177
178 go(member === null ? null : stopAgent(member.id), 'stop')
179 },
180 logs: () => {
181 const member = selected()
182
183 if (member === null) {
184 return
185 }
186
187 if (member.source === 'ruflo') {
188 go(agentLogs(member.id), 'logs')
189
190 return
191 }
192
193 state.detail = { title: `activity of ${member.label}${member.id === LEAD ? '' : ` (${member.id})`}`, lines: activityLines(state, member) }
194 host.invalidate()
195 },
196 pause: () => {
197 const claim = claimOf(ruflo(selected()))
198
199 go(claim === undefined ? null : setClaimStatus(claim.issueId, 'paused'), 'pause claim')
200 },
201 resume: () => {
202 const claim = claimOf(ruflo(selected()))
203
204 go(claim === undefined ? null : setClaimStatus(claim.issueId, 'active'), 'resume claim')
205 },
206 claim: () => {
207 const member = ruflo(selected())
208 const row = task()
209
210 go(member === null || row === null ? null : claimTask(row.id, member.id, member.role), 'claim task')
211 },
212 offer: () => {
213 const row = task()
214
215 go(row === null ? null : markStealable(row.id), 'offer task')
216 },
217 steal: () => {
218 const member = ruflo(selected())
219 const row = task()
220
221 go(member === null || row === null ? null : stealTask(row.id, member.id, member.role), 'steal task')
222 },
223 handoff: () => {
224 const member = ruflo(selected())
225 const row = task()
226 const claim = row === null ? undefined : state.snapshot?.claims.find(entry => entry.issueId === row.id)
227 const from = claim?.claimant.kind === 'agent' ? { id: claim.claimant.id, type: claim.claimant.agentType ?? 'agent' } : null
228
229 go(member === null || row === null || from === null ? null : handoffTask(row.id, from, { id: member.id, type: member.role }), 'hand off')
230 },
231 reroute: () => {
232 const row = task()
233
234 go(row === null ? null : reroute(row.description || row.type), 're-route')
235 },
236 voteYes: () => {
237 const proposal = model().proposals[0]
238 const member = selected()
239
240 go(proposal === undefined || member === null ? null : vote(proposal.id, member.id, true), 'vote')
241 },
242 voteNo: () => {
243 const proposal = model().proposals[0]
244 const member = selected()
245
246 go(proposal === undefined || member === null ? null : vote(proposal.id, member.id, false), 'vote')
247 },
248 confirm: () => {
249 const asked = state.confirm
250
251 state.timers.get('confirm')?.cancel()
252 state.timers.delete('confirm')
253
254 if (asked === null || Date.now() - asked.askedAtMs > CONFIRM_MS) {
255 state.confirm = null
256 host.invalidate()
257
258 return
259 }
260
261 void runAction(state, host, control, asked.spec)
262 },
263 cancel: () => {
264 state.timers.get('confirm')?.cancel()
265 state.timers.delete('confirm')
266 state.confirm = null
267 host.invalidate()
268 },
269 fill: () => {
270 const next = model().next
271
272 if (next === null) {
273 return
274 }
275
276 void host
277 .fillPrompt({ text: next.text })
278 .then(result => {
279 if (!result.isFilled) {
280 host.toast(`Next: ${next.text}`, 8000)
281 }
282 })
283 .catch(() => host.toast(`Next: ${next.text}`, 8000))
284 },
285 closeDetail: () => {
286 state.detail = null
287 host.invalidate()
288 },
289 }
290}
291hooks/audit.ts 60 lines1import { idOf } from './reader/parse'
2import type { State } from './state'
3
4/** At most this many rows wait for a flush; past it the oldest are dropped and counted. */
5export const AUDIT_MAX = 200
6export const AUDIT_FLUSH_MS = 30_000
7export const AUDIT_NAMESPACE = 'ruflo-swarm-audit'
8
9/** Events never recorded: the render and timer traffic would drown the trail, and the trail's own writes would feed it. */
10const SKIP = /^(ui\.|clock\.|store\.|fs\.|process\.run|session\.usage|agent\.list|env\.|config\.describe)/
11
12const NAME = /^[A-Za-z0-9_.:-]{1,64}$/
13
14/**
15 * One row of the trail: the event's name, the tool's name and the agent's id where the event has them, and the time.
16 * Never prompt text, arguments, file paths, results or anything a tool returned: the trail says what happened, not what was said.
17 */
18export function auditRow(event: string, e: unknown, nowMs: number): string | null {
19 if (SKIP.test(event) || !NAME.test(event)) {
20 return null
21 }
22
23 const value = (e ?? {}) as Record<string, unknown>
24 const tool = typeof value.tool === 'string' && NAME.test(value.tool) ? value.tool : undefined
25 const agent = idOf(value.agentId)
26
27 return JSON.stringify({ t: nowMs, event, ...(tool !== undefined && { tool }), ...(agent !== null && { agent }) })
28}
29
30/** Adds a row on the hot path: a push and, when full, a shift. Nothing waits on I/O here. */
31export function noteAudit(state: State, row: string | null): void {
32 if (row === null) {
33 return
34 }
35
36 state.audit.buffer.push(row)
37
38 if (state.audit.buffer.length > AUDIT_MAX) {
39 state.audit.buffer.shift()
40 state.audit.dropped += 1
41 }
42}
43
44/** The rows to write now and the argv after the CLI prefix that stores them, or null when there is nothing to write. */
45export function takeFlush(state: State, nowMs: number): { args: string[]; rows: number } | null {
46 if (state.audit.buffer.length === 0) {
47 return null
48 }
49
50 const rows = state.audit.buffer.splice(0, state.audit.buffer.length)
51 const dropped = state.audit.dropped
52
53 state.audit.dropped = 0
54 state.audit.flushedAtMs = nowMs
55
56 const value = JSON.stringify({ rows: rows.map(row => JSON.parse(row) as unknown), ...(dropped > 0 && { dropped }) })
57
58 return { args: ['memory', 'store', '--namespace', AUDIT_NAMESPACE, '--key', `audit-${nowMs}`, '--value', value], rows: rows.length }
59}
60hooks/commands.ts 151 lines1import type { CommandSpec } from 'claude-code'
2
3import { membersOf } from './model/members'
4import { topologyLines } from './model/topology'
5import { plain } from './reader/parse'
6import type { Snapshot } from './reader/snapshot'
7import type { State } from './state'
8import { paneModelOf } from './views/model'
9
10/**
11 * The commands this module registers: since ruflo-console's `/ruflo`, each is also reachable as `/ruflo swarm <sub>`,
12 * and each stays registered (ADR-406: no command is removed or renamed). Each name is prefixed with the plugin's so it never stands in for a built-in.
13 */
14export const COMMANDS: readonly CommandSpec[] = [
15 { name: 'ruflo-swarm-pane', description: 'Same as /ruflo swarm pane: show or hide the live swarm pane', argumentHint: '[open|close]', immediate: true },
16 { name: 'ruflo-swarm-status', description: 'Same as /ruflo swarm status: the swarm as ruflo has it on disk', argumentHint: '[json]', immediate: true },
17 { name: 'ruflo-swarm-topology', description: 'Same as /ruflo swarm topology: the topology, its leader and members', immediate: true },
18 { name: 'ruflo-swarm-claims', description: 'Same as /ruflo swarm claims: who has claimed which task', immediate: true },
19 { name: 'ruflo-swarm-consensus', description: 'Same as /ruflo swarm consensus: hive-mind proposals and votes', immediate: true },
20]
21
22/** The subcommands of `/ruflo swarm` this module answers; the old `/ruflo-swarm-<sub>` names are their aliases. */
23export const SWARM_SUBS = ['pane', 'status', 'topology', 'claims', 'consensus'] as const
24
25export type SwarmSub = (typeof SWARM_SUBS)[number]
26
27export const isSwarmSub = (word: string): word is SwarmSub => (SWARM_SUBS as readonly string[]).includes(word)
28
29const NO_SWARM = 'No ruflo swarm on disk in this folder. Start one with /ruflo-swarm:swarm init.'
30
31function missingLine(snapshot: Snapshot): string {
32 return snapshot.missing.length === 0 ? '' : `\nNot on disk: ${snapshot.missing.join(', ')}.`
33}
34
35export function statusText(state: State, nowMs: number, asJson: boolean): string {
36 const snapshot = state.snapshot
37
38 if (snapshot === null) {
39 return state.readError !== null ? `The swarm files could not be read: ${plain(state.readError, 200)}` : NO_SWARM
40 }
41
42 const model = paneModelOf(state, 100, 60, nowMs)
43
44 if (asJson) {
45 return `\`\`\`json\n${JSON.stringify(
46 {
47 swarm: snapshot.swarm,
48 members: model.members.map(member => ({ id: member.id, label: member.label, source: member.source, state: member.state, word: member.word, isLeader: member.isLeader })),
49 tasks: model.board,
50 claims: snapshot.claims,
51 hive: snapshot.hive,
52 ruos: snapshot.ruos,
53 route: state.route,
54 usage: state.usage,
55 missing: snapshot.missing,
56 },
57 null,
58 2,
59 )}\n\`\`\``
60 }
61
62 if (!snapshot.hasSwarm) {
63 return `${NO_SWARM}${missingLine(snapshot)}`
64 }
65
66 const b = model.board
67 const lines = [
68 model.title,
69 `agents: ${model.counts.map(entry => `${entry.state} ${entry.count}`).join(', ') || 'none'}`,
70 ...model.members.slice(0, 20).map(member => ` ${member.isLeader ? '★' : '·'} ${member.label} (${member.source}) ${member.word}`),
71 `tasks: ${b.pending} pending, ${b.claimed} claimed, ${b.done} done${b.failed > 0 ? `, ${b.failed} failed` : ''}`,
72 ...(model.remote.length > 0 ? ['ruOS hosts:', ...model.remote.map(line => ` ${line}`)] : []),
73 model.route,
74 model.usage,
75 ]
76
77 return `${lines.join('\n')}${missingLine(snapshot)}`
78}
79
80export function topologyText(state: State, nowMs: number): string {
81 const snapshot = state.snapshot
82
83 if (snapshot === null || !snapshot.hasSwarm) {
84 return NO_SWARM
85 }
86
87 const name = snapshot.swarm?.topology ?? snapshot.hive?.topology ?? 'unknown'
88 const members = membersOf(snapshot, state.activity, nowMs)
89 const extra = [
90 snapshot.swarm?.strategy !== undefined ? `strategy ${snapshot.swarm.strategy}` : '',
91 snapshot.swarm?.maxAgents !== undefined ? `max ${snapshot.swarm.maxAgents} agents` : '',
92 snapshot.hive?.strategy !== undefined ? `hive consensus ${snapshot.hive.strategy}` : '',
93 ].filter(part => part !== '')
94
95 return [`${name}${extra.length > 0 ? ` (${extra.join(', ')})` : ''}`, ...topologyLines(name, members, 80)].join('\n')
96}
97
98export function claimsText(state: State): string {
99 const snapshot = state.snapshot
100
101 if (snapshot === null || !snapshot.hasSwarm) {
102 return NO_SWARM
103 }
104
105 if (snapshot.missing.includes('claims')) {
106 return 'No claims on disk (.claude-flow/claims/claims.json). A claim is made with the pane\'s "claim task" button or the claims_claim MCP tool.'
107 }
108
109 if (snapshot.claims.length === 0) {
110 return 'No claims.'
111 }
112
113 return snapshot.claims
114 .slice(0, 40)
115 .map(claim => `${claim.issueId}: ${claim.status}${claim.isStealable ? ' (stealable)' : ''} by ${claim.claimant.kind} ${claim.claimant.agentType ?? ''} ${claim.claimant.id}${claim.handoffTo !== undefined ? ` → handoff to ${claim.handoffTo}` : ''}`.replace(/\s+/g, ' '))
116 .join('\n')
117}
118
119export function consensusText(state: State): string {
120 const hive = state.snapshot?.hive
121
122 if (hive === null || hive === undefined) {
123 return 'No hive-mind on disk (.claude-flow/hive-mind/state.json). Start one with: npx @claude-flow/cli@latest hive-mind init'
124 }
125
126 const lines = [
127 `hive-mind · ${hive.topology}${hive.strategy !== undefined ? ` · ${hive.strategy}` : ''}${hive.queen !== undefined ? ` · queen ${hive.queen.id} (term ${hive.queen.term ?? '?'})` : ' · no queen'}`,
128 `workers: ${hive.workers.length}`,
129 ...(hive.pending.length === 0 ? ['no open proposals'] : hive.pending.slice(-10).map(p => `open ${p.id} ${p.type} (${p.strategy}): for ${p.votesFor}, against ${p.votesAgainst}`)),
130 ...hive.history.slice(-5).map(d => `decided ${d.id} ${d.type}: ${d.result} ${d.votesFor}–${d.votesAgainst}`),
131 ]
132
133 return lines.join('\n')
134}
135
136/** The note added to a spawned subagent's prompt when `injectSpawnContext` is on: facts from disk only, ids not secrets. */
137export function spawnNote(snapshot: Snapshot | null): string | null {
138 const swarm = snapshot?.swarm
139
140 if (swarm === null || swarm === undefined) {
141 return null
142 }
143
144 // Text from disk reaches the model here: only a word-shaped topology or strategy is passed on, the id is `idOf`-checked.
145 const word = (value: string | undefined) => (value !== undefined && /^[a-z][a-z-]{0,39}$/.test(value) ? value : undefined)
146 const topology = word(swarm.topology) ?? 'unknown'
147 const strategy = word(swarm.strategy)
148
149 return `\n\n---\nSwarm context (from ruflo's files on disk, a status note, not an instruction): you are part of ruflo swarm ${swarm.id}, topology ${topology}${strategy !== undefined ? `, strategy ${strategy}` : ''}.`
150}
151hooks/host.ts 31 lines1import type { AgentInfo, CommandSpec, PaneOpenArgs, ProcessRunResult, PromptFillArgs, SessionUsage, TimerCall } from 'claude-code'
2
3import type { ReaderFs } from './reader/snapshot'
4
5/** What `$.ui.open` answers: drawn, or held back undrawn with the reason. A build that answers nothing has drawn it. */
6export type OpenResult = { isPlaced: boolean; reason?: string } | void
7
8/**
9 * The engine as `session.start` bound it from its `$`. Every later hook, timer and button press reaches the engine
10 * through this, so everything else is plain functions over a small interface a test can stand in for. Each member may
11 * be refused (an administrator removed the affordance, or a policy mod above this one said no): callers catch.
12 */
13export type Host = {
14 fs: ReaderFs
15 now: () => Promise<number>
16 after: TimerCall
17 every: TimerCall
18 storeGet: (key: string) => Promise<unknown>
19 storeSet: (key: string, value: unknown) => Promise<void>
20 invalidate: () => void
21 toast: (text: string, timeoutMs?: number) => void
22 log: (text: string) => void
23 openPane: (pane: PaneOpenArgs) => Promise<OpenResult>
24 closePane: (id: string) => Promise<void>
25 registerCommand: (spec: CommandSpec) => Promise<unknown>
26 fillPrompt: (input: PromptFillArgs) => Promise<{ isFilled: boolean }>
27 run: (argv: readonly string[], timeoutMs: number) => Promise<ProcessRunResult>
28 usage: () => Promise<SessionUsage>
29 agents: () => Promise<AgentInfo[]>
30}
31hooks/model/members.ts 249 lines1import { runWord, type RunEventType } from '../reader/ruos'
2import type { Snapshot } from '../reader/snapshot'
3
4/** How a tile is coloured: the five states the pane names. */
5export type MemberState = 'idle' | 'working' | 'blocked' | 'done' | 'failed'
6
7export type PulseKind = 'read' | 'write'
8
9/** One Claude Code agent loop as the engine reported it (`agent.spawn`, `$.agent.list()`), and what it has done since. */
10export type LoopRecord = {
11 id: string
12 /** The agent type it runs as, without a plugin's prefix (`ruflo-swarm:coordinator` → `coordinator`). */
13 role: string
14 name?: string
15 description?: string
16 status: string
17 calls: number
18 errors: number
19 lastTool?: string
20 lastSubject?: string
21 lastAtMs: number
22 pulse?: { kind: PulseKind; atMs: number }
23}
24
25/** What the session's loops are doing, kept by the hooks between reads of the disk. */
26export type Activity = {
27 /** Claude Code's own subagents and teammates, by agent id; `lead` is the main loop. */
28 loops: Map<string, LoopRecord>
29 turnId: string | null
30 isWorking: boolean
31 /** The last few calls of each loop, newest last, for the activity view. */
32 recent: Map<string, { tool: string; subject: string; isError: boolean; atMs: number }[]>
33}
34
35export const LEAD = 'lead'
36export const PULSE_MS = 2_000
37const RECENT = 12
38const MAX_LOOPS = 200
39
40export function newActivity(): Activity {
41 return { loops: new Map(), turnId: null, isWorking: false, recent: new Map() }
42}
43
44export type Member = {
45 id: string
46 label: string
47 role: string
48 source: 'ruflo' | 'claude' | 'lead' | 'queen'
49 state: MemberState
50 /** The word under the colour, as the source said it (`busy`, `terminated`, `running`). */
51 word: string
52 pulse?: PulseKind
53 isLeader: boolean
54 claims: number
55 calls: number
56}
57
58const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
59const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead'])
60
61/** Whether a tool call reads or writes files, or neither (a pulse is for file work only). */
62export function pulseOf(tool: string, command?: string): PulseKind | null {
63 if (WRITE_TOOLS.has(tool)) {
64 return 'write'
65 }
66
67 if (READ_TOOLS.has(tool)) {
68 return 'read'
69 }
70
71 if ((tool === 'Bash' || tool === 'PowerShell') && command !== undefined) {
72 return /(?:>|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\bmkdir\b|\btouch\b|\bpatch\b|\bgit\s+(?:apply|checkout|restore|commit))/.test(command) ? 'write' : 'read'
73 }
74
75 return null
76}
77
78export const roleOf = (type: string): string => (type.includes(':') ? type.slice(type.lastIndexOf(':') + 1) : type) || 'agent'
79
80function loopFor(activity: Activity, id: string, nowMs: number): LoopRecord {
81 const held = activity.loops.get(id)
82
83 if (held !== undefined) {
84 return held
85 }
86
87 if (activity.loops.size >= MAX_LOOPS) {
88 // Bounded: the loop not heard from longest makes room.
89 const oldest = [...activity.loops.values()].filter(loop => loop.id !== LEAD).sort((a, b) => a.lastAtMs - b.lastAtMs)[0]
90
91 if (oldest !== undefined) {
92 activity.loops.delete(oldest.id)
93 activity.recent.delete(oldest.id)
94 }
95 }
96
97 const loop: LoopRecord = { id, role: id === LEAD ? 'lead' : 'agent', status: 'running', calls: 0, errors: 0, lastAtMs: nowMs }
98
99 activity.loops.set(id, loop)
100
101 return loop
102}
103
104/** A subagent the engine started (`agent.spawn`'s answer), with the type and name it was asked for. */
105export function noteSpawn(activity: Activity, id: string, type: string, nowMs: number, name?: string, description?: string): void {
106 const loop = loopFor(activity, id, nowMs)
107
108 loop.role = roleOf(type)
109 loop.status = 'running'
110 loop.lastAtMs = nowMs
111
112 if (name !== undefined) {
113 loop.name = name
114 }
115
116 if (description !== undefined) {
117 loop.description = description
118 }
119}
120
121/** A tool call made by a loop (`agentId` absent: the main loop). Returns the pulse it lit, if any. */
122export function noteCall(activity: Activity, agentId: string | undefined, tool: string, subject: string, nowMs: number, command?: string): PulseKind | null {
123 const id = agentId ?? LEAD
124 const loop = loopFor(activity, id, nowMs)
125 const pulse = pulseOf(tool, command)
126
127 loop.calls += 1
128 loop.lastTool = tool
129 loop.lastSubject = subject
130 loop.lastAtMs = nowMs
131
132 if (pulse !== null) {
133 loop.pulse = { kind: pulse, atMs: nowMs }
134 }
135
136 return pulse
137}
138
139export function noteResult(activity: Activity, agentId: string | undefined, tool: string, subject: string, isError: boolean, nowMs: number): void {
140 const id = agentId ?? LEAD
141 const loop = loopFor(activity, id, nowMs)
142
143 if (isError) {
144 loop.errors += 1
145 }
146
147 activity.recent.set(id, [...(activity.recent.get(id) ?? []), { tool, subject, isError, atMs: nowMs }].slice(-RECENT))
148}
149
150/** A loop's own `turn.complete`: a subagent's answer is in. */
151export function noteDone(activity: Activity, agentId: string, reason: string, nowMs: number): void {
152 const loop = loopFor(activity, agentId, nowMs)
153
154 loop.status = reason === 'answer' ? 'completed' : reason === 'aborted' ? 'killed' : 'failed'
155 loop.lastAtMs = nowMs
156}
157
158/** `$.agent.list()` as the engine answered it: types and statuses for loops the hooks saw start before this module did. */
159export function noteListed(activity: Activity, listed: readonly { id: string; type: string; status: string; description?: string; name?: string }[], nowMs: number): void {
160 for (const agent of listed.slice(0, MAX_LOOPS)) {
161 const loop = loopFor(activity, agent.id, nowMs)
162
163 loop.role = roleOf(agent.type)
164 loop.status = agent.status
165
166 // A named Agent call runs as an in-process teammate, listed as type `teammate`: its name is what tells it apart.
167 if (agent.name !== undefined && agent.name !== '') {
168 loop.name = agent.name.slice(0, 40)
169 }
170
171 if (agent.description !== undefined && loop.description === undefined) {
172 loop.description = agent.description
173 }
174 }
175}
176
177const RUFLO_STATES: Record<string, MemberState> = { idle: 'idle', busy: 'working', terminated: 'done' }
178const RUN_STATES: Partial<Record<RunEventType, MemberState>> = { 'run.started': 'working', 'run.output': 'working', 'run.completed': 'done', 'run.failed': 'failed', 'run.stopped': 'done' }
179const LOOP_STATES: Record<string, MemberState> = { running: 'working', pending: 'working', completed: 'done', failed: 'failed', killed: 'failed' }
180
181/**
182 * The tiles, in a stable order: the leader first, then ruflo's agents as the store lists them, then Claude Code's loops.
183 * A ruflo agent with a blocked claim is blocked; a loop that has not called a tool for a while is idle, not working.
184 */
185export function membersOf(snapshot: Snapshot | null, activity: Activity, nowMs: number): Member[] {
186 const claims = snapshot?.claims ?? []
187 const claimsOf = (id: string) => claims.filter(claim => claim.claimant.id === id && claim.status !== 'completed')
188 const queen = snapshot?.hive?.queen
189 const members: Member[] = []
190
191 if (queen !== undefined) {
192 members.push({ id: queen.id, label: 'queen', role: 'queen', source: 'queen', state: 'working', word: `term ${queen.term ?? '?'}`, isLeader: true, claims: 0, calls: 0 })
193 }
194
195 const coordinator = (snapshot?.agents ?? []).find(agent => /coordinator|queen/.test(agent.type) && agent.status !== 'terminated')
196
197 const events = snapshot?.ruos.events ?? []
198
199 for (const agent of snapshot?.agents ?? []) {
200 const held = claimsOf(agent.id)
201 const isBlocked = held.some(claim => claim.status === 'blocked')
202 const remote = agent.remote
203 // On a ruOS desktop the newest lifecycle event for its run (or the agent) says where it stands.
204 const run = remote === undefined ? undefined : events.filter(event => event.type !== 'desktop.state' && ((remote.runId !== undefined && event.runId === remote.runId) || event.agentId === agent.id)).sort((a, b) => b.ts - a.ts)[0]
205 const runState = run === undefined ? undefined : RUN_STATES[run.type]
206
207 members.push({
208 id: agent.id,
209 label: remote === undefined ? agent.type : `${agent.type} @${remote.desktopName}`,
210 role: agent.type,
211 source: 'ruflo',
212 state: isBlocked ? 'blocked' : (runState ?? RUFLO_STATES[agent.status] ?? 'idle'),
213 word: isBlocked ? 'blocked' : run !== undefined ? runWord(run) : agent.status,
214 isLeader: queen === undefined && coordinator?.id === agent.id,
215 claims: held.length,
216 calls: 0,
217 })
218 }
219
220 const loops = [...activity.loops.values()].sort((a, b) => (a.id === LEAD ? -1 : b.id === LEAD ? 1 : 0))
221
222 for (const loop of loops) {
223 const pulse = loop.pulse !== undefined && nowMs - loop.pulse.atMs < PULSE_MS ? loop.pulse.kind : undefined
224 const isLead = loop.id === LEAD
225 const isQuiet = nowMs - loop.lastAtMs > 30_000
226 const base = isLead ? (activity.isWorking ? 'working' : 'idle') : (LOOP_STATES[loop.status] ?? 'idle')
227
228 members.push({
229 id: loop.id,
230 // A loop known only from its tool calls (no spawn or listing reached the hooks) is said to be one, not given a role.
231 label: isLead ? 'claude (main)' : (loop.name ?? (loop.role === 'agent' ? `subagent …${loop.id.slice(-4)}` : loop.role)),
232 role: loop.role,
233 source: isLead ? 'lead' : 'claude',
234 state: base === 'working' && isQuiet && !isLead ? 'idle' : base,
235 word: isLead ? (activity.isWorking ? 'turn running' : 'waiting') : loop.status,
236 ...(pulse !== undefined && { pulse }),
237 isLeader: isLead && queen === undefined && coordinator === undefined,
238 claims: 0,
239 calls: loop.calls,
240 })
241 }
242
243 return members
244}
245
246/** True while some tile is still lit: the frame timer keeps drawing until every pulse has faded. */
247export const isAnimating = (activity: Activity, nowMs: number): boolean =>
248 [...activity.loops.values()].some(loop => loop.pulse !== undefined && nowMs - loop.pulse.atMs < PULSE_MS)
249hooks/reader/parse.ts 365 lines1/**
2 * Parsers for the files ruflo writes under `.claude-flow/` and `.swarm/`, and for the JSON `hooks route` prints.
3 * Every one takes text from disk that another process wrote, so each tolerates any shape: what it cannot read
4 * is left out, never guessed. Nothing here reads, keeps or returns the hive's `hiveToken`.
5 */
6import { remoteHostOf, type RemoteHost } from './ruos'
7
8/** Text longer than this is not read: a store that size is not one the CLI wrote, and parsing it would stall a hook. */
9export const MAX_TEXT = 4_000_000
10/** At most this many records of one kind are kept; the rest are counted, not drawn. */
11export const MAX_RECORDS = 1_000
12
13export type SwarmInfo = {
14 id: string
15 topology: string
16 status: string
17 maxAgents?: number
18 strategy?: string
19 consensus?: string
20 agentIds: string[]
21 updatedAt?: string
22}
23
24export type AgentRecord = {
25 id: string
26 type: string
27 status: string
28 health?: number
29 taskCount?: number
30 model?: string
31 createdAt?: string
32 /** Where the agent runs, when ruflo-ruos placed it on a ruOS desktop (`config.host`, see ./ruos). */
33 remote?: RemoteHost
34}
35
36export type TaskRecord = {
37 id: string
38 type: string
39 description: string
40 priority: string
41 status: string
42 progress?: number
43 assignedTo: string[]
44}
45
46export type ClaimRecord = {
47 issueId: string
48 status: string
49 claimant: { kind: 'agent' | 'human'; id: string; agentType?: string }
50 progress?: number
51 handoffTo?: string
52 isStealable: boolean
53}
54
55export type Proposal = { id: string; type: string; status: string; strategy: string; votesFor: number; votesAgainst: number; proposedBy: string }
56export type Decision = { id: string; type: string; result: string; votesFor: number; votesAgainst: number; decidedAt: string }
57
58export type HiveInfo = {
59 topology: string
60 strategy?: string
61 queen?: { id: string; term?: number }
62 workers: string[]
63 pending: Proposal[]
64 history: Decision[]
65}
66
67export type RoutePick = {
68 task: string
69 agent: string
70 confidence: number
71 matched: boolean
72 pattern?: string
73 alternatives: { agent: string; confidence: number }[]
74 method?: string
75 atMs: number
76}
77
78const ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/
79
80/** Plain printable text of at most `max` characters: no control characters reach the terminal. */
81export function plain(value: unknown, max = 200): string {
82 if (typeof value !== 'string') {
83 return ''
84 }
85
86 const cleaned = value.replace(/[\u0000-\u001f\u007f-\u009f---]/g, ' ').replace(/\s+/g, ' ').trim()
87
88 return cleaned.length <= max ? cleaned : `${cleaned.slice(0, Math.max(0, max - 1))}…`
89}
90
91/** An id as ruflo mints them (`agent-…`, `task-…`, `proposal-…`), or null: only such a string ever reaches an argv. */
92export function idOf(value: unknown): string | null {
93 return typeof value === 'string' && ID.test(value) ? value : null
94}
95
96const numberOf = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
97const stringOf = (value: unknown, max = 80): string | undefined => (typeof value === 'string' && value !== '' ? plain(value, max) : undefined)
98const recordOf = (value: unknown): Record<string, unknown> | null =>
99 value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
100
101/** JSON text to a plain object, or null for anything else (too long, malformed, an array, a scalar). */
102export function jsonObject(text: string | null): Record<string, unknown> | null {
103 if (text === null || text.length > MAX_TEXT) {
104 return null
105 }
106
107 try {
108 return recordOf(JSON.parse(text))
109 } catch {
110 return null
111 }
112}
113
114const valuesOf = (value: unknown): unknown[] => {
115 const record = recordOf(value)
116
117 return record === null ? [] : Object.values(record).slice(0, MAX_RECORDS)
118}
119
120/** `.claude-flow/swarm/swarm-state.json`: the running swarm, else the one updated last. */
121export function parseSwarmStore(text: string | null): SwarmInfo | null {
122 const swarms = valuesOf(jsonObject(text)?.swarms)
123 .map(recordOf)
124 .flatMap(swarm => {
125 const id = idOf(swarm?.swarmId)
126
127 if (swarm === null || id === null) {
128 return []
129 }
130
131 const config = recordOf(swarm.config)
132
133 return [
134 {
135 id,
136 topology: stringOf(swarm.topology, 40) ?? 'unknown',
137 status: stringOf(swarm.status, 40) ?? 'unknown',
138 ...(numberOf(swarm.maxAgents) !== undefined && { maxAgents: numberOf(swarm.maxAgents) }),
139 ...(stringOf(config?.strategy, 40) !== undefined && { strategy: stringOf(config?.strategy, 40) }),
140 ...(stringOf(config?.consensusMechanism, 40) !== undefined && { consensus: stringOf(config?.consensusMechanism, 40) }),
141 agentIds: (Array.isArray(swarm.agents) ? swarm.agents : []).slice(0, MAX_RECORDS).flatMap(agent => (idOf(agent) !== null ? [agent as string] : [])),
142 ...(stringOf(swarm.updatedAt, 40) !== undefined && { updatedAt: stringOf(swarm.updatedAt, 40) }),
143 } satisfies SwarmInfo,
144 ]
145 })
146
147 const byRecency = (a: SwarmInfo, b: SwarmInfo) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')
148
149 return [...swarms.filter(swarm => swarm.status === 'running')].sort(byRecency)[0] ?? [...swarms].sort(byRecency)[0] ?? null
150}
151
152/** `.swarm/state.json`: the pointer `swarm init` leaves, which names the swarm and its strategy. */
153export function parseSwarmPointer(text: string | null): { id: string; topology?: string; strategy?: string; status?: string } | null {
154 const value = jsonObject(text)
155 const id = idOf(value?.id)
156
157 if (value === null || id === null) {
158 return null
159 }
160
161 return {
162 id,
163 ...(stringOf(value.topology, 40) !== undefined && { topology: stringOf(value.topology, 40) }),
164 ...(stringOf(value.strategy, 40) !== undefined && { strategy: stringOf(value.strategy, 40) }),
165 ...(stringOf(value.status, 40) !== undefined && { status: stringOf(value.status, 40) }),
166 }
167}
168
169/** `.claude-flow/agents/store.json`. */
170export function parseAgents(text: string | null): AgentRecord[] {
171 return valuesOf(jsonObject(text)?.agents).flatMap(entry => {
172 const agent = recordOf(entry)
173 const id = idOf(agent?.agentId)
174
175 if (agent === null || id === null) {
176 return []
177 }
178
179 return [
180 {
181 id,
182 type: stringOf(agent.agentType, 40) ?? 'agent',
183 status: stringOf(agent.status, 20) ?? 'unknown',
184 ...(numberOf(agent.health) !== undefined && { health: numberOf(agent.health) }),
185 ...(numberOf(agent.taskCount) !== undefined && { taskCount: numberOf(agent.taskCount) }),
186 ...(stringOf(agent.model, 40) !== undefined && { model: stringOf(agent.model, 40) }),
187 ...(stringOf(agent.createdAt, 40) !== undefined && { createdAt: stringOf(agent.createdAt, 40) }),
188 ...(remoteHostOf(agent.config) !== null && { remote: remoteHostOf(agent.config) as RemoteHost }),
189 },
190 ]
191 })
192}
193
194/** `.claude-flow/tasks/store.json`. */
195export function parseTasks(text: string | null): TaskRecord[] {
196 return valuesOf(jsonObject(text)?.tasks).flatMap(entry => {
197 const task = recordOf(entry)
198 const id = idOf(task?.taskId)
199
200 if (task === null || id === null) {
201 return []
202 }
203
204 return [
205 {
206 id,
207 type: stringOf(task.type, 40) ?? 'task',
208 description: plain(task.description, 300),
209 priority: stringOf(task.priority, 20) ?? 'normal',
210 status: stringOf(task.status, 20) ?? 'unknown',
211 ...(numberOf(task.progress) !== undefined && { progress: numberOf(task.progress) }),
212 assignedTo: (Array.isArray(task.assignedTo) ? task.assignedTo : []).slice(0, 50).flatMap(agent => (idOf(agent) !== null ? [agent as string] : [])),
213 },
214 ]
215 })
216}
217
218/** `.claude-flow/claims/claims.json`: issue claims (who works on what), not the authorization file `.claude-flow/claims.json`. */
219export function parseClaims(text: string | null): ClaimRecord[] {
220 const store = jsonObject(text)
221 const stealable = recordOf(store?.stealable) ?? {}
222
223 return valuesOf(store?.claims).flatMap(entry => {
224 const claim = recordOf(entry)
225 const issueId = idOf(claim?.issueId)
226 const claimant = recordOf(claim?.claimant)
227
228 if (claim === null || issueId === null || claimant === null) {
229 return []
230 }
231
232 const isAgent = claimant.type === 'agent'
233 const who = idOf(isAgent ? claimant.agentId : claimant.userId)
234
235 if (who === null) {
236 return []
237 }
238
239 const handoff = recordOf(claim.handoffTo)
240 const handoffTo = idOf(handoff?.agentId ?? handoff?.userId)
241
242 return [
243 {
244 issueId,
245 status: stringOf(claim.status, 30) ?? 'unknown',
246 claimant: { kind: isAgent ? 'agent' : 'human', id: who, ...(stringOf(claimant.agentType, 40) !== undefined && { agentType: stringOf(claimant.agentType, 40) }) },
247 ...(numberOf(claim.progress) !== undefined && { progress: numberOf(claim.progress) }),
248 ...(handoffTo !== null && { handoffTo }),
249 isStealable: claim.status === 'stealable' || Object.hasOwn(stealable, issueId),
250 },
251 ]
252 })
253}
254
255const votesOf = (value: unknown): { votesFor: number; votesAgainst: number } => {
256 const votes = valuesOf(value)
257
258 return { votesFor: votes.filter(vote => vote === true).length, votesAgainst: votes.filter(vote => vote === false).length }
259}
260
261/** `.claude-flow/hive-mind/state.json`, without its capability token. */
262export function parseHive(text: string | null): HiveInfo | null {
263 const hive = jsonObject(text)
264
265 if (hive === null || hive.initialized !== true) {
266 return null
267 }
268
269 const queen = recordOf(hive.queen)
270 const queenId = idOf(queen?.agentId)
271 const consensus = recordOf(hive.consensus)
272
273 const pending = (Array.isArray(consensus?.pending) ? consensus.pending : []).slice(-50).flatMap(entry => {
274 const proposal = recordOf(entry)
275 const id = idOf(proposal?.proposalId)
276
277 return proposal === null || id === null
278 ? []
279 : [
280 {
281 id,
282 type: stringOf(proposal.type, 40) ?? 'proposal',
283 status: stringOf(proposal.status, 20) ?? 'pending',
284 strategy: stringOf(proposal.strategy, 20) ?? 'unknown',
285 proposedBy: stringOf(proposal.proposedBy, 60) ?? 'unknown',
286 ...votesOf(proposal.votes),
287 },
288 ]
289 })
290
291 const history = (Array.isArray(consensus?.history) ? consensus.history : []).slice(-50).flatMap(entry => {
292 const decision = recordOf(entry)
293 const id = idOf(decision?.proposalId)
294 const votes = recordOf(decision?.votes)
295
296 return decision === null || id === null
297 ? []
298 : [
299 {
300 id,
301 type: stringOf(decision.type, 40) ?? 'proposal',
302 result: stringOf(decision.result, 20) ?? 'unknown',
303 votesFor: numberOf(votes?.for) ?? 0,
304 votesAgainst: numberOf(votes?.against) ?? 0,
305 decidedAt: stringOf(decision.decidedAt, 40) ?? '',
306 },
307 ]
308 })
309
310 return {
311 topology: stringOf(hive.topology, 40) ?? 'unknown',
312 ...(stringOf(hive.consensusStrategy, 30) !== undefined && { strategy: stringOf(hive.consensusStrategy, 30) }),
313 ...(queenId !== null && { queen: { id: queenId, ...(numberOf(queen?.term) !== undefined && { term: numberOf(queen?.term) }) } }),
314 workers: (Array.isArray(hive.workers) ? hive.workers : []).slice(0, MAX_RECORDS).flatMap(worker => (idOf(worker) !== null ? [worker as string] : [])),
315 pending,
316 history,
317 }
318}
319
320/**
321 * What `hooks route --format json` printed (log lines may come first), or the `hooks_route` MCP tool answered, as one pick.
322 * The router's own `matched` flag is kept as it said it; whether the confidence clears a threshold is the view's call.
323 */
324export function parseRoute(text: string, atMs: number): RoutePick | null {
325 const start = text.indexOf('{')
326 const value = start < 0 ? null : jsonObject(text.slice(start, Math.min(text.length, start + 200_000)))
327 const primary = recordOf(value?.primaryAgent)
328 const agent = stringOf(primary?.type, 40)
329 const confidence = numberOf(primary?.confidence)
330
331 if (value === null || agent === undefined || confidence === undefined) {
332 return null
333 }
334
335 const routing = recordOf(value.routing)
336
337 return {
338 task: plain(value.task, 200),
339 agent,
340 confidence: Math.max(0, Math.min(1, confidence)),
341 matched: value.matched === true,
342 ...(stringOf(value.matchedPattern, 40) !== undefined && { pattern: stringOf(value.matchedPattern, 40) }),
343 alternatives: (Array.isArray(value.alternativeAgents) ? value.alternativeAgents : []).slice(0, 3).flatMap(entry => {
344 const alt = recordOf(entry)
345 const type = stringOf(alt?.type, 40)
346 const score = numberOf(alt?.confidence)
347
348 return type !== undefined && score !== undefined ? [{ agent: type, confidence: Math.max(0, Math.min(1, score)) }] : []
349 }),
350 ...(stringOf(routing?.method, 40) !== undefined && { method: stringOf(routing?.method, 40) }),
351 atMs,
352 }
353}
354
355/** A pick held in `$.store` read back: every field checked, as anything else from storage is. */
356export function routeFromStore(value: unknown): RoutePick | null {
357 const pick = recordOf(value)
358
359 if (pick === null || typeof pick.task !== 'string' || typeof pick.agent !== 'string' || numberOf(pick.confidence) === undefined || numberOf(pick.atMs) === undefined) {
360 return null
361 }
362
363 return parseRoute(JSON.stringify({ task: pick.task, matched: pick.matched, matchedPattern: pick.pattern, primaryAgent: { type: pick.agent, confidence: pick.confidence }, alternativeAgents: [], routing: { method: pick.method } }), numberOf(pick.atMs) ?? 0)
364}
365hooks/reader/snapshot.ts 138 lines1import {
2 parseAgents,
3 parseClaims,
4 parseHive,
5 parseSwarmPointer,
6 parseSwarmStore,
7 parseTasks,
8 type AgentRecord,
9 type ClaimRecord,
10 type HiveInfo,
11 type SwarmInfo,
12 type TaskRecord,
13} from './parse'
14import { EVENTS_MAX, parseEvents, parseHosts, RUOS_EVENTS, RUOS_HOSTS, type RunEvent, type RuosHost } from './ruos'
15
16/** The file calls a reader makes, as `$.fs` answers them; every one may be refused. */
17export type ReaderFs = {
18 read: (path: string) => Promise<string>
19 stat: (path: string) => Promise<{ mtimeMs?: number; size?: number } | undefined>
20}
21
22/** Where ruflo keeps each fact, relative to the session's working directory. */
23export const FILES = {
24 swarm: '.claude-flow/swarm/swarm-state.json',
25 pointer: '.swarm/state.json',
26 agents: '.claude-flow/agents/store.json',
27 tasks: '.claude-flow/tasks/store.json',
28 claims: '.claude-flow/claims/claims.json',
29 hive: '.claude-flow/hive-mind/state.json',
30} as const
31
32export type FileKey = keyof typeof FILES
33
34export type Snapshot = {
35 swarm: SwarmInfo | null
36 agents: AgentRecord[]
37 tasks: TaskRecord[]
38 claims: ClaimRecord[]
39 hive: HiveInfo | null
40 /** ruOS desktops that host swarm agents (ruflo-ruos): null hosts when the snapshot file is absent. */
41 ruos: { hosts: RuosHost[] | null; events: RunEvent[]; note?: string }
42 /** The facts that are not on disk (no file, unreadable, or not in a shape ruflo writes), by key. */
43 missing: FileKey[]
44 /** True when any of ruflo's swarm files is there at all: the pane has something of its own to show. */
45 hasSwarm: boolean
46 readAtMs: number
47}
48
49/** The text of each file as last read, by path, with the mtime it was read at: an unchanged file is not read again. */
50export type ReadCache = Map<string, { mtimeMs: number; size: number; text: string }>
51
52async function textOf(fs: ReaderFs, cache: ReadCache, path: string): Promise<string | null> {
53 try {
54 const stat = await fs.stat(path)
55 const mtimeMs = stat?.mtimeMs ?? -1
56 const size = stat?.size ?? -1
57 const held = cache.get(path)
58
59 if (held !== undefined && mtimeMs >= 0 && held.mtimeMs === mtimeMs && held.size === size) {
60 return held.text
61 }
62
63 const text = await fs.read(path)
64
65 cache.set(path, { mtimeMs, size, text })
66
67 return text
68 } catch {
69 cache.delete(path)
70
71 return null
72 }
73}
74
75/** Reads every swarm fact ruflo keeps on disk, in parallel; a refused or absent file is a missing fact, never an error. */
76export async function readSnapshot(fs: ReaderFs, cache: ReadCache, nowMs: number): Promise<Snapshot> {
77 const keys = Object.keys(FILES) as FileKey[]
78 const texts = await Promise.all(keys.map(key => textOf(fs, cache, FILES[key])))
79 const text = (key: FileKey) => texts[keys.indexOf(key)] ?? null
80
81 const pointer = parseSwarmPointer(text('pointer'))
82 const stored = parseSwarmStore(text('swarm'))
83 // The pointer names the swarm `swarm init` made last; the store has its members. Either alone still names a swarm.
84 const swarm: SwarmInfo | null =
85 stored ??
86 (pointer !== null
87 ? { id: pointer.id, topology: pointer.topology ?? 'unknown', status: pointer.status ?? 'unknown', agentIds: [], ...(pointer.strategy !== undefined && { strategy: pointer.strategy }) }
88 : null)
89
90 const ruos = await readRuos(fs, cache)
91 const parsed = {
92 swarm,
93 agents: parseAgents(text('agents')),
94 tasks: parseTasks(text('tasks')),
95 claims: parseClaims(text('claims')),
96 hive: parseHive(text('hive')),
97 ruos,
98 }
99
100 const missing = keys.filter(key => {
101 switch (key) {
102 case 'swarm':
103 return stored === null
104 case 'pointer':
105 return pointer === null
106 case 'hive':
107 return parsed.hive === null
108 default:
109 return text(key) === null
110 }
111 })
112
113 return {
114 ...parsed,
115 missing,
116 hasSwarm: swarm !== null || parsed.agents.length > 0 || parsed.hive !== null,
117 readAtMs: nowMs,
118 }
119}
120
121/** The ruOS host snapshot and the tail of its event log. The log is read whole (there is no ranged read), so past a size it is not read. */
122async function readRuos(fs: ReaderFs, cache: ReadCache): Promise<Snapshot['ruos']> {
123 const hosts = parseHosts(await textOf(fs, cache, RUOS_HOSTS))
124 let size = -1
125
126 try {
127 size = (await fs.stat(RUOS_EVENTS))?.size ?? -1
128 } catch {
129 return { hosts, events: [] }
130 }
131
132 if (size > EVENTS_MAX) {
133 return { hosts, events: [], note: `ruOS event log is ${Math.round(size / 1_000_000)} MB: not read` }
134 }
135
136 return { hosts, events: parseEvents(await textOf(fs, cache, RUOS_EVENTS)) }
137}
138hooks/state.ts 132 lines1import type { PluginOptions, Timer } from 'claude-code'
2
3import type { ActionSpec } from './actions/argv'
4import type { Activity } from './model/members'
5import { routeFromStore, type RoutePick } from './reader/parse'
6import type { Snapshot } from './reader/snapshot'
7
8export const PANE_ID = 'ruflo-swarm'
9
10/**
11 * The `$.store` key the few facts that must outlive a hot reload sit under. `$.store` is the plugin's, not the folder's,
12 * so the key carries the working directory: a router pick or a selection never follows the person into another project.
13 */
14export const storeKeyOf = (cwd: string): string => `ruflo-swarm/ui:${cwd}`
15
16/** How the pane reaches the ruflo CLI. Each is a fixed argv prefix; only `npx` may touch the network. */
17export const CLI_PREFIXES = {
18 'npx-offline': ['npx', '--offline', '-y', '@claude-flow/cli@latest'],
19 npx: ['npx', '-y', '@claude-flow/cli@latest'],
20 ruflo: ['ruflo'],
21 'claude-flow': ['claude-flow'],
22} as const satisfies Record<string, readonly string[]>
23
24export type CliChoice = keyof typeof CLI_PREFIXES
25
26export type Options = {
27 /** `auto` opens the pane once a swarm is on disk and the layout docks panes; `command` only on request; `off` never. */
28 panel: 'auto' | 'command' | 'off'
29 cli: CliChoice
30 /** A router pick under this confidence is drawn as "below threshold", with its score. */
31 routeThreshold: number
32 /** Appends a short swarm note to the prompt of each subagent Claude Code spawns. */
33 injectSpawnContext: boolean
34 /** Records a bounded, content-free trail of engine events into ruflo memory. */
35 audit: boolean
36}
37
38const PANELS = new Set(['auto', 'command', 'off'])
39
40/** The options as the settings hold them, each one checked: a value the plugin does not know is its default. */
41export function optionsOf(raw: PluginOptions): Options {
42 const value = (raw ?? {}) as Record<string, unknown>
43 // `command` by default since ruflo-console: its cockpit is the pane that opens by itself, and two would crowd the dock.
44 const panel = typeof value.panel === 'string' && PANELS.has(value.panel) ? (value.panel as Options['panel']) : 'command'
45 const cli = typeof value.cli === 'string' && value.cli in CLI_PREFIXES ? (value.cli as CliChoice) : 'npx-offline'
46 const threshold = typeof value.routeThreshold === 'number' ? value.routeThreshold : Number(value.routeThreshold)
47
48 return {
49 panel,
50 cli,
51 routeThreshold: Number.isFinite(threshold) && threshold >= 0 && threshold <= 1 ? threshold : 0.5,
52 injectSpawnContext: value.injectSpawnContext === true,
53 audit: value.audit === true,
54 }
55}
56
57/** A destructive or mutating action waiting for the person's second press. */
58export type PendingConfirm = { label: string; spec: ActionSpec; askedAtMs: number }
59
60/** What an action did, as the pane says it: what ran, how it exited, and whether the disk shows the change. */
61export type ActionOutcome = { label: string; ok: boolean; verified: 'yes' | 'no' | 'n/a'; detail: string; atMs: number }
62
63export type Usage = { costUsd?: number; contextTokens?: number; contextPercent?: number; contextWindow?: number; readAtMs: number }
64
65export type State = {
66 options: Options
67 cwd: string
68 snapshot: Snapshot | null
69 readError: string | null
70 isRefreshing: boolean
71 isRefreshQueued: boolean
72 activity: Activity
73 usage: Usage | null
74 route: RoutePick | null
75 pane: { isOpen: boolean; isClosedByPerson: boolean; columns: number; rows: number }
76 viewport: { isFullscreen?: boolean }
77 /** The tile the buttons act on, by member id: survives a re-read that reorders the tiles. */
78 selected: string | null
79 selectedTask: string | null
80 confirm: PendingConfirm | null
81 outcome: ActionOutcome | null
82 isActing: boolean
83 /** Lines an action asked to show (an agent's logs), with whose they are. */
84 detail: { title: string; lines: string[] } | null
85 timers: Map<string, Timer>
86 audit: { buffer: string[]; dropped: number; flushedAtMs: number }
87}
88
89export function newState(raw: PluginOptions, activity: Activity): State {
90 return {
91 options: optionsOf(raw),
92 cwd: '',
93 snapshot: null,
94 readError: null,
95 isRefreshing: false,
96 isRefreshQueued: false,
97 activity,
98 usage: null,
99 route: null,
100 pane: { isOpen: false, isClosedByPerson: false, columns: 0, rows: 0 },
101 viewport: {},
102 selected: null,
103 selectedTask: null,
104 confirm: null,
105 outcome: null,
106 isActing: false,
107 detail: null,
108 timers: new Map(),
109 audit: { buffer: [], dropped: 0, flushedAtMs: 0 },
110 }
111}
112
113/** The part of the state written to `$.store`, so a hot reload keeps what the person chose. */
114export type Persisted = { selected: string | null; selectedTask: string | null; isClosedByPerson: boolean; route: RoutePick | null }
115
116export function persistedOf(state: State): Persisted {
117 return { selected: state.selected, selectedTask: state.selectedTask, isClosedByPerson: state.pane.isClosedByPerson, route: state.route }
118}
119
120export function restore(state: State, value: unknown): void {
121 if (value === null || typeof value !== 'object') {
122 return
123 }
124
125 const held = value as Partial<Persisted>
126
127 state.selected = typeof held.selected === 'string' ? held.selected : null
128 state.selectedTask = typeof held.selectedTask === 'string' ? held.selectedTask : null
129 state.pane.isClosedByPerson = held.isClosedByPerson === true
130 state.route = routeFromStore(held.route)
131}
132hooks/views/model.ts 213 lines1import { membersOf, type Member, type MemberState } from '../model/members'
2import { topologyLines } from '../model/topology'
3import type { Decision, Proposal, TaskRecord } from '../reader/parse'
4import type { FileKey } from '../reader/snapshot'
5import type { ActionOutcome, PendingConfirm, State } from '../state'
6
7/** Below this many columns the pane draws its narrow form: a list, not a grid, and the board as counts. */
8export const NARROW = 44
9export const TILE = 18
10
11export type TaskRow = { id: string; status: string; type: string; description: string; owner: string; isSelected: boolean }
12
13/** Everything the pane draws, worked out from the state with no element in sight, so it is tested as data. */
14export type PaneModel = {
15 columns: number
16 rows: number
17 isNarrow: boolean
18 hasSwarm: boolean
19 title: string
20 members: Member[]
21 selected: Member | null
22 counts: { state: MemberState; count: number }[]
23 topology: string[]
24 board: { pending: number; claimed: number; done: number; failed: number; rows: TaskRow[]; selected: TaskRow | null; total: number }
25 proposals: Proposal[]
26 decisions: Decision[]
27 usage: string
28 route: string
29 /** ruOS desktops hosting swarm agents, one line each; empty when no agent runs remotely and no host file is there. */
30 remote: string[]
31 missing: string[]
32 confirm: PendingConfirm | null
33 outcome: ActionOutcome | null
34 detail: State['detail']
35 next: { text: string; why: string } | null
36 isActing: boolean
37}
38
39const MISSING_WORDS: Record<FileKey, string> = {
40 swarm: 'swarm store',
41 pointer: 'swarm pointer',
42 agents: 'agent store',
43 tasks: 'task store',
44 claims: 'claims',
45 hive: 'hive-mind',
46}
47
48const CLAIMED = new Set(['in_progress', 'running', 'assigned'])
49const DONE = new Set(['completed', 'done'])
50const FAILED = new Set(['failed', 'cancelled'])
51
52const ageOf = (ms: number): string => {
53 const s = Math.max(0, Math.round(ms / 1000))
54
55 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`
56}
57
58/** The ruOS hosts section: each desktop with its state, heartbeat age and agents; "not on disk" when agents name a host and no snapshot exists. */
59export function remoteLines(snapshot: State['snapshot'], nowMs: number): string[] {
60 if (snapshot === null) {
61 return []
62 }
63
64 const hosts = snapshot.ruos.hosts
65 const remoteAgents = snapshot.agents.filter(agent => agent.remote !== undefined).length
66 const note = snapshot.ruos.note !== undefined ? [snapshot.ruos.note] : []
67
68 if (hosts === null) {
69 return remoteAgents > 0 ? [`ruOS hosts: not on disk (${remoteAgents} agent${remoteAgents === 1 ? '' : 's'} name one)`, ...note] : note
70 }
71
72 return [
73 ...hosts.slice(0, 20).map(host => {
74 const beat = host.heartbeatAt !== undefined ? ` · heartbeat ${ageOf(nowMs - Date.parse(host.heartbeatAt))} ago` : ' · no heartbeat'
75
76 return `${host.name} · ${host.state}${beat} · ${host.agents.length} agent${host.agents.length === 1 ? '' : 's'}`
77 }),
78 ...(hosts.length > 20 ? [`+${hosts.length - 20} more hosts`] : []),
79 ...(hosts.length === 0 ? ['no ruOS hosts'] : []),
80 ...note,
81 ]
82}
83
84const short = (id: string) => (id.length > 14 ? `…${id.slice(-8)}` : id)
85
86/** A cost or a token count as the engine gave it; a figure it left out is `n/a`, never zero. */
87export function usageLine(usage: State['usage']): string {
88 if (usage === null) {
89 return 'usage: not read yet'
90 }
91
92 const cost = usage.costUsd !== undefined ? `$${usage.costUsd.toFixed(usage.costUsd < 1 ? 3 : 2)}` : 'n/a'
93 const tokens = usage.contextTokens !== undefined ? `${Math.round(usage.contextTokens / 1000)}k` : 'n/a'
94 const window = usage.contextWindow !== undefined ? `/${Math.round(usage.contextWindow / 1000)}k` : ''
95 const percent = usage.contextPercent !== undefined ? ` (${Math.round(usage.contextPercent)}%)` : ''
96
97 return `cost ${cost} · context ${tokens}${window}${percent}`
98}
99
100/**
101 * The router's last pick the pane has seen (kept in `$.store`, so it may come from an earlier session: its age says),
102 * with its score; under the threshold it says so instead of naming a winner.
103 */
104export function routeLine(route: State['route'], threshold: number, nowMs = Date.now()): string {
105 if (route === null) {
106 return 'router: no pick seen yet'
107 }
108
109 const score = `${Math.round(route.confidence * 100)}%`
110 const alt = route.alternatives[0] !== undefined ? ` · alt ${route.alternatives[0].agent} ${Math.round(route.alternatives[0].confidence * 100)}%` : ''
111
112 const age = route.atMs > 0 ? ` · ${ageOf(nowMs - route.atMs)} ago` : ''
113
114 if (!route.matched) {
115 return `router: no match (best ${route.agent} ${score})${age}`
116 }
117
118 return route.confidence < threshold
119 ? `router: below threshold ${Math.round(threshold * 100)}% (best ${route.agent} ${score})${alt}${age}`
120 : `router: ${route.agent} ${score}${route.pattern !== undefined ? ` (${route.pattern})` : ''}${alt}${age}`
121}
122
123function nextOf(state: State, members: readonly Member[], board: PaneModel['board']): PaneModel['next'] {
124 const snapshot = state.snapshot
125
126 if (snapshot === null || !snapshot.hasSwarm) {
127 return { text: '/ruflo-swarm:swarm init', why: 'no swarm on disk here: start one' }
128 }
129
130 if (!members.some(member => member.source === 'ruflo')) {
131 return { text: 'npx @claude-flow/cli@latest agent spawn -t coder --name coder', why: 'the swarm has no ruflo agents yet' }
132 }
133
134 if (board.total === 0) {
135 return { text: 'npx @claude-flow/cli@latest task create --type implementation --description "…"', why: 'no tasks on the board' }
136 }
137
138 if (board.pending > 0 && board.claimed === 0) {
139 return { text: '/ruflo-swarm:swarm status', why: `${board.pending} task${board.pending === 1 ? '' : 's'} waiting and none claimed` }
140 }
141
142 return null
143}
144
145export function paneModelOf(state: State, columns: number, rows: number, nowMs: number): PaneModel {
146 const snapshot = state.snapshot
147 const members = membersOf(snapshot, state.activity, nowMs)
148 const selected = members.find(member => member.id === state.selected) ?? members[0] ?? null
149 const counts = (['working', 'blocked', 'idle', 'done', 'failed'] as const)
150 .map(memberState => ({ state: memberState, count: members.filter(member => member.state === memberState).length }))
151 .filter(entry => entry.count > 0)
152
153 const claimsByIssue = new Map((snapshot?.claims ?? []).map(claim => [claim.issueId, claim]))
154 const typeOf = new Map(members.map(member => [member.id, member.label]))
155
156 const taskRow = (task: TaskRecord): TaskRow => {
157 const claim = claimsByIssue.get(task.id)
158 const ownerId = claim?.claimant.id ?? task.assignedTo[0]
159
160 return {
161 id: task.id,
162 status: claim !== undefined && claim.status !== 'active' ? `${task.status}/${claim.status}` : task.status,
163 type: task.type,
164 description: task.description,
165 owner: ownerId === undefined ? 'unassigned' : (typeOf.get(ownerId) ?? short(ownerId)),
166 isSelected: task.id === state.selectedTask,
167 }
168 }
169
170 const tasks = snapshot?.tasks ?? []
171 // Open work first, newest store order kept within each group.
172 const rank = (task: TaskRecord) => (CLAIMED.has(task.status) ? 0 : task.status === 'pending' ? 1 : FAILED.has(task.status) ? 2 : 3)
173 const ordered = [...tasks].sort((a, b) => rank(a) - rank(b)).map(taskRow)
174 const selectedTask = ordered.find(row => row.id === state.selectedTask) ?? ordered[0] ?? null
175
176 const board = {
177 pending: tasks.filter(task => task.status === 'pending').length,
178 claimed: tasks.filter(task => CLAIMED.has(task.status) || claimsByIssue.get(task.id)?.status === 'active').length,
179 done: tasks.filter(task => DONE.has(task.status)).length,
180 failed: tasks.filter(task => FAILED.has(task.status)).length,
181 rows: ordered.map(row => ({ ...row, isSelected: row.id === selectedTask?.id })),
182 selected: selectedTask,
183 total: tasks.length,
184 }
185
186 const swarm = snapshot?.swarm
187 const topologyName = swarm?.topology ?? snapshot?.hive?.topology ?? 'unknown'
188
189 return {
190 columns,
191 rows,
192 isNarrow: columns < NARROW,
193 hasSwarm: snapshot?.hasSwarm === true,
194 title: swarm !== undefined && swarm !== null ? `swarm ${short(swarm.id)} · ${topologyName} · ${swarm.status}` : snapshot?.hive !== null && snapshot?.hive !== undefined ? `hive-mind · ${topologyName}` : 'ruflo swarm',
195 members,
196 selected,
197 counts,
198 topology: topologyLines(topologyName, members, columns),
199 board,
200 proposals: snapshot?.hive?.pending.filter(proposal => proposal.status === 'pending') ?? [],
201 decisions: (snapshot?.hive?.history ?? []).slice(-2).reverse(),
202 usage: usageLine(state.usage),
203 route: routeLine(state.route, state.options.routeThreshold, nowMs),
204 remote: remoteLines(snapshot, nowMs),
205 missing: (snapshot?.missing ?? []).map(key => MISSING_WORDS[key]),
206 confirm: state.confirm,
207 outcome: state.outcome,
208 detail: state.detail,
209 next: nextOf(state, members, board),
210 isActing: state.isActing,
211 }
212}
213hooks/views/pane.tsx 404 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { Elements, RenderChildren, RenderElement } from 'claude-code'
5
6import type { Member } from '../model/members'
7import { ACCENT, BAD, GOOD, HEAD, PULSE_COLORS, STATE_COLORS, STATE_GLYPHS, WARN } from './palette'
8import { TILE, type PaneModel } from './model'
9
10export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
11
12/** What the buttons do. Each is a closure over the host: the view calls nothing on the engine itself. */
13export type PaneActions = {
14 hide: () => void
15 prev: () => void
16 next: () => void
17 taskPrev: () => void
18 taskNext: () => void
19 stop: () => void
20 logs: () => void
21 pause: () => void
22 resume: () => void
23 claim: () => void
24 offer: () => void
25 steal: () => void
26 handoff: () => void
27 reroute: () => void
28 voteYes: () => void
29 voteNo: () => void
30 confirm: () => void
31 cancel: () => void
32 fill: () => void
33 closeDetail: () => void
34}
35
36type Section = { rows: number; node: RenderChildren }
37
38export const clip = (text: string, width: number): string => (text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`)
39const pad = (text: string, width: number): string => (text.length >= width ? text.slice(0, Math.max(0, width)) : text + ' '.repeat(width - text.length))
40
41function rule(kit: Kit, title: string, width: number, right = ''): RenderChildren {
42 const { Box, Text } = kit
43 const fill = Math.max(1, width - title.length - right.length - 2)
44
45 return (
46 <Box>
47 <Text bold color={HEAD}>{title}</Text>
48 <Text dimColor>{` ${'─'.repeat(fill)} `}</Text>
49 <Text>{right}</Text>
50 </Box>
51 )
52}
53
54function tile(kit: Kit, member: Member, width: number, isSelected: boolean): RenderChildren {
55 const { Text } = kit
56 const glyph = member.isLeader ? '★' : STATE_GLYPHS[member.state]
57 const text = pad(`${isSelected ? '▸' : ' '}${glyph} ${clip(member.label, width - 4)}`, width)
58
59 if (member.pulse !== undefined) {
60 return <Text inverse bold color={PULSE_COLORS[member.pulse]}>{text}</Text>
61 }
62
63 const color = STATE_COLORS[member.state]
64
65 return color === undefined ? <Text dimColor={!isSelected} bold={isSelected}>{text}</Text> : <Text color={color} bold={isSelected}>{text}</Text>
66}
67
68/** The agents as a grid of tiles, wrapped to the width; past `maxRows` the rest are a count. */
69function tiles(kit: Kit, model: PaneModel, maxRows: number): Section {
70 const { Box, Text } = kit
71 const perRow = Math.max(1, Math.floor(model.columns / (TILE + 1)))
72 const fit = Math.max(1, maxRows) * perRow
73 const shown = model.members.length > fit ? model.members.slice(0, fit - 1) : model.members
74 const hidden = model.members.length - shown.length
75 const rows: Member[][] = []
76
77 for (let i = 0; i < shown.length; i += perRow) {
78 rows.push(shown.slice(i, i + perRow))
79 }
80
81 return {
82 rows: rows.length + (hidden > 0 ? 1 : 0),
83 node: (
84 <Box flexDirection="column">
85 {rows.map(row => (
86 <Box columnGap={1}>{row.map(member => tile(kit, member, TILE, member.id === model.selected?.id))}</Box>
87 ))}
88 {hidden > 0 ? <Text dimColor>{`+${hidden} more agents (select with ◀ ▶)`}</Text> : null}
89 </Box>
90 ),
91 }
92}
93
94function legend(kit: Kit, model: PaneModel): RenderChildren {
95 const { Box, Text } = kit
96
97 return (
98 <Box flexWrap="wrap">
99 {model.counts.map(entry => (
100 <Text>
101 <Text color={STATE_COLORS[entry.state]} dimColor={STATE_COLORS[entry.state] === undefined}>{STATE_GLYPHS[entry.state]}</Text>
102 <Text dimColor>{` ${entry.state} ${entry.count} `}</Text>
103 </Text>
104 ))}
105 <Text color={PULSE_COLORS.read} inverse>{' read '}</Text>
106 <Text> </Text>
107 <Text color={PULSE_COLORS.write} inverse>{' write '}</Text>
108 </Box>
109 )
110}
111
112/** The selected agent, what it is, and the buttons that act on it: only the ones its kind can take. */
113function selection(kit: Kit, model: PaneModel, actions: PaneActions): Section {
114 const { Box, Text, Button } = kit
115 const member = model.selected
116
117 if (member === null) {
118 return { rows: 1, node: <Text dimColor>No agents yet.</Text> }
119 }
120
121 const isRuflo = member.source === 'ruflo'
122 const task = model.board.selected
123 const who = `${member.label} · ${member.word}${member.claims > 0 ? ` · ${member.claims} claim${member.claims === 1 ? '' : 's'}` : ''}${member.calls > 0 ? ` · ${member.calls} calls` : ''}`
124
125 return {
126 rows: 2,
127 node: (
128 <Box flexDirection="column">
129 <Box>
130 <Button key="prev" dimColor onPress={actions.prev}>◀</Button>
131 <Text bold>{` ${clip(who, Math.max(8, model.columns - 8))} `}</Text>
132 <Button key="next" dimColor onPress={actions.next}>▶</Button>
133 </Box>
134 <Box flexWrap="wrap" columnGap={1}>
135 {isRuflo ? <Button key="logs" onPress={actions.logs}>logs</Button> : <Button key="logs" onPress={actions.logs}>activity</Button>}
136 {isRuflo && task !== null ? <Button key="claim" onPress={actions.claim}>claim task</Button> : null}
137 {isRuflo && task !== null ? <Button key="steal" onPress={actions.steal}>steal task</Button> : null}
138 {isRuflo && task !== null ? <Button key="handoff" onPress={actions.handoff}>hand off here</Button> : null}
139 {isRuflo && member.claims > 0 ? <Button key="pause" onPress={actions.pause}>pause claim</Button> : null}
140 {isRuflo && member.claims > 0 ? <Button key="resume" onPress={actions.resume}>resume claim</Button> : null}
141 {isRuflo && member.state !== 'done' ? <Button key="stop" onPress={actions.stop}>stop…</Button> : null}
142 </Box>
143 </Box>
144 ),
145 }
146}
147
148function prompts(kit: Kit, model: PaneModel, actions: PaneActions): Section | null {
149 const { Box, Text, Button } = kit
150
151 if (model.confirm !== null) {
152 return {
153 rows: 1,
154 node: (
155 <Box columnGap={1}>
156 <Text color={WARN} bold>{clip(`${model.confirm.label}?`, Math.max(8, model.columns - 22))}</Text>
157 <Button key="confirm" onPress={actions.confirm}>confirm</Button>
158 <Button key="cancel" dimColor onPress={actions.cancel}>cancel</Button>
159 </Box>
160 ),
161 }
162 }
163
164 if (model.isActing) {
165 return { rows: 1, node: <Text color={ACCENT}>running…</Text> }
166 }
167
168 const outcome = model.outcome
169
170 if (outcome === null) {
171 return null
172 }
173
174 const mark = !outcome.ok ? '✗' : outcome.verified === 'no' ? '?' : '✓'
175 const color = !outcome.ok ? BAD : outcome.verified === 'no' ? WARN : GOOD
176 const said = outcome.verified === 'yes' ? 'seen on disk' : outcome.verified === 'no' ? 'exited 0 but the disk does not show it' : outcome.ok ? 'done' : 'failed'
177
178 // What happened leads, so a narrow pane cuts the label, never the verdict.
179 return { rows: 1, node: <Text color={color}>{clip(`${mark} ${said}: ${outcome.label}${outcome.detail !== '' ? ` · ${outcome.detail}` : ''}`, model.columns)}</Text> }
180}
181
182function board(kit: Kit, model: PaneModel, maxRows: number, actions: PaneActions): Section {
183 const { Box, Text, Button } = kit
184 const b = model.board
185 const right = `pending ${b.pending} · claimed ${b.claimed} · done ${b.done}${b.failed > 0 ? ` · failed ${b.failed}` : ''}`
186
187 if (b.total === 0) {
188 return { rows: 2, node: <Box flexDirection="column">{rule(kit, 'Tasks', model.columns, right)}<Text dimColor>No tasks on disk.</Text></Box> }
189 }
190
191 const start = Math.max(0, Math.min(b.rows.findIndex(row => row.isSelected) - 1, b.rows.length - maxRows))
192 const shown = b.rows.slice(start, start + Math.max(1, maxRows))
193
194 return {
195 rows: 2 + shown.length,
196 node: (
197 <Box flexDirection="column">
198 {rule(kit, 'Tasks', model.columns, right)}
199 {shown.map(row => (
200 <Text bold={row.isSelected} dimColor={!row.isSelected && row.status === 'completed'}>
201 {clip(`${row.isSelected ? '▸' : ' '} ${pad(row.status, 12)} ${row.description || row.type} → ${row.owner}`, model.columns)}
202 </Text>
203 ))}
204 <Box columnGap={1}>
205 <Button key="task-prev" dimColor onPress={actions.taskPrev}>◀ task</Button>
206 <Button key="task-next" dimColor onPress={actions.taskNext}>task ▶</Button>
207 <Button key="offer" onPress={actions.offer}>offer for stealing</Button>
208 <Button key="reroute" onPress={actions.reroute}>re-route</Button>
209 </Box>
210 </Box>
211 ),
212 }
213}
214
215function consensus(kit: Kit, model: PaneModel, actions: PaneActions): Section | null {
216 const { Box, Text, Button } = kit
217
218 if (model.proposals.length === 0 && model.decisions.length === 0) {
219 return null
220 }
221
222 const first = model.proposals[0]
223
224 return {
225 rows: 1 + Math.min(2, model.proposals.length) + model.decisions.length + (first !== undefined ? 1 : 0),
226 node: (
227 <Box flexDirection="column">
228 {rule(kit, 'Consensus', model.columns, `${model.proposals.length} open`)}
229 {model.proposals.slice(0, 2).map(proposal => (
230 <Text>{clip(` ${proposal.type} (${proposal.strategy}) for ${proposal.votesFor} · against ${proposal.votesAgainst}`, model.columns)}</Text>
231 ))}
232 {model.decisions.map(decision => (
233 <Text dimColor>{clip(` ${decision.result === 'approved' ? '✓' : '✗'} ${decision.type} ${decision.result} ${decision.votesFor}–${decision.votesAgainst}`, model.columns)}</Text>
234 ))}
235 {first !== undefined ? (
236 <Box columnGap={1}>
237 <Button key="vote-yes" onPress={actions.voteYes}>vote yes</Button>
238 <Button key="vote-no" onPress={actions.voteNo}>vote no</Button>
239 <Text dimColor>{clip(`as ${model.selected?.label ?? '—'}`, 24)}</Text>
240 </Box>
241 ) : null}
242 </Box>
243 ),
244 }
245}
246
247function detail(kit: Kit, model: PaneModel, actions: PaneActions, maxRows: number): Section | null {
248 const { Box, Text, Button } = kit
249
250 if (model.detail === null) {
251 return null
252 }
253
254 const lines = model.detail.lines.slice(-Math.max(1, maxRows))
255
256 return {
257 rows: 1 + lines.length,
258 node: (
259 <Box flexDirection="column">
260 <Box justifyContent="space-between">
261 <Text bold color={HEAD}>{clip(model.detail.title, model.columns - 8)}</Text>
262 <Button key="close-detail" dimColor onPress={actions.closeDetail}>close</Button>
263 </Box>
264 {lines.map(line => <Text dimColor wrap="truncate-end">{clip(line, model.columns)}</Text>)}
265 </Box>
266 ),
267 }
268}
269
270function header(kit: Kit, model: PaneModel, actions: PaneActions): RenderChildren {
271 const { Box, Text, Button } = kit
272
273 return (
274 <Box justifyContent="space-between">
275 <Text bold wrap="truncate-end">{clip(model.title, Math.max(8, model.columns - 7))}</Text>
276 <Button key="hide" dimColor onPress={actions.hide}>hide</Button>
277 </Box>
278 )
279}
280
281function nextRow(kit: Kit, model: PaneModel, actions: PaneActions): Section | null {
282 const { Box, Text, Button } = kit
283
284 if (model.next === null) {
285 return null
286 }
287
288 return {
289 rows: 2,
290 node: (
291 <Box flexDirection="column">
292 <Text>
293 <Text color={ACCENT} bold>Next </Text>
294 <Text>{clip(model.next.text, Math.max(8, model.columns - 5))}</Text>
295 </Text>
296 <Box columnGap={1}>
297 <Text dimColor>{clip(model.next.why, Math.max(8, model.columns - 16))}</Text>
298 <Button key="fill" onPress={actions.fill}>put in prompt</Button>
299 </Box>
300 </Box>
301 ),
302 }
303}
304
305/** The empty pane: no swarm on disk. Says so, and names the one command that starts one. */
306function empty(kit: Kit, model: PaneModel, actions: PaneActions): RenderElement {
307 const { Box, Text } = kit
308 const next = nextRow(kit, model, actions)
309
310 return (
311 <Box flexDirection="column" rowGap={1}>
312 {header(kit, model, actions)}
313 <Text>No ruflo swarm on disk in this folder.</Text>
314 {next?.node ?? null}
315 <Text dimColor>{clip(`${model.usage}`, model.columns)}</Text>
316 </Box>
317 )
318}
319
320/** The narrow form: one line per agent and the board as counts, for a pane under 44 columns. */
321function narrow(kit: Kit, model: PaneModel, actions: PaneActions): RenderElement {
322 const { Box, Text, Button } = kit
323 const budget = Math.max(1, model.rows - 7)
324 const shown = model.members.slice(0, budget)
325 const b = model.board
326
327 return (
328 <Box flexDirection="column">
329 {header(kit, model, actions)}
330 {shown.map(member => {
331 const color = member.pulse !== undefined ? PULSE_COLORS[member.pulse] : STATE_COLORS[member.state]
332
333 return (
334 <Text color={color} dimColor={color === undefined} inverse={member.pulse !== undefined} bold={member.id === model.selected?.id}>
335 {clip(`${member.id === model.selected?.id ? '▸' : ' '}${member.isLeader ? '★' : STATE_GLYPHS[member.state]} ${member.label} ${member.state}`, model.columns)}
336 </Text>
337 )
338 })}
339 {model.members.length > shown.length ? <Text dimColor>{`+${model.members.length - shown.length} more`}</Text> : null}
340 <Text>{clip(`tasks ${b.pending} pending · ${b.claimed} claimed · ${b.done} done`, model.columns)}</Text>
341 <Text dimColor>{clip(model.route, model.columns)}</Text>
342 <Text dimColor>{clip(model.usage, model.columns)}</Text>
343 <Box columnGap={1}>
344 <Button key="prev" dimColor onPress={actions.prev}>◀</Button>
345 <Button key="next" dimColor onPress={actions.next}>▶</Button>
346 <Button key="logs" onPress={actions.logs}>{model.selected?.source === 'ruflo' ? 'logs' : 'activity'}</Button>
347 </Box>
348 {prompts(kit, model, actions)?.node ?? null}
349 </Box>
350 )
351}
352
353/**
354 * The swarm pane. Sections are kept in the order that matters most and dropped from the bottom when the body is short:
355 * the tiles, what is selected and any confirm prompt always; then the board, consensus, topology, the router and usage.
356 */
357export function paneView(kit: Kit, model: PaneModel, actions: PaneActions): RenderElement {
358 const { Box, Text } = kit
359
360 if (!model.hasSwarm && model.members.length <= 1) {
361 return empty(kit, model, actions)
362 }
363
364 if (model.isNarrow) {
365 return narrow(kit, model, actions)
366 }
367
368 let budget = Math.max(6, model.rows) - 1
369 const take = (section: Section | null): RenderChildren => {
370 if (section === null || section.rows > budget) {
371 return null
372 }
373
374 budget -= section.rows
375
376 return section.node
377 }
378
379 const top = [take(tiles(kit, model, Math.max(1, Math.min(6, Math.floor(model.rows / 4))))), take({ rows: 1, node: legend(kit, model) }), take(selection(kit, model, actions)), take(prompts(kit, model, actions))]
380 const shownDetail = take(detail(kit, model, actions, Math.min(12, Math.max(2, budget - 8))))
381 const shownBoard = take(board(kit, model, Math.min(8, Math.max(1, budget - 10)), actions))
382 const shownConsensus = take(consensus(kit, model, actions))
383 const shownRemote = model.remote.length > 0 ? take({ rows: 1 + model.remote.length, node: <Box flexDirection="column">{rule(kit, 'ruOS hosts', model.columns)}{model.remote.map(line => <Text>{clip(line, model.columns)}</Text>)}</Box> }) : null
384 const shownTopology = take({ rows: 1 + model.topology.length, node: <Box flexDirection="column">{rule(kit, 'Topology', model.columns)}{model.topology.map(line => <Text>{line}</Text>)}</Box> })
385 const shownSignals = take({ rows: 2, node: <Box flexDirection="column"><Text>{clip(model.route, model.columns)}</Text><Text dimColor>{clip(model.usage, model.columns)}</Text></Box> })
386 const shownNext = take(nextRow(kit, model, actions))
387 const shownMissing = model.missing.length > 0 ? take({ rows: 1, node: <Text dimColor>{clip(`not on disk: ${model.missing.join(', ')}`, model.columns)}</Text> }) : null
388
389 return (
390 <Box flexDirection="column">
391 {header(kit, model, actions)}
392 {top}
393 {shownDetail}
394 {shownBoard}
395 {shownConsensus}
396 {shownRemote}
397 {shownTopology}
398 {shownSignals}
399 {shownNext}
400 {shownMissing}
401 </Box>
402 )
403}
404hooks/actions/argv.ts 156 lines1import { idOf, plain } from '../reader/parse'
2import type { Snapshot } from '../reader/snapshot'
3
4/**
5 * One thing a button asks ruflo to do: the argv after the CLI prefix, fixed but for ids that passed `idOf`
6 * and one JSON argument built by `JSON.stringify` (an argv element, never a shell string), and how the disk
7 * shows that it happened, checked after the CLI exits.
8 */
9export type ActionSpec = {
10 label: string
11 args: readonly string[]
12 /** Asks the person to press again before it runs: stopping, stealing, handing off. */
13 isDestructive: boolean
14 /** What the disk shows once it took effect; absent where the CLI writes nothing the pane reads. */
15 verify?: (snapshot: Snapshot) => boolean
16 /** The words for `verify`, as the outcome line says them. */
17 expect: string
18}
19
20const exec = (tool: string, params: Record<string, unknown>) => ['mcp', 'exec', '-t', tool, '-p', JSON.stringify(params)] as const
21
22/** `agent:<id>:<type>`, the claimant form the claims tools parse; a type is letters, digits and dashes. */
23function claimant(id: string, type: string): string | null {
24 const safeType = /^[A-Za-z0-9_-]{1,40}$/.test(type) ? type : null
25
26 return idOf(id) !== null && safeType !== null && !id.includes(':') ? `agent:${id}:${safeType}` : null
27}
28
29export function stopAgent(agentId: string): ActionSpec | null {
30 const id = idOf(agentId)
31
32 return id === null
33 ? null
34 : {
35 label: `stop agent ${id}`,
36 args: ['agent', 'stop', id],
37 isDestructive: true,
38 verify: snapshot => snapshot.agents.find(agent => agent.id === id)?.status !== 'busy' && snapshot.agents.find(agent => agent.id === id)?.status !== 'idle',
39 expect: 'the agent no longer idle or busy in .claude-flow/agents/store.json',
40 }
41}
42
43export function claimTask(taskId: string, agentId: string, agentType: string): ActionSpec | null {
44 const issueId = idOf(taskId)
45 const who = claimant(agentId, agentType)
46
47 return issueId === null || who === null
48 ? null
49 : {
50 label: `claim ${issueId} for ${agentType}`,
51 args: exec('claims_claim', { issueId, claimant: who }),
52 isDestructive: false,
53 verify: snapshot => snapshot.claims.some(claim => claim.issueId === issueId && claim.claimant.id === agentId),
54 expect: 'the claim in .claude-flow/claims/claims.json',
55 }
56}
57
58export function markStealable(taskId: string): ActionSpec | null {
59 const issueId = idOf(taskId)
60
61 return issueId === null
62 ? null
63 : {
64 label: `offer ${issueId} for stealing`,
65 args: exec('claims_mark-stealable', { issueId, reason: 'voluntary' }),
66 isDestructive: false,
67 verify: snapshot => snapshot.claims.some(claim => claim.issueId === issueId && claim.isStealable),
68 expect: 'the claim marked stealable',
69 }
70}
71
72export function stealTask(taskId: string, agentId: string, agentType: string): ActionSpec | null {
73 const issueId = idOf(taskId)
74 const stealer = claimant(agentId, agentType)
75
76 return issueId === null || stealer === null
77 ? null
78 : {
79 label: `steal ${issueId} for ${agentType}`,
80 args: exec('claims_steal', { issueId, stealer }),
81 isDestructive: true,
82 verify: snapshot => snapshot.claims.some(claim => claim.issueId === issueId && claim.claimant.id === agentId),
83 expect: 'the claim held by the selected agent',
84 }
85}
86
87export function handoffTask(taskId: string, from: { id: string; type: string }, to: { id: string; type: string }): ActionSpec | null {
88 const issueId = idOf(taskId)
89 const fromWho = claimant(from.id, from.type)
90 const toWho = claimant(to.id, to.type)
91
92 return issueId === null || fromWho === null || toWho === null || from.id === to.id
93 ? null
94 : {
95 label: `hand ${issueId} to ${to.type}`,
96 args: exec('claims_handoff', { issueId, from: fromWho, to: toWho }),
97 isDestructive: true,
98 verify: snapshot => snapshot.claims.some(claim => claim.issueId === issueId && (claim.handoffTo === to.id || claim.claimant.id === to.id)),
99 expect: 'a handoff to the selected agent on the claim',
100 }
101}
102
103/** Pauses or resumes the work an agent claimed: ruflo pauses claims, not agents. */
104export function setClaimStatus(taskId: string, status: 'paused' | 'active'): ActionSpec | null {
105 const issueId = idOf(taskId)
106
107 return issueId === null
108 ? null
109 : {
110 label: `${status === 'paused' ? 'pause' : 'resume'} the claim on ${issueId}`,
111 args: exec('claims_status', { issueId, status }),
112 isDestructive: false,
113 verify: snapshot => snapshot.claims.some(claim => claim.issueId === issueId && claim.status === status),
114 expect: `the claim ${status}`,
115 }
116}
117
118export function vote(proposalId: string, voterId: string, isFor: boolean): ActionSpec | null {
119 const id = idOf(proposalId)
120 const voter = idOf(voterId)
121
122 return id === null || voter === null
123 ? null
124 : {
125 label: `vote ${isFor ? 'yes' : 'no'} on ${id} as ${voter}`,
126 args: ['hive-mind', 'consensus', '-a', 'vote', '-p', id, '-v', isFor ? 'yes' : 'no', '--voter-id', voter],
127 isDestructive: false,
128 verify: snapshot => {
129 const proposal = snapshot.hive?.pending.find(entry => entry.id === id)
130 const decided = snapshot.hive?.history.some(entry => entry.id === id) === true
131
132 return decided || (proposal !== undefined && proposal.votesFor + proposal.votesAgainst > 0)
133 },
134 expect: 'a vote counted on the proposal in .claude-flow/hive-mind/state.json',
135 }
136}
137
138/** Asks the router again for a task's description. The text is the task's own, cleaned, one argv element. */
139export function reroute(description: string): ActionSpec | null {
140 const task = plain(description, 400)
141
142 // A value that starts with a dash would be read by the CLI as a flag of its own.
143 return task === '' || task.startsWith('-')
144 ? null
145 : { label: 'ask the router', args: ['hooks', 'route', '--task', task, '--format', 'json'], isDestructive: false, expect: 'a pick printed by the router' }
146}
147
148export function agentLogs(agentId: string): ActionSpec | null {
149 const id = idOf(agentId)
150
151 return id === null ? null : { label: `logs of ${id}`, args: ['agent', 'logs', id], isDestructive: false, expect: 'the log lines' }
152}
153
154/** The full argv: the configured CLI's fixed prefix, then the action's own. */
155export const argvOf = (prefix: readonly string[], spec: ActionSpec): string[] => [...prefix, ...spec.args]
156