feat(fleet): onboarding-injection — comms cheat-sheet + peer roster per agent (#621)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #621.
This commit is contained in:
@@ -70,3 +70,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
||||
|
||||
- Status: Phase 1 MERGED (#617: connector interface send/subscribe/health + registry + roster schema + design). Phase 2a (#618): Matrix CS-API client + factory. 20 connector tests green; no fleet.ts changes. Remaining Phase 2: init/configure connector-selection UX + roster wiring, systemd launch wiring, Conduit deploy guide. Detail: scratchpads/f4-matrix-connector.md.
|
||||
|
||||
## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding
|
||||
|
||||
- Status: implemented + tested. Injects # Fleet Comms (peer roster + cross-host agent-send commands + FLIP-reply + --verify) into each spawned fleet agent via composeContract; optional per-agent host/ssh/socket roster fields (socket: named → -L, unset → default socket no -L). 10 + 2 tests green. Detail: scratchpads/fleet-comms-onboarding.md.
|
||||
|
||||
31
docs/scratchpads/fleet-comms-onboarding.md
Normal file
31
docs/scratchpads/fleet-comms-onboarding.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Fleet onboarding-injection — comms cheat-sheet + peer roster (#620)
|
||||
|
||||
- **Issue:** #620 · **Branch:** `feat/fleet-comms-onboarding` (off main). Root cause of Mos's failed first send.
|
||||
|
||||
## What
|
||||
|
||||
Inject a `# Fleet Comms` block into each spawned fleet agent's system prompt (via composeContract — the
|
||||
runtime-agnostic path every `mosaic yolo <runtime>` agent hits), so it boots knowing how to reach peers.
|
||||
|
||||
- `src/fleet/comms-onboarding.ts` (standalone, no fleet.ts coupling):
|
||||
- `parseRosterAgents` (name/class/host/ssh, lenient), `renderPeerReach` (same-host `-s` vs cross-host
|
||||
`-H <ssh> -s`), `buildFleetCommsBlock` (self [host:session] identity + agent-send path + peer table +
|
||||
FLIP-to-reply + `agent send --verify`=ACCEPTED), `readFleetCommsBlock` (reads roster.yaml; '' if not a member).
|
||||
- `composeContract` appends it only when MOSAIC_AGENT_NAME is set + the agent is in the roster.
|
||||
- `roster.schema.json`: optional per-agent `host` + `ssh` (cross-host addresses; manual = pre-federation
|
||||
stopgap, federation/W1 auto-discovers later).
|
||||
|
||||
## Acceptance criteria (Mos) — all covered
|
||||
|
||||
1. own [host:session] + agent-send path + peer roster ✓
|
||||
2. cross-host correctness: local→`-s` (no -H); remote→`-H <ssh> -s` ✓ (concrete coder0-0@dragon-lin)
|
||||
3. FLIP-the-preamble reply rule ✓
|
||||
4. `agent send --verify` = ACCEPTED ✓
|
||||
5. no `-L` (default socket); matches live tooling ✓
|
||||
|
||||
## Verification
|
||||
|
||||
- 10 onboarding unit tests (parse, render local/remote/fallback/equal-host, build, situational read) +
|
||||
2 composeContract situational tests (injects for fleet agent w/ correct cross-host addr; no-op when
|
||||
MOSAIC_AGENT_NAME unset). tsc/eslint/prettier/sanitize clean.
|
||||
- Post-merge validation: Mos spawns a real w-jarvis agent → first-try reach to coder0-0@dragon-lin + a local peer.
|
||||
@@ -81,6 +81,18 @@
|
||||
"class": {
|
||||
"type": "string"
|
||||
},
|
||||
"host": {
|
||||
"description": "Host the agent runs on (hostname or IP). Absent = the fleet host. Used by onboarding-injection to render cross-host comms addresses. Manual cross-host listing is a pre-federation stopgap; federation (W1) auto-discovers later.",
|
||||
"type": "string"
|
||||
},
|
||||
"ssh": {
|
||||
"description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H <user@host>` form. Optional; only needed for agents on a different host than the fleet.",
|
||||
"type": "string"
|
||||
},
|
||||
"socket": {
|
||||
"description": "tmux socket the agent's session runs on. Onboarding renders `-L <socket>` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.",
|
||||
"type": "string"
|
||||
},
|
||||
"working_directory": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -56,6 +56,55 @@ describe('composeContract — overlay composer', () => {
|
||||
rmSync(cwdDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('injects the fleet comms cheat-sheet for a spawned fleet agent (situational)', () => {
|
||||
// A spawned agent has MOSAIC_AGENT_NAME set + is a member of the roster.
|
||||
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
' host: 10.1.10.37',
|
||||
' ssh: jwoltje@10.1.10.37',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
|
||||
// local peer → no -H; cross-host peer → -H ssh
|
||||
expect(out).toContain('-s orchestrator -m "…"');
|
||||
expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
expect(composeContract('claude', fixture.home)).not.toContain('# Fleet Comms');
|
||||
} finally {
|
||||
if (prev !== undefined) process.env['MOSAIC_AGENT_NAME'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the per-tier anchors and the selected harness runtime', () => {
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
|
||||
@@ -383,6 +384,12 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
// Runtime-specific contract
|
||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||
|
||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
||||
// knows how to reach the orchestrator and its peers from its first turn.
|
||||
const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||
if (fleetComms) parts.push('\n\n' + fleetComms);
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
|
||||
187
packages/mosaic/src/fleet/comms-onboarding.spec.ts
Normal file
187
packages/mosaic/src/fleet/comms-onboarding.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
parseRosterAgents,
|
||||
buildFleetCommsBlock,
|
||||
renderPeerReach,
|
||||
readFleetCommsBlock,
|
||||
type CommsPeer,
|
||||
} from './comms-onboarding.js';
|
||||
|
||||
const ROSTER = [
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' - name: coder0',
|
||||
' runtime: pi',
|
||||
' class: implementer',
|
||||
' # a manually-listed cross-host peer (pre-federation stopgap)',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
' host: 10.1.10.37',
|
||||
' ssh: jwoltje@10.1.10.37',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
describe('parseRosterAgents', () => {
|
||||
it('parses name + class + optional host/ssh', () => {
|
||||
const peers = parseRosterAgents(ROSTER);
|
||||
expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']);
|
||||
expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' });
|
||||
expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
});
|
||||
// local agents have no host/ssh
|
||||
expect(peers.find((p) => p.name === 'orchestrator')!.host).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses an optional per-agent socket', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', ' socket: mosaic-factory'].join('\n'),
|
||||
);
|
||||
expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-factory' });
|
||||
});
|
||||
|
||||
it('stops at the next top-level key', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
expect(peers.map((p) => p.name)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderPeerReach — same-host vs cross-host', () => {
|
||||
const send = '/home/u/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
|
||||
it('renders the short form for a same-host peer', () => {
|
||||
const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`);
|
||||
});
|
||||
|
||||
it('renders the -H form for a cross-host peer using ssh', () => {
|
||||
const peer: CommsPeer = {
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to host when a cross-host peer has no ssh', () => {
|
||||
const peer: CommsPeer = { name: 'x', className: 'worker', host: '10.0.0.9' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -H 10.0.0.9 -s x -m "…"`);
|
||||
});
|
||||
|
||||
it('treats a peer whose host equals the fleet host as same-host', () => {
|
||||
const peer: CommsPeer = { name: 'y', className: 'worker', host: 'w-jarvis' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s y -m "…"`);
|
||||
});
|
||||
|
||||
it('emits NO -L for an unset/default socket', () => {
|
||||
const peer: CommsPeer = { name: 'lead', className: 'orchestrator' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s lead -m "…"`);
|
||||
});
|
||||
|
||||
it('emits -L <socket> for a named socket', () => {
|
||||
const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-factory' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -L mosaic-factory -s coder0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('combines -L (named socket) and -H (cross-host) in order', () => {
|
||||
const peer: CommsPeer = {
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
socket: 'mosaic-factory',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -L mosaic-factory -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFleetCommsBlock', () => {
|
||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
const agents = parseRosterAgents(ROSTER);
|
||||
|
||||
it('excludes self, lists peers, flags the orchestrator, and emits both address forms', () => {
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
agents,
|
||||
fleetHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('You are **enhancer**');
|
||||
// criterion 1: agent's own [host:session] identity
|
||||
expect(block).toContain('`[w-jarvis:enhancer]`');
|
||||
// self excluded
|
||||
expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
|
||||
// peers present
|
||||
expect(block).toContain('| orchestrator |');
|
||||
expect(block).toContain('point of contact');
|
||||
// same-host peer short form
|
||||
expect(block).toContain(`${send} -s coder0 -m "…"`);
|
||||
// cross-host peer -H form + host annotation
|
||||
expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||
expect(block).toContain('host `10.1.10.37`');
|
||||
// conventions
|
||||
expect(block).toContain('FLIP the preamble');
|
||||
expect(block).toContain('ACCEPTED');
|
||||
});
|
||||
|
||||
it('returns empty when the agent has no peers', () => {
|
||||
expect(
|
||||
buildFleetCommsBlock({
|
||||
selfName: 'solo',
|
||||
agents: [{ name: 'solo', className: 'orchestrator' }],
|
||||
fleetHost: 'h',
|
||||
agentSendPath: send,
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('builds the cheat-sheet with correct peer addresses for a fleet member', () => {
|
||||
const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('| enhancer |');
|
||||
expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`);
|
||||
expect(block).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||
expect(block).not.toMatch(/\|\s*orchestrator\s*\|/); // self excluded
|
||||
});
|
||||
|
||||
it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => {
|
||||
expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
|
||||
});
|
||||
});
|
||||
183
packages/mosaic/src/fleet/comms-onboarding.ts
Normal file
183
packages/mosaic/src/fleet/comms-onboarding.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
||||
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
||||
Reference in New Issue
Block a user