/** * Fleet onboarding-injection (#620). * * Fleet agents are born not knowing how to reach their peers — the root cause of * a spawned agent's failed first send. When an agent boots via `mosaic yolo * ` (→ composeContract → system prompt), we append a comms cheat-sheet * + peer roster so it can talk to the orchestrator and other agents immediately. * * Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation * stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a * w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin * peer. Same-host peers render the short form. * * Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the * heavy fleet command module. The roster is parsed leniently — the cheat-sheet * is best-effort onboarding, never a hard dependency. */ import { existsSync, readFileSync } from 'node:fs'; import { homedir, hostname } from 'node:os'; import { join } from 'node:path'; export interface CommsPeer { name: string; /** Roster `class` (orchestrator | enhancer | implementer | worker | …). */ className: string; /** Host the peer runs on; absent ⇒ the fleet host (same host). */ host?: string; /** SSH target (user@host) for a cross-host peer; renders the `-H` form. */ ssh?: string; /** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */ socket?: string; } /** * Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a * dependency on the full fleet roster parser; the format is `- name:` list items * with `class:`/`host:`/`ssh:` siblings under `agents:`. */ export function parseRosterAgents(yamlText: string): CommsPeer[] { const peers: CommsPeer[] = []; let current: CommsPeer | null = null; let inAgents = false; const scalar = (line: string, key: string): string | null => { const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`)); return m ? (m[1] as string).trim() : null; }; for (const rawLine of yamlText.split('\n')) { const line = rawLine.replace(/\s+$/, ''); if (/^agents:\s*$/.test(line)) { inAgents = true; continue; } if (!inAgents) continue; // A new top-level key (no leading space) ends the agents block. if (/^\S/.test(line)) break; const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/); if (nameMatch) { if (current) peers.push(current); current = { name: nameMatch[1] as string, className: 'worker' }; continue; } if (!current) continue; const cls = scalar(line, 'class'); if (cls) current.className = cls; const host = scalar(line, 'host'); if (host) current.host = host; const ssh = scalar(line, 'ssh'); if (ssh) current.ssh = ssh; const socket = scalar(line, 'socket'); if (socket) current.socket = socket; } if (current) peers.push(current); return peers; } export interface FleetCommsOptions { /** This agent's name (it is excluded from its own peer list). */ selfName: string; /** All roster agents (including self; filtered out internally). */ agents: CommsPeer[]; /** Host the fleet runs on (short hostname) — the same-host baseline. */ fleetHost: string; /** Absolute path to agent-send.sh in this install. */ agentSendPath: string; } /** Is this peer on a different host than the fleet baseline? */ function isRemote(peer: CommsPeer, fleetHost: string): boolean { return peer.host !== undefined && peer.host !== fleetHost; } /** * Render the exact agent-send command to reach a peer (session = agent name). * Data-driven per peer: a named `socket` → `-L `; an unset socket → the * default tmux socket (no `-L`). A cross-host peer adds `-H `. */ export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string { const parts = [agentSendPath]; if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string)); parts.push('-s', peer.name, '-m', '"…"'); return parts.join(' '); } /** * Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when * the agent has no peers (a single-agent roster has no one to talk to). */ export function buildFleetCommsBlock(opts: FleetCommsOptions): string { const peers = opts.agents.filter((a) => a.name !== opts.selfName); if (peers.length === 0) return ''; const orchestrator = peers.find((p) => p.className === 'orchestrator'); const rows = peers .map((p) => { const where = isRemote(p, opts.fleetHost) ? `${p.className} · host \`${p.host}\`` : p.className; const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where; return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`; }) .join('\n'); const orchLine = orchestrator ? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` + `status, and decisions there.` : `This fleet has no orchestrator in its roster; coordinate with your peers directly.`; return `# Fleet Comms — reach your peers You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\` — that is the \`\` other agents see and reply to. Reach other agents (durable tmux sessions) with the Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer: same-host peers use the short form (no \`-H\`); cross-host peers include \`-H \`. ## Peers | Agent | Role | Reach (session = agent name) | | ----- | ---- | ---------------------------- | ${rows} ${orchLine} ## Conventions - Every message carries a self-identifying preamble \`[: -> :]\` — \`agent-send.sh\` adds it automatically. - **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`). - \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`; } /** * Read the fleet roster from `mosaicHome` and build the comms block for * `selfName`. Returns '' when there is no roster, the agent is not in it, or * there are no peers — onboarding is best-effort and never throws. */ export function readFleetCommsBlock( mosaicHome: string, selfName: string | undefined, fleetHost: string = hostname().split('.')[0] || 'localhost', ): string { if (!selfName) return ''; const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml'); if (!existsSync(rosterPath)) return ''; let text: string; try { text = readFileSync(rosterPath, 'utf-8'); } catch { return ''; } const agents = parseRosterAgents(text); if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet return buildFleetCommsBlock({ selfName, agents, fleetHost, agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'), }); } /** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */ export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');