Compare commits

..

2 Commits

Author SHA1 Message Date
aefcc9b5c0 feat(fleet): F4 Phase 2a — Matrix CS-API connector client + factory (#616)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/pr/ci Pipeline is pending
Implements the Matrix connector behind the F4 abstraction (stacked on #617).
Speaks the Matrix client-server API directly over HTTPS (injectable fetch, no
SDK) so it is homeserver-agnostic (Conduit default, Synapse alt).

- MatrixConnector implements OrchestratorConnector:
  - send  → PUT /_matrix/client/v3/rooms/{room}/send/m.room.message/{txn}
            (thread-aware via m.thread relation), returns event_id.
  - subscribe → /sync long-poll loop driven by the pure parseSyncResponse
            (skips the orchestrator's own echoes; carries threadId).
  - health → /_matrix/client/versions (reachable) + /account/whoami (authed).
- Pure helpers buildMessageBody + parseSyncResponse → send/receive unit-testable.
- registerMatrixConnector(env) registers the factory; the access token comes from
  MATRIX_ACCESS_TOKEN, never the roster.

Verified: 13 Matrix + 7 registry = 20 connector tests green; tsc/eslint/prettier
clean. Still a self-contained connectors/ module (no fleet.ts changes).

Stacked on #617 (F4 Phase 1). Merge order: #617 -> this; rebases onto main after.

Refs #616, #613

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83
2026-06-22 03:17:58 -05:00
85a93428eb feat(fleet): F4 Phase 1 — orchestrator chat connector abstraction + Matrix design (#616)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
From the north-star (#613) orchestrator-chat-connector decision: make the
orchestrator's chat channel a pluggable, user-chosen connector (tmux | discord
| matrix peers) and add Matrix (local homeserver) — starting with the small
uniform abstraction so connectors drop in without touching fleet core.

Phase 1 (this PR — design + scaffold):
- src/fleet/connectors/types.ts: OrchestratorConnector interface (send /
  subscribe / health per the Lead's spec) + message/config types; thread-aware
  via optional threadId so Matrix threads + the future first-party Mosaic
  Discord plugin fit without an interface change. DEFAULT_CONNECTOR_KIND=tmux.
- src/fleet/connectors/registry.ts: extensible factory registry;
  resolveConnectorKind defaults tmux (back-compat); createConnector throws
  ConnectorNotImplementedError until Phase 2 registers factories.
- roster.schema.json: optional connector block (tmux|discord|matrix; matrix
  homeserver/user/room). Secrets via env (gateway pattern), never the roster.
- docs/fleet/f4-matrix-connector.md: interface, config, Matrix client-server API
  mapping, Conduit-default local homeserver, phasing, back-compat.

Self-contained connectors/ module — NO fleet.ts changes, so independent of the
in-flight stacked fleet-config PR (#615).

Verified: 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema
valid JSON.

Refs #616, #613

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83
2026-06-22 03:11:31 -05:00
18 changed files with 869 additions and 379 deletions

View File

@@ -59,10 +59,6 @@ Active workstream is **W1 — Federation v1**. Workers should:
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
## Fleet-polish bundle — boot-survival symmetry (#611) — feat/fleet-polish-bundle
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
- Status: implemented + tested. disable-on-remove (boot-resurrection bug, TDD) + add-enable + init-R5 hard guarantee. 4 new + 147 existing fleet tests green. Detail: scratchpads/fleet-polish-bundle.md.
## Fleet enhancer role + two-agent floor (#614) — feat/fleet-enhancer-floor (stacked on #612)
- Status: implemented + tested. enhancer added to 4 presets; init guarantees 1 orchestrator + >=1 enhancer; remove protects the sole enhancer; enhancer role doc. 155 fleet tests green. Detail: scratchpads/fleet-enhancer-floor.md.
- Status: Phase 1 done (abstraction + scaffold). Connector interface (send/subscribe/health) + registry + roster connector schema + design doc; tmux default/back-compat; matrix factory + CS-API client landed (Phase 2a, #617-stacked); 20 connector tests green; no fleet.ts changes (independent of #615). Detail: scratchpads/f4-matrix-connector.md.

View File

@@ -0,0 +1,92 @@
# F4 — Orchestrator chat connector + Matrix (local homeserver)
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.md` (#613) — orchestrator-chat-connector decision.
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
## Goal
The fleet **orchestrator** is the operator's single point of contact. The north-star makes the
chat channel a **user-chosen connector** — tmux today, Discord live ("Mos"), with Matrix /
Telegram / Slack configurable. F4 adds **Matrix** (local homeserver) as a **peer** connector and,
first, the small **connector abstraction** that makes connectors pluggable without touching fleet
core.
## The abstraction (Phase 1 — this PR)
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
```ts
interface OrchestratorConnector {
readonly kind: 'tmux' | 'discord' | 'matrix';
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human → orchestrator
health(): Promise<ConnectorHealth>; // reachable + authenticated
}
```
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
ack half; `health()` is the liveness half.
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
Discord plugin) fit **without an interface change**.
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)`
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
### Config model
A roster may carry an optional `connector` block (`roster.schema.json`); absent ⇒ tmux.
```yaml
connector:
kind: matrix # tmux | discord | matrix
matrix:
homeserver_url: https://matrix.example.internal
user_id: '@mos:example.internal'
room_id: '!abc:example.internal'
```
**Secrets are never in the roster.** `MATRIX_ACCESS_TOKEN` / `DISCORD_BOT_TOKEN` come from the
environment (the gateway env-config pattern that already masks them). The sanitization gate would
reject a token committed to a shipped file anyway.
## Matrix connector (Phase 2)
The connector speaks the **Matrix client-server API** directly over HTTPS (`fetch` — no SDK needed
for MVP), so it is **homeserver-agnostic**:
| Op | Matrix CS-API |
| ----------- | ------------------------------------------------------------------------ |
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` |
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline |
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) |
| threads | `m.thread` relations ↔ `threadId` |
## Local homeserver (infra, not connector code)
Strategic default: a **self-hosted** homeserver on our own infra — no third-party gateway.
- **Default: Conduit** (Rust, single binary, low resource) — trivial to stand up for a fleet/dev
homeserver.
- **Alternative: Synapse** (mature, feature-complete) for scale.
The connector only needs `homeserver_url` + `user_id` + `room_id` + an access token, so the
homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not connector code.
## Phasing
| Phase | Scope | This PR |
| ----- | --------------------------------------------------------------------------------------- | ------- |
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow |
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |
## Back-compat & boundaries
- Existing rosters (no `connector`) resolve to tmux — **zero change**.
- Fleet core never branches on connector kind; it depends only on the interface.
- Cross-host reach rides the **federation** layer (W1), not a bespoke broker (north-star assumption).
- Phase 1 touches **no** `fleet.ts` core (a self-contained `connectors/` module), so it is
independent of the in-flight fleet-config PRs.

View File

@@ -0,0 +1,30 @@
# F4 — Orchestrator chat connector + Matrix (#616)
- **Issue:** #616 · **Branch:** `feat/f4-matrix-connector` (off main; independent of #615) · **Doctrine:** north-star #613.
## Phase 1 (this PR) — abstraction + scaffold
- `src/fleet/connectors/types.ts`: `OrchestratorConnector` (send/subscribe/health) + message/config types; thread-aware via optional `threadId`; `DEFAULT_CONNECTOR_KIND=tmux`.
- `src/fleet/connectors/registry.ts`: extensible factory registry; `resolveConnectorKind` (defaults tmux, back-compat); `createConnector` throws `ConnectorNotImplementedError` until Phase 2 registers factories.
- `roster.schema.json`: optional `connector` block (tmux|discord|matrix; matrix homeserver/user/room; secrets via env, never roster).
- Design doc `docs/fleet/f4-matrix-connector.md`: interface, config, Matrix CS-API mapping, Conduit-default infra, phasing.
- **No fleet.ts changes** → self-contained, zero conflict with stacked #615.
## Verification
- 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema valid JSON.
## Phase 2+ (follow-ups, in the doc)
Matrix CS-API client (fetch send/sync/health) + factory; init/configure connector-selection UX + roster-parse wiring; systemd launch wiring; Conduit deploy guide; first-party Mosaic Discord (threads) as a connector.
## Phase 2a (feat/f4-matrix-client, stacked on #617) — Matrix CS-API client
- `src/fleet/connectors/matrix.ts`: `MatrixConnector implements OrchestratorConnector` over the Matrix
client-server API (injectable fetch, no SDK). `send` → PUT m.room.message (thread-aware); `subscribe`
→ /sync long-poll loop using the pure `parseSyncResponse`; `health` → /versions + /whoami.
`registerMatrixConnector(env)` registers the factory (token from MATRIX_ACCESS_TOKEN, never roster).
- Pure helpers `buildMessageBody` + `parseSyncResponse` make send/receive unit-testable.
- 13 Matrix tests + 7 registry = 20 connector tests green; tsc/eslint/prettier clean.
- Remaining Phase 2: init/configure connector-selection UX + roster-parse wiring (touches fleet.ts —
after #615); systemd launch wiring; Conduit deploy guide.

View File

@@ -1,26 +0,0 @@
# Fleet enhancer role + two-agent floor (#614)
- **Issue:** #614 · **Branch:** `feat/fleet-enhancer-floor` (stacked on #612 `feat/fleet-polish-bundle`)
- **Doctrine:** `docs/fleet/north-star.md` (PR #613) — every fleet = orchestrator + enhancer minimum.
## Changes
- **Presets** (general, coding, research, hybrid): add `enhancer` (claude, `class: enhancer`,
`persistent_persona: true`) as a core always-on agent alongside the orchestrator. minimal/local-canary
unchanged.
- **fleet.ts**: `countEnhancers` helper; init guarantee extended — non-minimal profiles must yield
exactly 1 orchestrator AND >=1 enhancer (hard-fail otherwise); `removeAgentFromRoster` refuses to drop
the sole enhancer (symmetric with the sole-orchestrator guard) so the floor holds at runtime, not just init.
- **Role doc**: `framework/fleet/roles/enhancer.md` — the enhancer mandate (monitor → analyze → plan →
upgrade tools/skills/harness WITH orchestrator → file Mosaic Stack bug reports) + boundaries (does NOT
code or review).
## Verification
- 155 fleet tests green (new: countEnhancers; remove-sole-enhancer guard; remove-allows-when-another;
init two-agent-floor; every-non-minimal-preset-has-enhancer; updated preset rosters). tsc/eslint/
prettier/sanitize clean. TDD on the init guarantee + remove protection.
## Stacking
Built on #612's init-R5 code. PR shows #612 + enhancer until #612 merges; then rebase onto main → clean.

View File

@@ -1,20 +0,0 @@
# Fleet-polish bundle — boot-survival symmetry (#611)
- **Issue:** #611 · **Branch:** `feat/fleet-polish-bundle` · From the Lead's Codex symmetry-gap finding.
## Three fixes
1. **disable-on-remove (BUG, TDD).** `fleet remove` stopped + deleted roster/env/heartbeat but never
`systemctl --user disable mosaic-agent@NAME.service` → a removed-but-enabled unit could resurrect on
reboot pointing at deleted config. Fix: `buildSystemdDisableCommand` + disable in `remove`
(best-effort, gated on !--keep-files).
2. **add-enable.** `fleet add` now enables the new agent's unit for boot-survival (best-effort,
independent of --start) — symmetry with disable-on-remove.
3. **init-R5 guarantee.** `fleet init --write` now FAILS HARD when a non-minimal profile doesn't yield
exactly one orchestrator (was a soft warning). `minimal` (sanctioned no-orchestrator) still allowed.
## Verification
- 4 new tests (disable builder; remove-invokes-disable; add-invokes-enable; init general → exactly 1
orchestrator) + 147 existing fleet tests green (151 total). tsc/eslint/prettier clean.
- TDD on the disable bug per contract.

View File

@@ -15,10 +15,6 @@ agents:
runtime: claude
class: orchestrator
persistent_persona: true
- name: enhancer
runtime: claude
class: enhancer
persistent_persona: true
- name: coder0
runtime: pi
class: implementer

View File

@@ -15,10 +15,6 @@ agents:
runtime: claude
class: orchestrator
persistent_persona: true
- name: enhancer
runtime: claude
class: enhancer
persistent_persona: true
- name: generalist
runtime: pi
class: worker

View File

@@ -15,10 +15,6 @@ agents:
runtime: claude
class: orchestrator
persistent_persona: true
- name: enhancer
runtime: claude
class: enhancer
persistent_persona: true
- name: coder0
runtime: pi
class: implementer

View File

@@ -15,10 +15,6 @@ agents:
runtime: claude
class: orchestrator
persistent_persona: true
- name: enhancer
runtime: claude
class: enhancer
persistent_persona: true
- name: researcher0
runtime: pi
class: researcher

View File

@@ -1,41 +0,0 @@
# Enhancer — fleet role definition
The **enhancer** is one half of the fleet's two-agent floor: every fleet runs, at
minimum, an **orchestrator** and an **enhancer**. The orchestrator drives delivery;
the enhancer makes the fleet _get better at delivering_ over time.
It is a **core, always-on** agent (`class: enhancer`, `persistent_persona: true`),
not an ephemeral per-lane worker.
## Mandate
The enhancer runs the fleet's **continuous-improvement loop**:
1. **Monitor** fleet activity — agents, heartbeats, sessions, throughput, failures.
2. **Analyze** for enhancements and optimizations — friction, gaps, recurring defects,
missing or broken tools, skill/harness shortfalls.
3. **Plan** a remediation: a concrete improvement with rationale and expected effect.
4. **Upgrade fleet capability — with the orchestrator** — tool creation/repair, skills,
harness improvements. The orchestrator owns fleet composition; the enhancer advises and
implements improvements to the _means of production_, not the product.
5. **File upstream bug reports** to Mosaic Stack for real defects, so they flow back to the
framework for proper remediation rather than being patched over locally.
6. **Recommend which agents are needed** — advise the orchestrator on roles to add/remove as
the mission evolves.
## Boundaries
- **Does NOT write product/source code.**
- **Does NOT review code** (that is the code-review / security-review roles).
- **Does NOT perform delivery tasks.**
Improvement and diagnosis only. When the enhancer finds work that requires coding or review,
it files it (bug report / recommendation) and the orchestrator materializes the right worker.
## Why two, not one
The orchestrator alone optimizes for _this_ delivery; the enhancer optimizes for _every future_
delivery — self-healing the fleet's tools, skills, and harnesses, and routing real defects
upstream. Together they are the irreducible core; every other role is added on demand.
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).

View File

@@ -113,6 +113,35 @@
}
}
}
},
"connector": {
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
"type": "object",
"additionalProperties": false,
"required": ["kind"],
"properties": {
"kind": {
"enum": ["tmux", "discord", "matrix"]
},
"matrix": {
"type": "object",
"additionalProperties": false,
"required": ["homeserver_url", "user_id", "room_id"],
"properties": {
"homeserver_url": { "type": "string" },
"user_id": { "type": "string" },
"room_id": { "type": "string" }
}
},
"discord": {
"type": "object",
"additionalProperties": false,
"required": ["channel_id"],
"properties": {
"channel_id": { "type": "string" }
}
}
}
}
}
}

View File

@@ -14,13 +14,11 @@ import {
buildEnableLingerCommand,
buildFleetServiceCommand,
buildSystemdEnableCommand,
buildSystemdDisableCommand,
buildSystemdShowCommand,
buildTmuxListPanesCommand,
buildTmuxListSessionsCommand,
classifySendResult,
countOrchestrators,
countEnhancers,
detectDrift,
enableFleetUnits,
FLEET_PROFILES,
@@ -985,129 +983,6 @@ describe('fleet ps — drift detection', () => {
});
});
describe('fleet-polish bundle — boot-survival symmetry', () => {
async function rosterHome(agents: string): Promise<string> {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
return home;
}
it('buildSystemdDisableCommand returns the systemctl --user disable array', () => {
expect(buildSystemdDisableCommand('mosaic-agent@coder0.service')).toEqual([
'systemctl',
'--user',
'disable',
'mosaic-agent@coder0.service',
]);
});
it('fleet remove DISABLES the unit so a removed agent cannot resurrect on boot', async () => {
const home = await rosterHome(
[
'version: 1',
'transport: tmux',
'agents:',
' - name: orchestrator',
' runtime: pi',
' class: orchestrator',
' - name: coder0',
' runtime: codex',
' class: worker',
].join('\n') + '\n',
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
expect(calls).toContainEqual([
'systemctl',
'--user',
'disable',
'mosaic-agent@coder0.service',
]);
// stop must still happen too
expect(calls).toContainEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('fleet add ENABLES the new agent unit for boot-survival', async () => {
const home = await rosterHome(
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
) + '\n',
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder1',
'--runtime',
'codex',
'--class',
'worker',
'--no-start',
]);
expect(calls).toContainEqual([
'systemctl',
'--user',
'enable',
'mosaic-agent@coder1.service',
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('fleet init --write enforces the two-agent floor (1 orchestrator + >=1 enhancer)', async () => {
// The general profile must yield exactly one orchestrator AND at least one
// enhancer; the guarantee is enforced (not just warned). Happy path writes cleanly.
const home = await tempDir();
const program = new Command();
program.exitOverride();
registerFleetCommand(program, {
runner: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
mosaicHome: home,
});
try {
await program.parseAsync([
'node',
'mosaic',
'fleet',
'init',
'--profile',
'general',
'--write',
]);
const written = await readFile(join(home, 'fleet', 'roster.yaml'), 'utf8');
const orchestrators = (written.match(/class:\s*orchestrator/g) ?? []).length;
const enhancers = (written.match(/class:\s*enhancer/g) ?? []).length;
expect(orchestrators).toBe(1);
expect(enhancers).toBeGreaterThanOrEqual(1);
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
describe('fleet install — auto-enable units for boot-survival', () => {
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
@@ -2313,63 +2188,47 @@ describe('fleet preset rosters', () => {
},
);
it('general preset: orchestrator + enhancer + one generalist worker', async () => {
it('general preset: orchestrator + one generalist worker', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer', 'generalist']);
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']);
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
expect(roster.agents.find((a) => a.name === 'enhancer')?.className).toBe('enhancer');
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
});
it('coding preset: orchestrator + enhancer + coder0 + coder1 + reviewer', async () => {
it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator',
'enhancer',
'coder0',
'coder1',
'reviewer',
]);
});
it('research preset: orchestrator + enhancer + researcher0 + researcher1 + analyst', async () => {
it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator',
'enhancer',
'researcher0',
'researcher1',
'analyst',
]);
});
it('hybrid preset: orchestrator + enhancer + coder0 + researcher0 + reviewer', async () => {
it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator',
'enhancer',
'coder0',
'researcher0',
'reviewer',
]);
});
it('every non-minimal preset carries an enhancer (two-agent floor)', async () => {
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
expect(countOrchestrators(roster)).toBe(1);
expect(countEnhancers(roster)).toBeGreaterThanOrEqual(1);
expect(roster.agents.find((a) => a.className === 'enhancer')?.runtime).toBe('claude');
}
});
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
// Core agents (orchestrator + enhancer) run claude; only ephemeral workers are pi.
const workers = roster.agents.filter(
(a) => a.className !== 'orchestrator' && a.className !== 'enhancer',
);
const workers = roster.agents.filter((a) => a.name !== 'orchestrator');
for (const worker of workers) {
expect(worker.runtime).toBe('pi');
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
@@ -2511,43 +2370,6 @@ describe('fleet add/remove — pure helpers', () => {
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']);
});
it('countEnhancers counts enhancer-class agents (two-agent floor)', () => {
const roster: FleetRoster = {
...baseRoster,
agents: [
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
{ name: 'coder0', runtime: 'codex', className: 'worker' },
],
};
expect(countEnhancers(roster)).toBe(1);
expect(countEnhancers(baseRoster)).toBe(0);
});
it('removeAgentFromRoster throws when removing the sole enhancer (two-agent floor)', () => {
const roster: FleetRoster = {
...baseRoster,
agents: [
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
],
};
expect(() => removeAgentFromRoster(roster, 'enhancer')).toThrow('sole enhancer');
});
it('removeAgentFromRoster allows removing an enhancer when another remains', () => {
const roster: FleetRoster = {
...baseRoster,
agents: [
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
{ name: 'enhancer2', runtime: 'claude', className: 'enhancer' },
],
};
const updated = removeAgentFromRoster(roster, 'enhancer');
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer2']);
});
it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => {
const yaml = serializeRosterToYaml(baseRoster);
expect(typeof yaml).toBe('string');

View File

@@ -227,15 +227,6 @@ export function buildSystemdEnableCommand(unit: string): string[] {
return ['systemctl', '--user', 'enable', unit];
}
/**
* Returns the systemctl --user disable command for a given unit.
* Used by `fleet remove` so a removed agent's enabled unit cannot resurrect on
* boot pointing at deleted config (boot-survival symmetry with enable-on-add).
*/
export function buildSystemdDisableCommand(unit: string): string[] {
return ['systemctl', '--user', 'disable', unit];
}
/**
* Returns the loginctl enable-linger command for a given user.
* Linger allows user systemd services to survive logout.
@@ -881,33 +872,20 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content);
// Guarantee the two-agent floor: exactly one orchestrator AND at least
// one enhancer for every profile except the sanctioned no-orchestrator
// `minimal` preset. A mismatch means a corrupted/edited preset — fail hard
// rather than write a malformed fleet.
// Validate: exactly one orchestrator required (R5) — friendly summary on success.
const written = await loadFleetRoster(destination);
const orchCount = countOrchestrators(written);
const enhancerCount = countEnhancers(written);
if (profile === 'minimal') {
if (orchCount !== 1) {
process.stderr.write(
`Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`,
);
console.log(
`Initialized ${profile} fleet: ${written.agents.length} agent(s) (no orchestrator). Next: mosaic fleet install`,
);
} else if (orchCount !== 1) {
throw new Error(
`Fleet init failed: the "${profile}" roster has ${orchCount} orchestrator agent(s), ` +
`expected exactly 1 (R5). The preset may be corrupted — re-install the framework.`,
);
} else if (enhancerCount < 1) {
throw new Error(
`Fleet init failed: the "${profile}" roster has no enhancer agent. Every fleet keeps an ` +
`orchestrator + enhancer minimum (two-agent floor). The preset may be corrupted — ` +
`re-install the framework.`,
`Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`,
);
} else {
const workerCount = written.agents.length - 1 - enhancerCount;
const workerCount = written.agents.length - 1;
console.log(
`Initialized ${profile} fleet: 1 orchestrator + ${enhancerCount} enhancer(s) + ` +
`${workerCount} worker(s). Next: mosaic fleet install`,
`Initialized ${profile} fleet: 1 orchestrator + ${workerCount} agent(s). Next: mosaic fleet install`,
);
}
});
@@ -1240,24 +1218,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
// Enable the unit for boot-survival (non-fatal) — symmetry with
// disable-on-remove. Independent of --start so a queued agent still
// survives a reboot once its unit exists.
try {
const enableResult = await runner(
...splitCommand(buildSystemdEnableCommand(`mosaic-agent@${name}.service`)),
);
if (enableResult.exitCode !== 0) {
process.stderr.write(
`Warning: could not enable mosaic-agent@${name}.service: ${enableResult.stderr || enableResult.stdout || 'non-zero exit'}\n`,
);
}
} catch (err) {
process.stderr.write(
`Warning: enable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
if (opts.start !== false) {
await runChecked(runner, buildFleetServiceCommand('start', name));
console.log(`Started mosaic-agent@${name}.service.`);
@@ -1294,26 +1254,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
);
}
// Disable the unit (non-fatal) so an enabled instance cannot resurrect on
// boot pointing at the now-deleted config — boot-survival symmetry with
// enable-on-add. Skipped only when --keep-files keeps the config in place.
if (!opts.keepFiles) {
try {
const disableResult = await runner(
...splitCommand(buildSystemdDisableCommand(`mosaic-agent@${name}.service`)),
);
if (disableResult.exitCode !== 0) {
process.stderr.write(
`Warning: could not disable mosaic-agent@${name}.service: ${disableResult.stderr || disableResult.stdout || 'non-zero exit'}\n`,
);
}
} catch (err) {
process.stderr.write(
`Warning: disable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
// Write updated roster
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
@@ -1954,15 +1894,6 @@ export function countOrchestrators(roster: FleetRoster): number {
return roster.agents.filter((a) => a.className === 'orchestrator').length;
}
/**
* Count enhancer agents in a parsed roster. The two-agent floor (north-star)
* requires every non-minimal fleet to carry at least one enhancer alongside the
* sole orchestrator.
*/
export function countEnhancers(roster: FleetRoster): number {
return roster.agents.filter((a) => a.className === 'enhancer').length;
}
/** Valid runtime identifiers for fleet agents. */
export const VALID_FLEET_RUNTIMES: readonly string[] = [
'pi',
@@ -2005,15 +1936,6 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
`Cannot remove agent "${name}": it is the sole orchestrator. Add another orchestrator first (R5).`,
);
}
// Two-agent floor: never drop the last enhancer (the continuous-improvement
// loop). Symmetric with the sole-orchestrator guard.
const remainingEnhancerCount = remaining.filter((a) => a.className === 'enhancer').length;
if (remainingEnhancerCount === 0 && agent.className === 'enhancer') {
throw new Error(
`Cannot remove agent "${name}": it is the sole enhancer. Every fleet keeps at least one ` +
`enhancer (two-agent floor). Add another enhancer first.`,
);
}
return {
...roster,
agents: remaining,

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
MatrixConnector,
buildMessageBody,
parseSyncResponse,
registerMatrixConnector,
type FetchLike,
} from './matrix.js';
import { createConnector, _resetConnectorRegistry } from './registry.js';
import type { MatrixConnectorConfig } from './types.js';
const CONFIG: MatrixConnectorConfig = {
homeserverUrl: 'https://matrix.internal/',
userId: '@mos:internal',
roomId: '!room:internal',
};
/** A fetch mock that returns queued responses and records calls. */
function mockFetch(responses: Array<{ ok?: boolean; status?: number; body?: unknown }>): {
fetchImpl: FetchLike;
calls: Array<{ url: string; method?: string; body?: string }>;
} {
const calls: Array<{ url: string; method?: string; body?: string }> = [];
let i = 0;
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, method: init?.method, body: init?.body });
const r = responses[Math.min(i, responses.length - 1)] ?? {};
i += 1;
return {
ok: r.ok ?? true,
status: r.status ?? 200,
json: async () => r.body ?? {},
text: async () => JSON.stringify(r.body ?? {}),
};
};
return { fetchImpl, calls };
}
describe('buildMessageBody', () => {
it('builds an m.text event', () => {
expect(buildMessageBody({ text: 'hi' })).toEqual({ msgtype: 'm.text', body: 'hi' });
});
it('adds an m.thread relation when threadId is set', () => {
expect(buildMessageBody({ text: 'hi', threadId: '$evt' })).toEqual({
msgtype: 'm.text',
body: 'hi',
'm.relates_to': { rel_type: 'm.thread', event_id: '$evt' },
});
});
});
describe('parseSyncResponse', () => {
it('extracts operator messages and skips the orchestrators own echoes', () => {
const data = {
next_batch: 's2',
rooms: {
join: {
'!room:internal': {
timeline: {
events: [
{
type: 'm.room.message',
sender: '@jason:internal',
origin_server_ts: 1_700_000_000_000,
content: { body: 'status?' },
},
{
type: 'm.room.message',
sender: '@mos:internal', // self — skipped
origin_server_ts: 1_700_000_001_000,
content: { body: 'working on it' },
},
{ type: 'm.reaction', sender: '@jason:internal', content: {} }, // non-message
],
},
},
},
},
};
const msgs = parseSyncResponse(data, '!room:internal', '@mos:internal');
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatchObject({ text: 'status?', sender: '@jason:internal' });
expect(msgs[0]!.ts).toBe(new Date(1_700_000_000_000).toISOString());
});
it('carries threadId through thread-relments', () => {
const data = {
rooms: {
join: {
'!room:internal': {
timeline: {
events: [
{
type: 'm.room.message',
sender: '@jason:internal',
origin_server_ts: 1,
content: {
body: 'in thread',
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
},
},
],
},
},
},
},
};
expect(parseSyncResponse(data, '!room:internal', '@mos:internal')[0]!.threadId).toBe('$root');
});
it('returns [] for an empty/foreign sync', () => {
expect(parseSyncResponse({}, '!room:internal', '@mos:internal')).toEqual([]);
});
});
describe('MatrixConnector', () => {
it('throws without an access token', () => {
expect(() => new MatrixConnector(CONFIG, { accessToken: '' })).toThrow(/access token/i);
});
it('send PUTs an m.text event and returns the event id', async () => {
const { fetchImpl, calls } = mockFetch([{ body: { event_id: '$abc' } }]);
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
const res = await c.send({ text: 'pong' }, 1234);
expect(res).toEqual({ delivered: true, messageId: '$abc' });
expect(calls[0]!.method).toBe('PUT');
expect(calls[0]!.url).toContain(
'/_matrix/client/v3/rooms/!room%3Ainternal/send/m.room.message/mosaic-1234-1',
);
expect(JSON.parse(calls[0]!.body!)).toEqual({ msgtype: 'm.text', body: 'pong' });
});
it('send reports not-delivered on a non-2xx', async () => {
const { fetchImpl } = mockFetch([{ ok: false, status: 403 }]);
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
const res = await c.send({ text: 'x' });
expect(res.delivered).toBe(false);
expect(res.error).toContain('403');
});
it('health reports reachable + authenticated when whoami matches', async () => {
const { fetchImpl } = mockFetch([
{ body: { versions: ['v1.11'] } }, // /versions
{ body: { user_id: '@mos:internal' } }, // /whoami
]);
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
const h = await c.health();
expect(h.reachable).toBe(true);
expect(h.authenticated).toBe(true);
});
it('health flags auth mismatch', async () => {
const { fetchImpl } = mockFetch([
{ body: {} },
{ body: { user_id: '@someone-else:internal' } },
]);
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
const h = await c.health();
expect(h.reachable).toBe(true);
expect(h.authenticated).toBe(false);
});
it('health reports unreachable when /versions fails', async () => {
const { fetchImpl } = mockFetch([{ ok: false, status: 502 }]);
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
const h = await c.health();
expect(h.reachable).toBe(false);
});
});
describe('registerMatrixConnector', () => {
beforeEach(() => _resetConnectorRegistry());
it('registers a matrix factory createConnector can build', () => {
registerMatrixConnector({ MATRIX_ACCESS_TOKEN: 'tok' } as NodeJS.ProcessEnv);
const c = createConnector({ kind: 'matrix', matrix: CONFIG });
expect(c.kind).toBe('matrix');
});
it('the factory rejects config missing the matrix block', () => {
registerMatrixConnector({ MATRIX_ACCESS_TOKEN: 'tok' } as NodeJS.ProcessEnv);
expect(() => createConnector({ kind: 'matrix' })).toThrow(/missing the .matrix. block/i);
});
});

View File

@@ -0,0 +1,246 @@
/**
* Matrix connector (F4 Phase 2) — speaks the Matrix client-server API directly
* over HTTPS so it is homeserver-agnostic (Conduit default, Synapse alt). No
* SDK: a small injectable fetch keeps it dependency-light and unit-testable.
*
* The access token is supplied by the caller (from the environment —
* MATRIX_ACCESS_TOKEN — per the gateway secret pattern), never the roster.
*/
import {
type OrchestratorConnector,
type OutboundMessage,
type InboundMessage,
type SendResult,
type ConnectorHealth,
type MatrixConnectorConfig,
type Unsubscribe,
} from './types.js';
import { registerConnector } from './registry.js';
/** Minimal fetch surface — avoids a lib.dom dependency and is trivial to mock. */
export interface FetchLike {
(
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string },
): Promise<{
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
}>;
}
export interface MatrixConnectorOptions {
accessToken: string;
/** Injectable fetch (defaults to global fetch). */
fetchImpl?: FetchLike;
/** Long-poll timeout for /sync, ms. */
syncTimeoutMs?: number;
}
/** Build the `m.room.message` event content, threading when a threadId is set. */
export function buildMessageBody(message: OutboundMessage): Record<string, unknown> {
const content: Record<string, unknown> = {
msgtype: 'm.text',
body: message.text,
};
if (message.threadId) {
content['m.relates_to'] = { rel_type: 'm.thread', event_id: message.threadId };
}
return content;
}
/** Shape of the bits of a /sync response we consume. */
interface SyncResponse {
next_batch?: string;
rooms?: {
join?: Record<
string,
{
timeline?: {
events?: Array<{
type?: string;
sender?: string;
origin_server_ts?: number;
content?: {
body?: string;
['m.relates_to']?: { rel_type?: string; event_id?: string };
};
}>;
};
}
>;
};
}
/**
* Extract inbound operator messages from a /sync response for one room,
* skipping the orchestrator's own echoes. Pure — the testable core of receive.
*/
export function parseSyncResponse(
data: unknown,
roomId: string,
selfUserId: string,
): InboundMessage[] {
const sync = data as SyncResponse;
const events = sync.rooms?.join?.[roomId]?.timeline?.events ?? [];
const out: InboundMessage[] = [];
for (const ev of events) {
if (ev.type !== 'm.room.message') continue;
if (!ev.sender || ev.sender === selfUserId) continue; // skip our own messages
const body = ev.content?.body;
if (typeof body !== 'string') continue;
const rel = ev.content?.['m.relates_to'];
out.push({
text: body,
sender: ev.sender,
ts: new Date(ev.origin_server_ts ?? 0).toISOString(),
...(rel?.rel_type === 'm.thread' && rel.event_id ? { threadId: rel.event_id } : {}),
});
}
return out;
}
export class MatrixConnector implements OrchestratorConnector {
readonly kind = 'matrix' as const;
private readonly fetchImpl: FetchLike;
private readonly token: string;
private readonly syncTimeoutMs: number;
private txnCounter = 0;
private stopped = false;
constructor(
private readonly config: MatrixConnectorConfig,
opts: MatrixConnectorOptions,
) {
this.token = opts.accessToken;
this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
this.syncTimeoutMs = opts.syncTimeoutMs ?? 30_000;
if (!this.token) {
throw new Error('MatrixConnector requires an access token (set MATRIX_ACCESS_TOKEN).');
}
}
private url(path: string): string {
return `${this.config.homeserverUrl.replace(/\/$/, '')}${path}`;
}
private authHeaders(): Record<string, string> {
return { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' };
}
/** Monotonic, unique-per-instance transaction id for idempotent sends. */
private nextTxnId(nowMs: number): string {
this.txnCounter += 1;
return `mosaic-${nowMs}-${this.txnCounter}`;
}
async send(message: OutboundMessage, nowMs = Date.now()): Promise<SendResult> {
const txnId = this.nextTxnId(nowMs);
const path = `/_matrix/client/v3/rooms/${encodeURIComponent(
this.config.roomId,
)}/send/m.room.message/${encodeURIComponent(txnId)}`;
try {
const res = await this.fetchImpl(this.url(path), {
method: 'PUT',
headers: this.authHeaders(),
body: JSON.stringify(buildMessageBody(message)),
});
if (!res.ok) {
return { delivered: false, error: `Matrix send failed: HTTP ${res.status}` };
}
const json = (await res.json()) as { event_id?: string };
return { delivered: true, ...(json.event_id ? { messageId: json.event_id } : {}) };
} catch (err) {
return { delivered: false, error: err instanceof Error ? err.message : String(err) };
}
}
subscribe(handler: (message: InboundMessage) => void): Unsubscribe {
this.stopped = false;
let since: string | undefined;
const loop = async (): Promise<void> => {
while (!this.stopped) {
try {
const q = new URLSearchParams({ timeout: String(this.syncTimeoutMs) });
if (since) q.set('since', since);
const res = await this.fetchImpl(this.url(`/_matrix/client/v3/sync?${q.toString()}`), {
method: 'GET',
headers: this.authHeaders(),
});
if (!res.ok) {
await this.backoff();
continue;
}
const data = await res.json();
since = (data as SyncResponse).next_batch ?? since;
for (const msg of parseSyncResponse(data, this.config.roomId, this.config.userId)) {
handler(msg);
}
} catch {
await this.backoff();
}
}
};
void loop();
return () => {
this.stopped = true;
};
}
private backoff(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 2_000));
}
async health(): Promise<ConnectorHealth> {
try {
const versions = await this.fetchImpl(this.url('/_matrix/client/versions'), {
method: 'GET',
});
if (!versions.ok) {
return {
reachable: false,
authenticated: false,
detail: `versions HTTP ${versions.status}`,
};
}
const who = await this.fetchImpl(this.url('/_matrix/client/v3/account/whoami'), {
method: 'GET',
headers: this.authHeaders(),
});
if (!who.ok) {
return { reachable: true, authenticated: false, detail: `whoami HTTP ${who.status}` };
}
const json = (await who.json()) as { user_id?: string };
const authenticated = json.user_id === this.config.userId;
return {
reachable: true,
authenticated,
lastSeen: new Date().toISOString(),
...(authenticated
? {}
: { detail: `whoami user ${json.user_id} != ${this.config.userId}` }),
};
} catch (err) {
return {
reachable: false,
authenticated: false,
detail: err instanceof Error ? err.message : String(err),
};
}
}
}
/**
* Register the Matrix connector factory. The token is read from the environment
* (MATRIX_ACCESS_TOKEN) at build time, never the roster.
*/
export function registerMatrixConnector(env: NodeJS.ProcessEnv = process.env): void {
registerConnector('matrix', (config) => {
if (!config.matrix) {
throw new Error('Matrix connector config missing the `matrix` block (homeserver/user/room).');
}
return new MatrixConnector(config.matrix, { accessToken: env['MATRIX_ACCESS_TOKEN'] ?? '' });
});
}

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
KNOWN_CONNECTOR_KINDS,
isKnownConnectorKind,
resolveConnectorKind,
registerConnector,
hasConnector,
createConnector,
ConnectorNotImplementedError,
_resetConnectorRegistry,
} from './registry.js';
import type { ConnectorConfig, OrchestratorConnector } from './types.js';
function fakeConnector(kind: 'tmux' | 'discord' | 'matrix'): OrchestratorConnector {
return {
kind,
send: async () => ({ delivered: true, messageId: 'x' }),
subscribe: () => () => {},
health: async () => ({ reachable: true, authenticated: true }),
};
}
describe('connector registry (F4 Phase 1)', () => {
beforeEach(() => {
_resetConnectorRegistry();
});
it('knows the three peer connector kinds', () => {
expect(KNOWN_CONNECTOR_KINDS).toEqual(['tmux', 'discord', 'matrix']);
});
it('isKnownConnectorKind guards correctly', () => {
expect(isKnownConnectorKind('matrix')).toBe(true);
expect(isKnownConnectorKind('irc')).toBe(false);
expect(isKnownConnectorKind(42)).toBe(false);
});
it('resolveConnectorKind defaults to tmux when config is absent (back-compat)', () => {
expect(resolveConnectorKind(undefined)).toBe('tmux');
expect(resolveConnectorKind({ kind: 'matrix' })).toBe('matrix');
});
it('createConnector throws ConnectorNotImplementedError for an unregistered kind', () => {
const cfg: ConnectorConfig = { kind: 'matrix' };
expect(() => createConnector(cfg)).toThrow(ConnectorNotImplementedError);
expect(() => createConnector(cfg)).toThrow(/not implemented yet/i);
});
it('createConnector with no config resolves the default kind (tmux) and reports it unimplemented in Phase 1', () => {
try {
createConnector();
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(ConnectorNotImplementedError);
expect((err as ConnectorNotImplementedError).kind).toBe('tmux');
}
});
it('register → has → create resolves a registered factory', () => {
expect(hasConnector('matrix')).toBe(false);
registerConnector('matrix', (cfg) => fakeConnector(cfg.kind));
expect(hasConnector('matrix')).toBe(true);
const connector = createConnector({ kind: 'matrix' });
expect(connector.kind).toBe('matrix');
});
it('passes the config through to the factory', () => {
let received: ConnectorConfig | null = null;
registerConnector('matrix', (cfg) => {
received = cfg;
return fakeConnector(cfg.kind);
});
const cfg: ConnectorConfig = {
kind: 'matrix',
matrix: {
homeserverUrl: 'https://matrix.internal',
userId: '@mos:internal',
roomId: '!room:internal',
},
};
createConnector(cfg);
expect(received).toEqual(cfg);
});
});

View File

@@ -0,0 +1,76 @@
/**
* Connector registry (F4 Phase 1).
*
* A tiny extensible registry so connector implementations (Phase 2: tmux,
* Discord, Matrix) register a factory by kind and fleet core resolves one from
* roster config without branching on kind. Phase 1 ships the registry + the
* config→kind resolution; the connector factories land in Phase 2.
*/
import {
type ConnectorConfig,
type ConnectorKind,
type OrchestratorConnector,
DEFAULT_CONNECTOR_KIND,
} from './types.js';
/** The set of connector kinds the framework recognizes. */
export const KNOWN_CONNECTOR_KINDS: readonly ConnectorKind[] = ['tmux', 'discord', 'matrix'];
/** Type guard: is `value` a known connector kind? */
export function isKnownConnectorKind(value: unknown): value is ConnectorKind {
return typeof value === 'string' && (KNOWN_CONNECTOR_KINDS as readonly string[]).includes(value);
}
/**
* Resolve the connector kind from roster config. Absent config ⇒ the default
* (tmux) so existing rosters keep working unchanged (back-compat).
*/
export function resolveConnectorKind(config?: ConnectorConfig): ConnectorKind {
return config?.kind ?? DEFAULT_CONNECTOR_KIND;
}
/** A factory builds a live connector from its validated config. */
export type ConnectorFactory = (config: ConnectorConfig) => OrchestratorConnector;
/** Thrown when no factory is registered for a requested kind. */
export class ConnectorNotImplementedError extends Error {
constructor(public readonly kind: ConnectorKind) {
super(
`Connector "${kind}" is not implemented yet. ` +
`Register a factory via registerConnector('${kind}', …) (F4 Phase 2).`,
);
this.name = 'ConnectorNotImplementedError';
}
}
const registry = new Map<ConnectorKind, ConnectorFactory>();
/** Register a connector factory for a kind (idempotent — last registration wins). */
export function registerConnector(kind: ConnectorKind, factory: ConnectorFactory): void {
registry.set(kind, factory);
}
/** True when a factory is registered for `kind`. */
export function hasConnector(kind: ConnectorKind): boolean {
return registry.has(kind);
}
/**
* Build a connector from roster config. Throws `ConnectorNotImplementedError`
* when no factory is registered for the resolved kind (the Phase-1 default for
* every kind until Phase 2 registers them).
*/
export function createConnector(config?: ConnectorConfig): OrchestratorConnector {
const kind = resolveConnectorKind(config);
const factory = registry.get(kind);
if (!factory) {
throw new ConnectorNotImplementedError(kind);
}
return factory(config ?? { kind });
}
/** Test/runtime helper: drop all registrations. */
export function _resetConnectorRegistry(): void {
registry.clear();
}

View File

@@ -0,0 +1,111 @@
/**
* Orchestrator chat connectors (F4).
*
* A connector mediates the chat channel between the fleet **orchestrator** and
* its human operator. Connectors are PEERS — tmux (default), Discord, Matrix,
* and future first-party plugins — selected per fleet, never hardwired. Fleet
* core depends only on the small uniform interface below, so a new connector
* drops in without touching the fleet.
*
* The interface is deliberately minimal: send (orchestrator → human),
* subscribe (human → orchestrator), health (reachable/authed liveness). Thread
* support is optional metadata (`threadId`) so thread-capable connectors
* (Matrix rooms/threads, the future Mosaic Discord plugin) fit without an
* interface change.
*/
/** The connector kinds shipped/known to the framework. */
export type ConnectorKind = 'tmux' | 'discord' | 'matrix';
/** A message the orchestrator sends out to the human operator. */
export interface OutboundMessage {
/** Message body (markdown where the connector supports it). */
text: string;
/** Optional thread/topic id for thread-capable connectors. */
threadId?: string;
/** Optional attachment references (paths or URLs); connector-dependent. */
attachments?: string[];
}
/** A message received from the human operator. */
export interface InboundMessage {
/** Message body. */
text: string;
/** Thread/topic id if the connector carries one. */
threadId?: string;
/** Opaque sender identifier (connector-scoped). */
sender: string;
/** ISO-8601 timestamp the connector assigns/observes. */
ts: string;
}
/** Result of a send — the "ack" half of ack/health. */
export interface SendResult {
/** True when the connector accepted/delivered the message. */
delivered: boolean;
/** Connector-assigned message id when available. */
messageId?: string;
/** Reason when `delivered` is false. */
error?: string;
}
/** Liveness of a connector — the "health" half of ack/health. */
export interface ConnectorHealth {
/** The transport endpoint is reachable. */
reachable: boolean;
/** Credentials are valid / the connector is authenticated. */
authenticated: boolean;
/** ISO-8601 of the last successful interaction, if any. */
lastSeen?: string;
/** Human-readable detail (e.g. failure reason). */
detail?: string;
}
/** Unsubscribe handle returned by `subscribe`. */
export type Unsubscribe = () => void;
/**
* The uniform contract every orchestrator chat connector implements. Small by
* design — send / subscribe / health — so connectors are interchangeable and
* fleet core never branches on connector kind.
*/
export interface OrchestratorConnector {
/** Which kind of connector this is. */
readonly kind: ConnectorKind;
/** Send a message from the orchestrator to the operator. */
send(message: OutboundMessage): Promise<SendResult>;
/** Subscribe to inbound operator messages; returns an unsubscribe handle. */
subscribe(handler: (message: InboundMessage) => void): Unsubscribe;
/** Report connector liveness (reachable + authenticated). */
health(): Promise<ConnectorHealth>;
}
/**
* Connector configuration carried by the roster (the `connector` block).
* Secrets (access tokens, bot tokens) are NEVER stored here — they come from
* the environment (the gateway env-config pattern). Absent config ⇒ tmux.
*/
export interface ConnectorConfig {
kind: ConnectorKind;
/** Matrix connector settings (homeserver + room); token via env. */
matrix?: MatrixConnectorConfig;
/** Discord connector settings (channel); token via env. */
discord?: DiscordConnectorConfig;
}
export interface MatrixConnectorConfig {
/** Local homeserver base URL, e.g. https://matrix.example.internal */
homeserverUrl: string;
/** Full Matrix user id of the orchestrator, e.g. @mos:example.internal */
userId: string;
/** Room id/alias the orchestrator converses in. */
roomId: string;
}
export interface DiscordConnectorConfig {
/** Channel id the orchestrator converses in. */
channelId: string;
}
/** The default connector when a roster declares none (back-compat). */
export const DEFAULT_CONNECTOR_KIND: ConnectorKind = 'tmux';