SLOPSHOPPER

ruflo-swarm

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

newpanespinnerguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ruflo-swarm
│ ┃ Swarm ✕ › fix the failing auth test and add an audit log call │ ┃ ruflo swarm [ hide ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No ruflo swarm on disk in this folder. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Next /ruflo-swarm:swarm init ⎿ Added 2 lines, removed 1 line │ ┃ no swarm on disk here: start one [ put in pr ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ cost $0.420 · context 97k/200k (49%) │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ruflo-swarm-pane │ ⎿ ruflo-swarm: Swarm pane shown │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Swarm
ruflo swarm [ hide ] No ruflo swarm on disk in this folder. Next /ruflo-swarm:swarm init no swarm on disk here: start one [ put in prompt ] cost $0.420 · context 97k/200k (49%)
README

ruflo-swarm

Agent teams, swarm coordination, Monitor streams, and worktree isolation.

Install

/plugin marketplace add ruvnet/ruflo
/plugin install ruflo-swarm@ruflo

What's Included

  • Agent Teams: TeamCreate, SendMessage, and Task tool integration for multi-agent coordination
  • Topologies: hierarchical, mesh, hierarchical-mesh, ring, star, adaptive
  • Monitor Streams: Real-time swarm status via Monitor("npx @claude-flow/cli@latest swarm watch --stream")
  • Worktree Isolation: Each agent works in its own git worktree to avoid conflicts
  • Hive-Mind Consensus: Byzantine, Raft, Gossip, CRDT, and Quorum strategies
  • Anti-Drift: hierarchical topology with specialized strategy for tight coordination

Requires

  • ruflo-core plugin (provides MCP server)

Compatibility

  • CLI: pinned to @claude-flow/cli v3.6 major+minor.
  • Verification: bash plugins/ruflo-swarm/scripts/smoke.sh is the contract.

MCP surface (12 tools)

FamilyCountTools
swarm_*4swarm_init, swarm_status, swarm_shutdown, swarm_health
agent_*8agent_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.

Built-in Claude Code coordination tools

This plugin pairs with Claude Code's native multi-agent tools (no MCP needed):

ToolPurpose
TaskSpawn a sub-agent (use name: for addressability + run_in_background: true for parallel execution)
SendMessageInter-agent comms (named agents only)
TaskCreate / TaskList / TaskGet / TaskUpdate / TaskOutput / TaskStopShared task tracker for swarm pipelines
MonitorLive-stream events from a long-running process (persistent: true) — primary wake signal for /loop
EnterWorktree / ExitWorktreeGit worktree isolation per agent

Anti-drift defaults (per CLAUDE.md)

For coding swarms, the canonical defaults that prevent agent drift:

SettingValueRationale
topologyhierarchicalCoordinator catches divergence
maxAgents6–8Smaller team = less drift
strategyspecializedClear roles, no overlap
consensusraftLeader maintains authoritative state
memoryhybridSQLite + AgentDB for both fast + durable

For 10+ agent teams, use hierarchical-mesh (queen + peer communication).

Namespace coordination

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

Live swarm pane (Claude Code mod, early access)

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.

  • Tiles: one per agent: ruflo's agents from .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.
  • Topology (hierarchical, mesh, ring, star) as ruflo wrote it, plus tasks with their claims, hive-mind proposals and their votes, cost and context from $.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.
  • Buttons act through the ruflo CLI with fixed argv and ids checked against ruflo's own format: logs or activity, claim, steal, hand off, pause or resume a claim, stop, offer for stealing, re-route, vote, and put the next command in the prompt (a person still presses Enter). Stop, steal and hand off ask for a second press. After each action the pane re-reads the disk and says whether the change shows there. A CLI that exits 0 while nothing changes on disk is reported as unverified.
  • Commands: /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.
  • Options (/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).
  • The mod does not register prompt.submit or tool.check hooks, and its tool.call hook only observes.
  • With the ruflo-mods trust gate: if you set 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.

Verification

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

Architecture Decisions

Related Plugins

  • ruflo-agentdb — namespace convention owner
  • ruflo-autopilot — owns the 270s cache-aware /loop heartbeat for long-running swarms
  • ruflo-intelligence — hooks_route powers swarm agent recommendation per task
Source 15 files
hooks/register.ts 471 lines
1import 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}
471
hooks/actions/controller.ts 291 lines
1import 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}
291
hooks/audit.ts 60 lines
1import { 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}
60
hooks/commands.ts 151 lines
1import 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}
151
hooks/host.ts 31 lines
1import 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}
31
hooks/model/members.ts 249 lines
1import { 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)
249
hooks/reader/parse.ts 365 lines
1/**
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}
365
hooks/reader/snapshot.ts 138 lines
1import {
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}
138
hooks/state.ts 132 lines
1import 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}
132
hooks/views/model.ts 213 lines
1import { 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}
213
hooks/views/pane.tsx 404 lines
1/* @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}
404
hooks/actions/argv.ts 156 lines
1import { 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