Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
227 lines
8.8 KiB
TypeScript
227 lines
8.8 KiB
TypeScript
/**
|
|
* 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
|
|
* <runtime>` (→ 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 <socket>`; an unset socket → the
|
|
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
|
|
*/
|
|
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 \`<src>\` 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 <user@host>\`.
|
|
|
|
## Peers
|
|
|
|
| Agent | Role | Reach (session = agent name) |
|
|
| ----- | ---- | ---------------------------- |
|
|
${rows}
|
|
|
|
${orchLine}
|
|
|
|
## Conventions
|
|
|
|
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\` — \`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'),
|
|
});
|
|
}
|
|
|
|
/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */
|
|
export interface CommsBlockResult {
|
|
/** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */
|
|
ok: boolean;
|
|
/** The Fleet-Comms cheat-sheet (empty unless ok). */
|
|
output: string;
|
|
/** Operator-facing reason when !ok. */
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Resolve the Fleet-Comms cheat-sheet for an explicit <role>, backing the
|
|
* `mosaic fleet comms-block <role>` command. Unlike readFleetCommsBlock — which
|
|
* returns '' on any miss so composeContract can no-op silently during a launch —
|
|
* this NEVER silently emits empty: an unknown role or missing roster yields
|
|
* ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1)
|
|
* rather than printing nothing. That makes it safe to preview any peer's view,
|
|
* e.g. `mosaic fleet comms-block coder0-0`.
|
|
*/
|
|
export function resolveCommsBlock(
|
|
mosaicHome: string,
|
|
role: string | undefined,
|
|
fleetHost?: string,
|
|
): CommsBlockResult {
|
|
if (!role) {
|
|
return { ok: false, output: '', error: 'comms-block requires a <role> argument' };
|
|
}
|
|
const block = fleetHost
|
|
? readFleetCommsBlock(mosaicHome, role, fleetHost)
|
|
: readFleetCommsBlock(mosaicHome, role);
|
|
if (!block) {
|
|
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
return {
|
|
ok: false,
|
|
output: '',
|
|
error: existsSync(rosterPath)
|
|
? `role "${role}" is not a member of the fleet roster at ${rosterPath}`
|
|
: `no fleet roster at ${rosterPath}`,
|
|
};
|
|
}
|
|
return { ok: true, output: block };
|
|
}
|
|
|
|
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
|
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|