Run ruflo swarm agents on your own ruOS cloud desktops via the tenant-authenticated fleet MCP or per-tenant SSH; optional and removable (ADR-405)

Run ruflo swarm agents on your own ruOS cloud desktops. The plugin can:
The plugin is optional. ruflo works the same without it.
Design and threat model: ADR-405.
| Transport | When | Configure |
|---|---|---|
| ruOS fleet MCP / REST (default) | The only path from outside your ruOS tenant (laptops, workstations, CI) | In Claude Code: the connected mcp__ruos__* tools. In a terminal: RUOS_MCP_URL + RUOS_MCP_TOKEN, or the @cognitum/ruos login (~/.config/ruos/credentials.json) |
Per-tenant SSH :2222 | Only desktop to desktop inside your tenant's Fly 6PN, i.e. when ruflo runs ON one of your ruOS desktops | --transport ssh, RUOS_SSH_KEY, optional RUOS_SSH_USER, RUOS_FLY_APP. Peers come from ~/.claude/federation/peers.json |
Mint a desktop:control token with the narrowest lifetime. A read-only token can list desktops but cannot exec, start or stop them.
:17870, which has no per-tenant auth (ruOS ADR-070).desktop_delete or secret_delete.Jobs. --jobs auto (the default) uses the live ruOS jobs API (ADR-105: long-poll, 64 KiB chunks) when your fleet answers it, and otherwise polls desktop_exec. The poll path is a detached nohup runner under ~/.ruflo-ruos/runs/, read by byte offset about 2 KiB at a time, because desktop_exec output is head-capped at about 4 KiB.
| Command | What it does |
|---|---|
/ruflo-ruos:hosts | Lists your desktops: state, heartbeat, and the ruflo agents on each |
/ruflo-ruos:run | Runs claude -p on a desktop, streams the output, and records the agent and its claim |
/ruflo-ruos:view | Delegates to /ruos view if installed, otherwise to a view-only desktop_share link |
/ruflo-ruos:deploy | Read-only hand-off: branch, HEAD, dirty and ahead counts of a repo, plus a summary for a human to review, merge and deploy. Never pushes, deploys or publishes. |
Skill: ruos-host-run, the session path that uses the connected fleet MCP. Agent: ruos-host-operator.
CLI="node plugins/ruflo-ruos/scripts/cli.mjs"
$CLI status # config + next auto-stop, no network
$CLI hosts
$CLI run --desktop "Work Desktop" --prompt-file task.txt --model sonnet --timeout 900
$CLI run --desktop <id> --prompt-file task.txt --start # wakes a stopped desktop (billable)
$CLI stop --desktop <id> --run <runId> --confirm
$CLI desktop-stop --desktop <id> --confirm
$CLI attach --desktop <id> --run <runId> # re-read a run after a restart (HOME persists)
$CLI logs --run <runId>
$CLI build --prompt-file task.txt # exact desktop_exec strings for the session path
$CLI record start|output|end --run <runId> ...
$CLI deploy-info --desktop <id> --repo projects/app
When the ruflo mod (ruflo-mods) is loaded, this plugin's mod adds a ruos segment to ruflo's status line, e.g. ruOS 2 agents · Work Desktop, and clears it when no agent is running. It reads only .claude-flow/ruos/hosts.json, makes no network calls, and draws nothing without ruflo-mods. Verify it with claude plugin test plugins/ruflo-ruos.
Remote agents are recorded with ruflo's own tools: agent_spawn with config.host = {kind: "ruos", desktopId, desktopName, transport, runId}, then agent_update, then claims_claim/claims_release on ruos-run-<runId>. The plugin itself writes only to .claude-flow/ruos/:
events.jsonl: lifecycle events, with ids and sizes only;audit.jsonl: one record per run (desktop, command sha256, start, end, exit, bytes), because ruOS does not audit the detached output;hosts.json: the current host snapshot;runs/<runId>.log: the run output, mode 0600.ruOS stops cloud desktops at 23:00 America/Toronto on weekdays. desktop_keepawake does not override this.
--ignore-autostop.--start.bash plugins/ruflo-ruos/scripts/smoke.sh # structure, security greps, full node --test suite
node plugins/ruflo-ruos/scripts/bench.mjs # adapter overhead vs a raw local callhooks/register.ts 57 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { HOSTS_PATH, segmentText } from './segment'
4
5/** How often the segment re-reads hosts.json (a local file read, no network). */
6export const REFRESH_MS = 15_000
7
8type State = { last: string | null | undefined }
9
10/**
11 * Re-read the plugin's hosts.json and set the `ruos` segment when it changed.
12 * `$.ruflo` exists only where the ruflo mod is seated, and `claude plugin
13 * validate` rejects feature-detecting a noun, so the call sits in try/catch:
14 * without ruflo-mods it rejects and nothing is drawn.
15 */
16async function refresh($: EngineInterface, state: State): Promise<void> {
17 let text: string | null = null
18 try {
19 const root = await $.session.root()
20 text = segmentText(await $.fs.read(`${root}/${HOSTS_PATH}`))
21 } catch {
22 text = null // no snapshot yet: nothing to show
23 }
24 if (text === state.last) return
25 try {
26 await $.ruflo.segment({ id: 'ruos', text })
27 state.last = text
28 } catch {
29 // ruflo mod not seated, or it refused the segment: draw nothing
30 }
31}
32
33/**
34 * ruflo-ruos as a mod (ADR-405): contributes the `ruos` segment to ruflo's
35 * status line. Reads only the plugin's own hosts.json; never calls ruOS (no
36 * network, no `$.ruos` dependency) and never issues a destructive tool.
37 */
38export const register: Register = on => {
39 const state: State = { last: undefined }
40
41 on('session.start', async ($, e, next) => {
42 const result = await next(e)
43 await refresh($, state)
44 try {
45 $.clock.every(REFRESH_MS, () => refresh($, state))
46 } catch {
47 // a withheld clock only means no periodic refresh
48 }
49 return result
50 })
51
52 on('prompt.submit', async ($, e, next) => {
53 await refresh($, state)
54 return next(e)
55 })
56}
57hooks/segment.ts 38 lines1/**
2 * The `ruos` status segment text (ADR-405), derived from the plugin's own
3 * `.claude-flow/ruos/hosts.json` snapshot. Pure: no `$`, so it is tested
4 * directly. Text is untrusted on both sides; ruflo-mods also sanitises.
5 */
6
7export const HOSTS_PATH = '.claude-flow/ruos/hosts.json'
8/** ruflo-mods renders at most 48 characters per segment. */
9export const SEGMENT_MAX = 48
10
11type Host = { name?: unknown; agents?: unknown }
12
13/** Control and bidi-override characters never reach the status line. */
14const clean = (s: string) => s.replace(/[\u0000-\u001f\u007f-\u009f--]/g, '').trim()
15
16/**
17 * `ruOS 2 agents · Work Desktop` (+N more hosts), or null when no ruflo
18 * agent is placed on a ruOS desktop (null clears the segment).
19 */
20export function segmentText(hostsJson: string | undefined): string | null {
21 if (!hostsJson) return null
22 let hosts: Host[]
23 try {
24 const parsed = JSON.parse(hostsJson) as { hosts?: unknown }
25 hosts = Array.isArray(parsed.hosts) ? (parsed.hosts as Host[]).slice(0, 200) : []
26 } catch {
27 return null
28 }
29 const busy = hosts
30 .map(h => ({ name: typeof h.name === 'string' ? clean(h.name) : '', agents: Array.isArray(h.agents) ? h.agents.length : 0 }))
31 .filter(h => h.agents > 0)
32 if (busy.length === 0) return null
33 const total = busy.reduce((n, h) => n + h.agents, 0)
34 const head = `ruOS ${total} agent${total === 1 ? '' : 's'} · ${busy[0]?.name || 'desktop'}`
35 const text = busy.length > 1 ? `${head} +${busy.length - 1}` : head
36 return text.length > SEGMENT_MAX ? `${text.slice(0, SEGMENT_MAX - 1)}…` : text
37}
38types/index.d.ts 17 lines1/**
2 * Vendored `$.ruflo` contract (ruflo-mods, ADR-404), the subset ruflo-ruos
3 * uses. Source of truth: plugins/ruflo-mods/types/index.d.ts — kept in step
4 * by tests/vendored-types.test.mjs. Where the ruflo mod is not seated there
5 * is no `$.ruflo` and a call rejects; ruflo-ruos catches and draws nothing.
6 */
7export type RufloSegment = { id: string; text: string | null }
8
9declare module 'claude-code' {
10 interface EngineInterface {
11 ruflo: {
12 /** Sets (or with `text: null` removes) one status bar segment. */
13 segment: (input: RufloSegment) => Promise<void>
14 }
15 }
16}
17