Compare commits

...

20 Commits

Author SHA1 Message Date
7342415a32 fix(fleet): consume model_hint + fix socket-default trap (stand-up fixes) (#627)
Some checks are pending
ci/woodpecker/push/ci Pipeline is running
ci/woodpecker/push/publish Pipeline is running
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 19:18:01 +00:00
095e19443b feat(fleet): onboarding-injection — comms cheat-sheet + peer roster per agent (#621)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 17:54:54 +00:00
fabc413407 feat(fleet): F4 Phase 2a — Matrix CS-API connector client + factory (#618)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 16:48:17 +00:00
858d90329d feat(fleet): F4 Phase 1 — chat connector abstraction + Matrix design (#617)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 16:14:32 +00:00
2bf66136e4 feat(fleet): enhancer role + two-agent floor (orchestrator + enhancer) (#615)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 13:15:59 +00:00
4434c3c481 docs(fleet): orchestrator+enhancer two-agent floor + role library + Discord plugin north-star (#613)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-22 13:15:05 +00:00
dd0a0d38c6 ci(publish): gate kaniko image builds + publish on changed paths (CI throughput) (#619)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 13:14:31 +00:00
d46ac40890 fix(fleet): boot-survival symmetry — disable-on-remove + add-enable + init-R5 (#612)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 08:12:58 +00:00
8ddd48c843 feat(mosaic): mosaic update re-seeds framework + relaunches agents (R13) (#610)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 03:34:05 +00:00
528700ceea feat(framework): P6 — docs + compliance matrix + resident-budget CI (#607)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/tag/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 02:20:35 +00:00
32f4215461 chore(release): bump @mosaicstack/mosaic 0.0.38 -> 0.0.39 (#608)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-22 02:16:12 +00:00
23343bb7f0 feat(mosaic): P5 — overlay composer (compose-contract + *.local overlays) (#605)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 02:16:05 +00:00
c8b2dab0ca chore(release): bump @mosaicstack/mosaic 0.0.37 -> 0.0.38 (#603)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-22 01:48:27 +00:00
6dbe452a9f fix(fleet): watch viewer-session leak + workdir test settle-race (#601)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:43:21 +00:00
59c755067e feat(fleet): F3-m2 — native Pi heartbeat + model surface + mosaic_mission_status tool (#602)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:43:18 +00:00
6ffb27787e fix(fleet): complete HB reader/writer consistency + sidecar hardening (#599)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:22:35 +00:00
130837365f chore(release): bump @mosaicstack/mosaic 0.0.36 -> 0.0.37 (#597)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 23:27:14 +00:00
67df06f1c4 feat(fleet): orchestrator-mutable fleet — fleet add/remove (F5/R9) (#596)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:26:21 +00:00
60a309d5a4 fix(fleet): heartbeat consistency — MOSAIC_HOME path + configurable interval (#595)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 23:25:53 +00:00
2dc0f24828 docs(fleet): Fleet Suite PRD (init/configure/operate + Mos-on-Discord) (#588)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:17:10 +00:00
43 changed files with 3900 additions and 137 deletions

View File

@@ -25,12 +25,10 @@ steps:
commands: commands:
- apk add --no-cache bash - apk add --no-cache bash
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh - bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
# L0 resident-token budget: keep the Constitution + dispatcher small. # Resident line-count ceiling over framework-owned resident files
- | # (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
for f in CONSTITUTION.md AGENTS.md; do - bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
n=$(wc -l < "packages/mosaic/framework/defaults/$f") - bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
if [ "$n" -gt 120 ]; then echo "L0 budget exceeded: defaults/$f is $n lines (max 120)"; exit 1; fi
done
typecheck: typecheck:
image: *node_image image: *node_image

View File

@@ -4,6 +4,23 @@
variables: variables:
- &node_image 'node:22-alpine' - &node_image 'node:22-alpine'
- &enable_pnpm 'corepack enable' - &enable_pnpm 'corepack enable'
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
# (tags) always build everything. Exclude-list keeps the default SAFE: any
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
- &image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when: when:
- branch: [main] - branch: [main]
@@ -26,6 +43,15 @@ steps:
publish-npm: publish-npm:
image: *node_image image: *node_image
# Publish only when a publishable package changed (or on a release tag); a
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'packages/**'
environment: environment:
NPM_TOKEN: NPM_TOKEN:
from_secret: gitea_token from_secret: gitea_token
@@ -91,6 +117,7 @@ steps:
build-gateway: build-gateway:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username
@@ -116,6 +143,7 @@ steps:
build-appservice: build-appservice:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username
@@ -141,6 +169,7 @@ steps:
build-web: build-web:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username

View File

@@ -45,3 +45,36 @@ Active workstream is **W1 — Federation v1**. Workers should:
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change). - Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (53%); all 12 hard gates intact. - Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (53%); all 12 hard gates intact.
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md. - Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
## P5 — Overlay composer + cross-harness (#604) — feat/p5-overlay-composer
- Status: MERGED to main (#605). R7 (compose-contract) + R8 (cross-harness) + R9 (composer test).
- `composeContract({harness, mosaicHome})` pure fn + `.local` overlay deltas-by-value; `mosaic compose-contract <harness>` command; AGENTS bare-launch nudge; composer spec (per-tier anchor + Tier-3 byte-equality). Detail: scratchpads/p5-overlay-composer.md.
## P6 — Docs, compliance matrix, alpha tag (#606) — feat/p6-docs-compliance-alpha
- Status: in-repo deliverables done (CONTRIBUTING.md + harness×gate compliance matrix + check-resident-budget.sh + CI wiring + ALPHA-DOD.md). Remaining: alpha tag v0.0.39-alpha (Lead, post-merge). aiguide reconcile merged (#8). Detail: scratchpads/p6-docs-compliance-alpha.md.
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
- 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
- Status: MERGED to main. 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
- Status: MERGED to main. 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.
## 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.
## Fleet stand-up fixes — model_hint→--model + socket-default trap (#626) — feat/fleet-standup-fixes
- Status: implemented + tested. FIX1 model_hint→MOSAIC_AGENT_MODEL→--model. FIX2 absent socket = default tmux socket (no -L) across parse/spawn/systemd-unit/observe (socketArgs helper, bare-empty shellEnvValue, conditional -L). 158 fleet tests green; shipped presets unaffected (explicit socket_name). Detail: scratchpads/fleet-standup-fixes.md.

View File

@@ -0,0 +1,75 @@
# Constitution Alpha — Definition-of-Done checklist + release notes
Drafted for the `v0.0.39-alpha` tag (Lead cuts after P5 #605 → P6 #607 → aiguide #8 merge).
Maps every DoD §8 acceptance criterion to its merged evidence. Legend:
**✅ merged on main** · **⏳ review-ready PR (pending merge)** · **🔲 Lead action**.
## DoD §8 green-checklist
| # | Acceptance criterion (DESIGN §8) | Status | Evidence / PR |
| --- | ------------------------------------------------------------------------------------------------------ | ------ | ----------------- |
| 1 | MIT `LICENSE` (root + framework) + `"license":"MIT"` in package.json | ✅ | P0 #570 |
| 2 | Three credential-path sites + hook URL fast-failed (no private paths in `*.sh`/hooks) | ✅ | P0 #570 |
| 3 | `verify-sanitized.sh` (two-class, `*.sh`+`*.md`, self-tested) wired **blocking** in CI | ✅ | P1 #572 |
| 4 | Operator data purged from the full set (guides / tools / init-generator) | ✅ | P2 #572 |
| 5 | `rails/``tools/` in **both** template families | ✅ | P2 #572 |
| 6 | `jarvis-loop.json` deleted; `defaults/SOUL.md`**neutral sanitized persona** (Q10 decision) | ✅ | P2 #572 |
| 7 | `CONSTITUTION.md` extracted (gates one place, capability-verb, §1.4 split, no false "already loaded") | ✅ | P3 #575 / #577 |
| 8 | `AGENTS.md`/`STANDARDS.md` out of `PRESERVE_PATHS` + seed-semantics → overwrite in **both** installers | ✅ | P4 #590 |
| 9 | Snapshot + v2→v3 migration moving user edits to `.local`/`.bak`; `FRAMEWORK_VERSION=3` | ✅ | P4 #590 / #593 |
| 10 | `mosaic-init --non-interactive` fail-closed persona | ✅ | P4 #590 |
| 11 | **5-fixture migration matrix** green against **both** installers asserting **injected bytes** | ✅ | P4 #590 / #593 |
| 12 | `compose-contract` built + composer unit test (per-tier anchor + Tier-3 byte-equality) | ⏳ | P5 #605 |
| 13 | Resident line-count ceiling enforced (framework-owned resident files) | ⏳ | P6 #607 |
| 14 | `CONTRIBUTING.md` + harness×gate compliance matrix | ⏳ | P6 #607 |
| 15 | `aiguide` reconciled with the Constitution | ⏳ | aiguide #8 |
| 16 | Each phase PR CI-green; alpha tag pushed + Gitea release published | 🔲 | Lead (post-merge) |
**Note on #6:** the DoD's literal "delete `defaults/SOUL.md`" was superseded by the resolved
**Q10** decision — ship a _neutral, operator-agnostic_ example persona instead of deleting it. Main
carries the sanitized 2.6 KB neutral SOUL.md ("Mosaic agent", no operator identity); the sanitization
gate confirms it is PII-clean. Criterion met in spirit (no operator persona leaks) via the better option.
**Gate to flip 1214 → ✅:** merge P5 #605 → P6 #607 (rebase auto-drops the dup format fix
`adc7df2`/`9f6da92`) → aiguide #8, with `ci.yml` terminal-green on the merged head.
---
## Release notes — `v0.0.39-alpha` (Mosaic Framework Constitution, alpha)
### Mosaic Framework Constitution — Alpha
This release makes the Mosaic framework a **safe-to-open-source, fork-and-customize agent
operating layer**. It separates the non-negotiable law from operator identity, makes
customization survive upgrades, and wires the guarantees into CI.
**Highlights**
- **Constitution (L0).** The hard gates now live in one place — `CONSTITUTION.md` — authored in
capability verbs, with a thin `AGENTS.md` dispatcher that references the law instead of restating
it. Governance model in `constitution/LAYER-MODEL.md`.
- **Public & sanitized.** MIT-licensed; all operator identity, private paths, and credential sites
removed from shipped files. A self-tested `verify-sanitized.sh` gate (two rule classes) runs
**blocking** in CI so re-contamination can't merge.
- **Upgrade-safe customization.** Framework-owned files overwrite cleanly on upgrade while
`SOUL.md`/`USER.md`/`*.local.md`/`credentials` are preserved. The v2→v3 migration snapshots first
and moves any user-edited `AGENTS.md`/`STANDARDS.md` to `.pre-constitution.bak`/`.local.md`
never silently lost. Verified by a 5-fixture matrix across **both** installers.
- **Operator overlays.** `mosaic compose-contract <harness>` merges your `*.local.md` deltas into
the contract per harness, so customization reaches the model as one pre-merged blob.
- **Cross-harness.** Single L0 source referenced (never restated) by Claude / Codex / OpenCode / Pi;
tiered injection with a byte-equal Tier-3 fallback read.
- **Guardrails in CI.** Resident line-count ceiling over framework-owned resident files; composer
unit test; sanitization gate — all blocking.
- **Docs.** `CONTRIBUTING.md` with the layer model, dual-installer parity rule, and a harness×gate
**compliance matrix** (the Codex/OpenCode/Pi hook-parity gap is tracked for v2).
**Known limitations (accepted, documented in `CONTRIBUTING.md` §9)**
- Bare launches that bypass `mosaic` get base contracts only (no `*.local` overlays) and are not
drift-checked by `mosaic doctor` — mitigated by the unconditional Tier-3 self-load + a nudge.
- Codex/OpenCode/Pi mechanical hook parity, `policy/*.md` composition, and live-launch cross-harness
verification are **v2**.
**Phase lineage:** P0 #570 · P1+P2 #572 · P3 #575/#577 · P4 #590/#593 · P5 #605 · P6 #607 ·
aiguide #8 (umbrella #542).

View File

@@ -0,0 +1,109 @@
# PRD — Mosaic Fleet Suite (init, configure, operate)
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
> **North star:** [docs/fleet/north-star.md](./north-star.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.350.0.37
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
## Mission
Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**:
a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid),
gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can
operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from
CLI/TUI and (designed-for) the webUI.
**Immediate tangible goal:** the **"Mos"** orchestrator agent running on `w-jarvis`, reachable
in **Discord channel `1517622518662434996`** (server `1112631390438166618`). Once the fleet is
functional, we use the fleet itself to continue the work.
## Requirements
### A. Configure-without-AI CLI
| ID | Requirement |
| --- | ------------------------------------------------------------------------------------------------------------- |
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). |
| R17 | Fleet controls are **simple and intuitive**. |
### B. Comms & orchestrator chat-ops
| ID | Requirement |
| --- | --------------------------------------------------------------------------------------------------------------------------------- |
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. |
### C. Runtime, health, lifecycle
| ID | Requirement |
| --- | ---------------------------------------------------------------------------------- |
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
| R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. |
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
### D. Surfaces, testing, docs
| ID | Requirement |
| --- | ----------------------------------------------------------------------------------- |
| R18 | Fleet built so the **webUI can view / monitor / terminate / butt-in** on a session. |
| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. |
| R12 | **Documentation**: how to install, configure, and use the fleet. |
## Architecture / approach
- **Config model:** `roster.yaml` is the source of truth (already exists). Add **presets** (`general`/`coding`/`research`/`hybrid`) as shipped example rosters; `init` selects a preset, always injects the orchestrator, and writes the roster. Re-init = regenerate roster (preserve user/site overrides — mirrors install env-merge from #567).
- **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session.
- **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable.
- **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path.
- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
## Phases (incremental, each shippable)
| Phase | Deliverable | Notes |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1R5, R8, R10, R17 |
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 |
| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) |
## Work division (proposed — confirm with dragon-lin)
- **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis.
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11).
- **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous.
## Immediate target: Mos on Discord (F2 first slice)
The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable
fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996`
(server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within
~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar.
## Success criteria
- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator.
- **Mos answers in Discord `1517…`** on w-jarvis.
- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin.
- Harness updates handled gracefully; HB healthy for claude + pi/gpt agents.
- Docs let a new operator install/configure/use the fleet.
- Re-init + orchestrator mutation work.
## Assumptions (veto-able)
- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`.
- `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later.
- `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol).
- `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed).

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

@@ -73,6 +73,37 @@ diff-sanity → squash-merge → verify), **decide-and-inform** cadence, and a d
this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03 this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03
(orchestration model) for the rationale. (orchestration model) for the rationale.
## Fleet roster — the two-agent floor and the role library
A fleet is **never a single agent**. The minimum viable fleet is **two**:
| Role | Mandate | Boundaries |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| **Orchestrator** | The user's **single point of contact**. Owns the general flow, keeps agentic actions on-target, and **adds/removes agents from the fleet at will** to meet goals and user needs. Exactly **one** per fleet (the existing R5 invariant). | Delegates source work; never the sole worker. |
| **Enhancer** | The fleet's **continuous-improvement loop**. Monitors fleet activity, analyzes for enhancements/optimizations, builds a **plan of remediation**, and — **with the orchestrator** — upgrades fleet capability: tool creation/repair, skills, harness improvements, and **bug reports filed to Mosaic Stack** for proper remediation. Recommends which agents are needed. | **Does not code, review code, or perform delivery tasks.** Improvement and diagnosis only. |
> **Why two, not one:** the orchestrator drives delivery; the enhancer makes the fleet
> _get better at delivering_ over time. The enhancer is how the fleet self-heals its tools,
> skills, and harnesses, and how real defects flow back to Mosaic Stack as bug reports.
> Together they are the irreducible core — every other role is added on demand.
A **general** fleet starts at this floor: the orchestrator (advised by the enhancer)
materializes whatever roles prove necessary over the mission's life. Specialized presets
(coding, research, etc.) seed additional roles up front, but all reduce to the same two-agent
spine plus an on-demand **role library**:
| Role profile | Purpose |
| ------------------- | --------------------------------------------------------------------------------- |
| **orchestrator** | point of contact, flow control, fleet composition (1 per fleet) |
| **enhancer** | fleet monitoring, optimization, tool/skill/harness upgrades, upstream bug reports |
| **coder** | implementation (worker; stops at PR-open) |
| **code review** | independent code review gate |
| **security review** | security/auth/secret review gate |
| **research** | investigation, synthesis, options analysis |
| **board** | deliberation panel — moonshot, contrarian, technical, business, financial lenses |
| **operations** | infra, deploy, health, incident response |
| _…extensible_ | new profiles added as missions demand (orchestrator + enhancer decide) |
## Invariants — "maximal vision, incremental delivery, zero foreclosure" ## Invariants — "maximal vision, incremental delivery, zero foreclosure"
Every artifact, starting Phase 2, MUST: Every artifact, starting Phase 2, MUST:
@@ -102,7 +133,7 @@ Every artifact, starting Phase 2, MUST:
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| 01 | tmux PoC, hardening, published CLI v0.0.34 (#565#568) | ✅ done | | 01 | tmux PoC, hardening, published CLI v0.0.34 (#565#568) | ✅ done |
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now | | **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: orchestrator+reviewer; ephemeral workers per lane) | planned | | 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning | planned | | 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning | planned |
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity | planned | | 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity | planned |
@@ -121,6 +152,28 @@ Every artifact, starting Phase 2, MUST:
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger), runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
which `fleet init` should automate. which `fleet init` should automate.
## Decisions of record (2026-06-22, with Jason)
- **Two-agent floor:** every fleet has, at minimum, an **orchestrator** and an **enhancer**.
The orchestrator is the user's point of contact and composes the fleet; the enhancer runs the
continuous-improvement loop (monitor → analyze → remediate → upgrade tools/skills/harness →
file Mosaic Stack bug reports) and **does not code or review**.
- **Role library:** orchestrator, enhancer, coder, code review, security review, research,
board (moonshot/contrarian/technical/business/financial), operations — extensible; the
orchestrator (advised by the enhancer) adds roles as missions demand.
- **Orchestrator chat connector:** the orchestrator is reachable over a user-chosen connector
(tmux now; Telegram/Discord/Matrix/Slack configurable). Validated live: **"Mos" orchestrator
on Discord** via the Claude Code discord channel plugin (w-jarvis).
## Future enhancements (north-star, post-MVP — not on the MVP track)
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly
implements the basic Discord functions **and native Discord threads**. Threads let a user
separate conversation topics with the orchestrator (the pattern proven by the Hermes agent).
A major enhancement over the current third-party channel plugin; **not required for the MVP**,
but a committed north-star target. `ASSUMPTION:` ships as a Mosaic-owned plugin so the fleet
controls Discord UX (threads, reactions, attachments, per-thread context) end-to-end.
## Assumptions (veto-able) ## Assumptions (veto-able)
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst, - `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,

View File

@@ -0,0 +1,29 @@
# F3-m3 — `mosaic update` re-seeds framework + relaunches agents (R13)
- **Issue:** #609 · **Branch:** `feat/f3-m3-update-reseed`
## Gap (found in 0.0.39 production validation)
`mosaic update` installs the new npm CLI but never re-seeds `~/.config/mosaic/` from the package's
bundled `framework/`. So the shipped custom Pi harness (agent-name export + native HB, 0.0.39) stays
DORMANT until a re-seed — operators get the new CLI on a stale framework.
## Implementation
- `update-checker.ts`: `resolveBundledFrameworkRoot()`, `buildReseedCommand()` (install.sh in
`MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep` — the P4 data-safe reconcile), `runFrameworkReseed()`,
`readRosterAgentNames()`, `buildRelaunchCommands()` (systemctl --user restart per agent).
- `cli.ts` `update`: after a successful CLI install that includes `@mosaicstack/mosaic`, re-seed the
framework (default-on; `--no-reseed` to skip). Then either `--relaunch` (restart rostered agents) or
print clear guidance to run `mosaic update --relaunch` / `mosaic fleet restart`.
## Flow
`update CLI → re-seed framework (data-safe) → relaunch agents (opt-in)` — closes R13, activates the
native harness for every operator.
## Verification
- 6 new unit tests (reseed command/env, relaunch commands, roster parse, missing-installer guard).
- 19 runtime + 26 launch tests still green; tsc/eslint/prettier clean.
- Data-safety of the sync is already proven (P4 5-fixture matrix + live dragon-lin validation).

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

@@ -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.

View File

@@ -0,0 +1,26 @@
# 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

@@ -0,0 +1,20 @@
# 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

@@ -0,0 +1,28 @@
# Fleet stand-up fixes — model_hint→--model + socket-default trap (#626)
- **Issue:** #626 · **Branch:** `feat/fleet-standup-fixes` (off main). PoC-blocking, before doctrine doc.
## FIX 1 — model_hint consumed
- generateAgentEnv emits `MOSAIC_AGENT_MODEL=<modelHint>` (bare empty when unset).
- start-agent-session.sh default command → `mosaic yolo $RUNTIME ${MOSAIC_AGENT_MODEL:+--model $MOSAIC_AGENT_MODEL}`.
→ pi workers launch with `--model openai-codex/gpt-5.5:high`.
## FIX 2 — socket default trap (absent ⇒ literal default socket, no -L everywhere)
- THE TRAP (3 sites): parseRosterText fallback was DEFAULT_SOCKET_NAME; systemd unit had
`Environment=MOSAIC_TMUX_SOCKET=mosaic-factory` + `ExecStop ${…:-mosaic-factory}`; start-agent-session
defaulted `:-mosaic-factory`. All fixed → absent socket = '' = default tmux socket (no -L).
- `socketArgs(name)` helper → `name ? ['-L', name] : []`; replaced all ~15 -L render sites in fleet.ts.
- shellEnvValue('') now emits a **bare** `VAR=` (not `''`) — unambiguous empty in systemd EnvironmentFile
(a quoted '' could become a literal socket named "''").
- start-agent-session.sh: `_tmux` wrapper passes -L only when socket set; mosaic-agent@.service: dropped the
socket default + conditional ExecStop. So spawn == observe == onboarding cheat-sheet.
- CONTAINMENT: all 6 shipped presets set socket_name: mosaic-factory explicitly → unaffected; only
socket-less rosters (the PoC) get default-socket behavior. DEFAULT_SOCKET_NAME exported for explicit use.
## Verification
- 158 fleet + 201 fleet-adjacent tests green; new: socketArgs none/named, model_hint→env, explicit-socket
renders -L, socket-less env bare. tsc/eslint/prettier/sanitize clean. Shell bash -n + end-to-end sim
(socket-less→no -L, model→--model).

View File

@@ -0,0 +1,43 @@
# P5 — Overlay composer + cross-harness (compose-contract)
- **Issue:** #604 · **Branch:** `feat/p5-overlay-composer` · **Lineage:** #542 → constitution alpha
- **Requirements:** R7 (compose-contract) + R8 (cross-harness) + R9 (composer test)
- **Design of record:** `docs/design/framework-constitution/{DESIGN.md §3.2, PRD.md §4}` (on `feat/framework-constitution-alpha`)
## Locked design (sequential-thinking)
Current `launch.ts` assembly (`buildComposedPrompt`) injects by value: mission + PRD + hard-gate +
CONSTITUTION + AGENTS + USER + TOOLS + runtime. It does **not** inject SOUL or STANDARDS (those are
read-on-demand per the gutted AGENTS dispatcher), and has no `.local` overlay support.
**Decision (ASSUMPTION — recorded for the PR):** overlays are injected as **deltas by value** under
labeled sections; base files keep their existing residency.
- `USER.local.md` → appended directly under the `# User Profile` block (USER is injected).
- `SOUL.local.md` + `STANDARDS.local.md` → a trailing `# Operator Overlays` section (their bases are
load-on-demand, so only the small delta is injected — not the full base prose).
- **Why:** honors DESIGN §3.2 ("model gets one pre-merged blob, no read-merge ritual") while preserving
the P3 byte-budget tiering (don't re-inject large SOUL/STANDARDS prose). Precedence order kept: base
layers first, operator overlays at recency.
- Base-only is automatic when a `.local` file is absent (`readOptional`).
## Plan
| # | Task | File |
| --- | ------------------------------------------------------------------------------------------------------ | --------------------------------------- |
| 1 | Extract `composeContract({harness, mosaicHome})` pure fn; `buildComposedPrompt` delegates | `src/commands/launch.ts` |
| 2 | Overlay logic (USER.local under profile; SOUL/STANDARDS.local in `# Operator Overlays`) | `src/commands/launch.ts` |
| 3 | `mosaic compose-contract <harness>` command → prints blob to stdout | `src/commands/launch.ts` |
| 4 | Bare-launch overlay nudge in self-load fallback | `framework/defaults/AGENTS.md` |
| 5 | `compose-contract.spec.ts`: per-tier anchor, Tier-3 byte-equality, overlay present/absent, per-harness | `src/commands/compose-contract.spec.ts` |
## Deferred to P6
CONTRIBUTING.md + harness×gate compliance matrix; resident line-count CI ceiling; `aiguide` reconcile;
alpha tag `mosaic-vX.Y.Z-alpha`.
## Status
- [x] Phase scaffold (branch, issue #604, scratchpad, TASKS)
- [ ] Implementation (tasks 15)
- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut)

View File

@@ -0,0 +1,29 @@
# P6 — Docs, compliance matrix, alpha tag (constitution capstone)
- **Issue:** #606 · **Branch:** `feat/p6-docs-compliance-alpha` · **Lineage:** #542
- **Requirements:** R9 (resident line-count ceiling) + R10 (CONTRIBUTING + compliance matrix + aiguide) + alpha tag
## Delivered (in-repo)
- `framework/CONTRIBUTING.md` — layer model, operator-hygiene/PII prohibition, dedup rule, resident
budget, **dual-installer parity rule**, adding-a-harness, re-contamination rule, **harness×gate
compliance matrix** (hook-parity gap marked ⚠️ tracked-v2), known-limitations (§9 residuals), PR checklist.
- `framework/tools/quality/scripts/check-resident-budget.sh` — line-count ceiling over framework-owned
resident files (CONSTITUTION + AGENTS + each runtime/\*/RUNTIME.md); `--self-test`; replaces the crude
inline ci.yml loop. Wired blocking in `.woodpecker/ci.yml`.
- Composer unit test (R9) already runs via `pnpm test`; `verify-sanitized.sh` (P1) already wired.
## Verification
- Sanitization gate green (CONTRIBUTING is operator-neutral). Resident-budget self-test + real run green.
- prettier clean. Current resident counts: CONSTITUTION 96, AGENTS 83, RUNTIME max 75 — all < ceiling.
## Remaining
- [ ] `aiguide` reconcile (separate repo `~/src/aiguide` / mosaicstack/aiguide) — consistency pass vs Constitution.
- [ ] Alpha tag `mosaic-vX.Y.Z-alpha` — propose version; Lead cuts after full DoD §8 green + all phases merged.
## Notes
- Alpha DoD (DESIGN §8): all phases P0P6 merged + CI green. P5 (#605) pending merge after 0.0.38 publish.
- Hook parity (codex/opencode/pi) = tracked v2 gap, documented in the matrix, not closed here.

View File

@@ -0,0 +1,185 @@
# Contributing to the Mosaic Framework
The Mosaic framework is the open-source agent-operating layer that deploys to
`~/.config/mosaic/`. It is designed to be **forked and customized** — but the
shared core must stay operator-neutral, deduplicated, and upgrade-safe. This
guide is the contract for changing framework-owned files.
> Governance model and layer rationale: `constitution/LAYER-MODEL.md` (source-only).
> Requirements & phase history: `docs/design/framework-constitution/`.
---
## 1. The layer model (where does my change go?)
| Layer | What | Owner | On upgrade | File(s) |
| ------ | ------------------------------------------------------------- | ---------------- | --------------------------------------- | -------------------------------------------- |
| **L0** | Constitution — the non-negotiable law (hard gates) | Framework | **Overwritten** | `CONSTITUTION.md` |
| **L1** | Standards & guides — how to do the work well | Framework | Overwritten; user delta → `*.local.md` | `STANDARDS.md`, `guides/*` |
| **L2** | Persona (SOUL) — agent name, tone, role | User (init) | **Never overwritten** | `SOUL.md` (+ optional `SOUL.local.md`) |
| **L3** | Operator (USER) — human identity, prefs, policy | User (init) | **Never overwritten** | `USER.md` (+ optional `USER.local.md`) |
| **L4** | Project / runtime mechanism — per-repo deltas; harness wiring | Repo / framework | Project user-owned; runtime overwritten | `<repo>/AGENTS.md`, `runtime/<h>/RUNTIME.md` |
**The one sentence a user can rely on:** edit `SOUL.md` / `USER.md` and the
`.local.md` overlays — they survive every upgrade. To change framework behavior,
add a `.local.md` overlay; never edit a framework-owned file in place.
---
## 2. Operator hygiene (PII / secrets prohibition) — **blocking**
Framework-owned files ship publicly. They **must not** contain:
- Operator or personal identity (names, handles, pronouns, accessibility notes).
- Private `$HOME` paths, private hostnames, or domains.
- Secrets, tokens, or credentials (use `~/.config/mosaic/credentials.json`; the
hook URL soft-degrades via `${OPENBRAIN_URL}`).
This is enforced by `tools/quality/scripts/verify-sanitized.sh`, wired **blocking**
in CI (`.woodpecker/ci.yml`). It runs two rule classes: structural (private-`$HOME`
defaults, dead paths, unrendered tokens) and a labeled current-contaminant denylist.
Run it locally before pushing:
```bash
bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
```
Operator-specific behavior belongs in **your** `SOUL.md`/`USER.md`/`*.local.md`,
never in the shared core. (The "framework-PR firewall" in `CONSTITUTION.md` §4
states this as law for agents opening framework PRs.)
---
## 3. Dedup rule — one source, everyone references it
Hard gates live in **`CONSTITUTION.md` (L0) only**. `AGENTS.md`, `STANDARDS.md`,
and every `runtime/<h>/RUNTIME.md` **reference** the law — they never restate it.
Restating a gate is a defect: it creates two sources that drift. If you find a
gate duplicated outside L0, delete the copy and point to L0.
`AGENTS.md` is a thin dispatcher (load order + guide router + the tier-aware
self-load). Keep it that way; new procedure goes in `guides/*` (on-demand), not
in the resident core.
---
## 4. Resident line-count ceiling — **blocking**
The framework-owned files injected by value (`CONSTITUTION.md`, `AGENTS.md`, each
`runtime/<h>/RUNTIME.md`) are budgeted by **line count** — never by word count
(a word cap forces paraphrasing the law, the exact drift vector we removed).
```bash
bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
```
Wired blocking in CI. Gate **wording** stays intact; if a file legitimately needs
more lines, raise its ceiling in the script deliberately (in the same PR, with
rationale). The per-harness _total_ resident prompt (which also sums the user's
`SOUL.md`/`USER.md`) is a `mosaic doctor` runtime advisory — CI cannot see user
files, so it is out of CI scope by design (DESIGN §7).
---
## 5. Dual-installer parity rule
Two installers seed and migrate `~/.config/mosaic/`:
- **`framework/install.sh`** (bash) — the canonical installer.
- **`packages/mosaic/src/config/file-adapter.ts`** (TS) — the wizard path.
**Any change to seed lists, overwrite/preserve semantics, or migration MUST land
in BOTH**, validated by the **shared fixture suite**:
- `framework/tools/quality/scripts/test-install-migration.sh` (bash matrix)
- `packages/mosaic/src/config/file-adapter.test.ts` (vitest)
Both assert the same behavior: framework-owned files overwrite (backup-once to
`*.pre-constitution.bak`); user-seeded files seed-if-absent; `SOUL.md`/`USER.md`/
`*.local.md`/`credentials` are preserved. A change in one installer without the
other (and its fixtures) is incomplete.
---
## 6. Adding a harness adapter
A harness (runtime) is wired by:
1. `runtime/<h>/RUNTIME.md`**mechanism only** (subagent syntax, hook/MCP wiring,
injection method). No restated gates (see §3).
2. Launcher emission in `src/commands/launch.ts` — how the composed contract reaches
the harness (system-prompt append vs. instructions file). Add the harness to the
`RuntimeName` union and the runtime-path map.
3. `mosaic compose-contract <harness>` works automatically once the runtime path
exists (it composes base + `*.local.md` overlays for that harness).
Then add a row to the compliance matrix (§8) and mark which gates are mechanical
vs. resident-only for the new harness.
---
## 7. Re-contamination rule
A green sanitization gate is not permanent. Before every PR:
- Do not reintroduce operator identity, private paths, or secrets (§2).
- Do not copy a gate out of L0 (§3).
- Do not add an unrendered template token or a dead path to a shipped file.
If `verify-sanitized.sh` goes red, that diff **is** your worklist — fix it, don't
suppress it.
---
## 8. Harness × gate compliance matrix
How each gate is enforced per harness. **Mechanical** = a hook/CI check the agent
cannot bypass. **Resident** = injected contract prose (strong, but not a hard stop).
**CI** = repo-side, harness-independent.
| Gate / mechanism | Claude | Codex | OpenCode | Pi |
| --------------------------------------------- | ----------- | ---------------- | ---------------- | ---------------- |
| Contract injection (resident-by-value) | append SP | instructions | `AGENTS.md` | append SP |
| Operator overlays (`*.local`, composed) | ✅ | ✅ | ✅ | ✅ |
| Bare-launch self-load (Tier-3, read L0) | ✅ | ✅ | ✅ | ✅ |
| Sanitization (no PII) — `verify-sanitized` | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
| Resident budget ceiling | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
| Migration parity (5-fixture, both installers) | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
| `no-memory-write` (PreToolUse hook) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
| QA / typecheck (PostToolUse hooks) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
| Native heartbeat (fleet `ps` model/status) | sidecar | sidecar | sidecar | **native ✅** |
⚠️ **Hook-parity gap (tracked, v2):** the mechanical PreToolUse/PostToolUse hooks
exist for Claude Code only. On Codex/OpenCode/Pi those gates are currently enforced
by the resident contract + CI, not by a per-tool hook. Closing hook parity is a
**v2** item, not part of this alpha.
---
## 9. Known limitations (accepted residual risks)
These are accepted with rationale (DESIGN §9); they are documented, not bugs:
- **Bare-launch overlays are base-only.** A harness started without `mosaic` never
ran the composer, so `*.local.md` overlays are not applied. Mitigated by the
unconditional Tier-3 self-load + the `mosaic doctor` nudge in `AGENTS.md`; not
eliminated. Relaunch via `mosaic <harness>` to pick up overlays.
- **Bare-launch drift is undetected by `mosaic doctor`** (the launcher never ran).
- **Codex/OpenCode/Pi hook parity** is a tracked v2 gap (§8).
- **Live-launch cross-harness verification** is v2; the alpha verifies the composer
by unit test (per-tier anchor + Tier-3 byte-equality), not a live launch.
**Deferred to v2 (explicit):** `constitution/` deploy directory; capability JSON
adapters; 3-way merge; `policy/*.md` composition; per-layer version stamps as a
migration driver.
---
## 10. PR checklist
- [ ] No operator identity / private paths / secrets (`verify-sanitized.sh` green).
- [ ] No gate restated outside `CONSTITUTION.md` (§3).
- [ ] Resident budget green (`check-resident-budget.sh`).
- [ ] Seed/migration changes landed in **both** installers + shared fixtures (§5).
- [ ] New harness → compliance-matrix row updated (§8).
- [ ] `prettier --check` + `pnpm lint` + `pnpm typecheck` + `pnpm test` green.

View File

@@ -9,7 +9,10 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
1. Your context already includes `CONSTITUTION.md` + `USER.md` + the TOOLS index + the runtime 1. Your context already includes `CONSTITUTION.md` + `USER.md` + the TOOLS index + the runtime
contract (injected by `mosaic` launch) — do not re-read those. **If you were launched bare** contract (injected by `mosaic` launch) — do not re-read those. **If you were launched bare**
(a harness started without `mosaic`, so the law is NOT in your context), read (a harness started without `mosaic`, so the law is NOT in your context), read
`~/.config/mosaic/CONSTITUTION.md` now, before your first action. `~/.config/mosaic/CONSTITUTION.md` now, before your first action. A bare launch also gets
**base contracts only** — operator overlays (`*.local.md`) are composed by the launcher, so if
`SOUL.local.md`/`USER.local.md`/`STANDARDS.local.md` exist, relaunch via `mosaic <harness>` (or run
`mosaic doctor`) to pick them up.
2. Read `SOUL.md` (agent persona — small, once). 2. Read `SOUL.md` (agent persona — small, once).
3. Read project-local `AGENTS.md` / `CLAUDE.md` if present (these may only make behavior stricter). 3. Read project-local `AGENTS.md` / `CLAUDE.md` if present (these may only make behavior stricter).
4. Read guides ONLY as triggered by the table below — pull role-relevant depth on demand, not up front. 4. Read guides ONLY as triggered by the table below — pull role-relevant depth on demand, not up front.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
# 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

@@ -81,6 +81,18 @@
"class": { "class": {
"type": "string" "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": { "working_directory": {
"type": "string" "type": "string"
}, },
@@ -113,6 +125,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

@@ -9,8 +9,16 @@
* 4. Memory routing — remind agent to use ~/.config/mosaic/memory/ * 4. Memory routing — remind agent to use ~/.config/mosaic/memory/
*/ */
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'; import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; import { Type } from 'typebox';
import {
existsSync,
readFileSync,
writeFileSync,
unlinkSync,
mkdirSync,
renameSync,
} from 'node:fs';
import { join, basename } from 'node:path'; import { join, basename } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { execSync, spawnSync } from 'node:child_process'; import { execSync, spawnSync } from 'node:child_process';
@@ -25,6 +33,57 @@ const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mo
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Native heartbeat (fleet R14/R15)
// ---------------------------------------------------------------------------
// When this agent runs under the Mosaic fleet (MOSAIC_AGENT_NAME set), the
// extension writes its OWN heartbeat in the same .hb contract `fleet ps` reads
// (ts/pid/status[/model]) and touches a `.hb.native` precedence marker so the
// shell sidecar defers. Native HB knows the real turn state (busy/ok), so it is
// more accurate than the pane-PID-only sidecar fallback.
const HB_AGENT_NAME = process.env['MOSAIC_AGENT_NAME'] ?? '';
const HB_RUN_DIR = process.env['MOSAIC_HEARTBEAT_RUN_DIR'] ?? join(MOSAIC_HOME, 'fleet', 'run');
const HB_INTERVAL_MS = (() => {
const s = Number.parseInt(process.env['MOSAIC_HEARTBEAT_INTERVAL'] ?? '', 10);
return Number.isFinite(s) && s > 0 ? s * 1000 : 15_000;
})();
function nativeHbEnabled(): boolean {
return HB_AGENT_NAME.length > 0;
}
function readModelId(ctx: ExtensionContext): string | null {
const m = ctx.model as unknown as { id?: string; name?: string } | undefined;
return m?.id ?? m?.name ?? null;
}
function writeNativeHeartbeat(status: 'ok' | 'busy', model: string | null): void {
if (!nativeHbEnabled()) return;
try {
mkdirSync(HB_RUN_DIR, { recursive: true });
const hb = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb`);
const lines = [`ts=${nowIso()}`, `pid=${process.pid}`, `status=${status}`];
if (model) lines.push(`model=${model}`);
const tmp = `${hb}.tmp.${process.pid}`;
writeFileSync(tmp, lines.join('\n') + '\n');
renameSync(tmp, hb); // atomic replace — fleet ps never reads a partial file
// Precedence marker: tells the shell sidecar that native HB is authoritative.
writeFileSync(join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`), nowIso() + '\n');
} catch {
// Best-effort: never let heartbeat I/O disrupt the Pi session.
}
}
function clearNativeMarker(): void {
if (!nativeHbEnabled()) return;
try {
const m = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`);
if (existsSync(m)) unlinkSync(m); // native stopping — let the sidecar take over
} catch {
/* ignore */
}
}
function safeRead(filePath: string): string | null { function safeRead(filePath: string): string | null {
try { try {
return readFileSync(filePath, 'utf-8'); return readFileSync(filePath, 'utf-8');
@@ -187,6 +246,9 @@ function buildMissionSummary(cwd: string, mission: ActiveMission): string {
export default function register(pi: ExtensionAPI) { export default function register(pi: ExtensionAPI) {
let sessionCwd = process.cwd(); let sessionCwd = process.cwd();
let hbStatus: 'ok' | 'busy' = 'ok';
let hbModel: string | null = null;
let hbTimer: ReturnType<typeof setInterval> | null = null;
// ── Session Start ───────────────────────────────────────────────────── // ── Session Start ─────────────────────────────────────────────────────
pi.on('session_start', async (_event, ctx) => { pi.on('session_start', async (_event, ctx) => {
@@ -207,10 +269,39 @@ export default function register(pi: ExtensionAPI) {
} else { } else {
ctx.ui.notify('Mosaic framework loaded', 'info'); ctx.ui.notify('Mosaic framework loaded', 'info');
} }
// Native heartbeat: write immediately, then on an interval. Idle = 'ok';
// turn_start/turn_end flip the status so `fleet ps` reflects real activity.
if (nativeHbEnabled()) {
hbModel = readModelId(ctx);
writeNativeHeartbeat('ok', hbModel);
hbTimer = setInterval(() => writeNativeHeartbeat(hbStatus, hbModel), HB_INTERVAL_MS);
if (typeof hbTimer.unref === 'function') hbTimer.unref();
}
}); });
// ── Session End ─────────────────────────────────────────────────────── // ── Turn lifecycle → accurate busy/ok heartbeat ───────────────────────
pi.on('session_end', async (_event, _ctx) => { pi.on('turn_start', async (_event, ctx) => {
hbStatus = 'busy';
hbModel = readModelId(ctx) ?? hbModel;
writeNativeHeartbeat('busy', hbModel);
});
pi.on('turn_end', async (_event, ctx) => {
hbStatus = 'ok';
hbModel = readModelId(ctx) ?? hbModel;
writeNativeHeartbeat('ok', hbModel);
});
// ── Session Shutdown ──────────────────────────────────────────────────
// (The pi API event is 'session_shutdown'; the prior 'session_end' handler
// never fired — fixed here so repo hooks + lock cleanup actually run.)
pi.on('session_shutdown', async (_event, _ctx) => {
if (hbTimer) {
clearInterval(hbTimer);
hbTimer = null;
}
clearNativeMarker();
// Run repo session-end hook // Run repo session-end hook
runRepoHook(sessionCwd, 'session-end'); runRepoHook(sessionCwd, 'session-end');
@@ -252,4 +343,32 @@ export default function register(pi: ExtensionAPI) {
} }
}, },
}); });
// ── Register mosaic_mission_status tool (model-callable) ──────────────
// R14 "proper tool usage": give the agent a first-class tool to load its
// active Mosaic mission, milestone progress, task counts, and latest
// scratchpad — so it self-orients on in-flight work before planning,
// instead of shelling out or guessing. Mirrors the /mosaic-status command
// but returns the summary as tool output the LLM can read.
pi.registerTool({
name: 'mosaic_mission_status',
label: 'Mosaic Mission Status',
description:
'Return the active Mosaic mission, milestone progress, task counts, and latest scratchpad for the current project. Returns a note when no mission is active.',
promptSnippet: 'Read the active Mosaic mission + task state for the current project',
promptGuidelines: [
'Use mosaic_mission_status at the start of a session or task to load the active mission, milestone progress, and open tasks before planning work.',
],
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
const mission = detectMission(sessionCwd);
const text = mission
? buildMissionSummary(sessionCwd, mission)
: 'No active Mosaic mission in this project.';
return {
content: [{ type: 'text', text }],
details: mission ? { ...mission } : { active: false },
};
},
});
} }

View File

@@ -8,13 +8,15 @@ PartOf=mosaic-tmux-holder.service
[Service] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory # No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal
# default tmux socket (no -L). The per-agent .env sets it when the roster names
# one; otherwise it stays unset and start-agent-session.sh uses the default socket.
Environment=MOSAIC_AGENT_NAME=%i Environment=MOSAIC_AGENT_NAME=%i
Environment=MOSAIC_AGENT_RUNTIME=pi Environment=MOSAIC_AGENT_RUNTIME=pi
Environment=MOSAIC_AGENT_WORKDIR=%h Environment=MOSAIC_AGENT_WORKDIR=%h
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
ExecStop=-/bin/bash -lc 'tmux -L "${MOSAIC_TMUX_SOCKET:-mosaic-factory}" kill-session -t "=%i"' ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
[Install] [Install]
WantedBy=default.target WantedBy=default.target

View File

@@ -2,11 +2,15 @@
set -euo pipefail set -euo pipefail
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory} # Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's
# socket_name is honored when set; absent never silently becomes mosaic-factory
# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe).
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi} MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME} MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-} MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-$HOME/.config/mosaic/fleet/run} MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15} MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
if [ -z "$AGENT_NAME" ]; then if [ -z "$AGENT_NAME" ]; then
@@ -19,13 +23,25 @@ if ! command -v tmux >/dev/null 2>&1; then
exit 69 exit 69
fi fi
if tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then # tmux wrapper: pass -L only when a socket is configured. An absent/empty socket
echo "Mosaic agent session already running: $AGENT_NAME on socket $MOSAIC_TMUX_SOCKET" # means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet.
_tmux() {
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
else
tmux "$@"
fi
}
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
exit 0 exit 0
fi fi
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME" # Map the roster's per-agent model_hint to `--model` so workers launch on the
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
fi fi
# ── Derive a runtime-bin PATH prefix ───────────────────────────────────────── # ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
@@ -90,23 +106,30 @@ MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
# #
# We build the snippet as a double-quoted here-string embedded in a printf call # We build the snippet as a double-quoted here-string embedded in a printf call
# to avoid nested quoting problems. # to avoid nested quoting problems.
#
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
# server environment (not this script's, and not the systemd unit's), so the
# name would otherwise be empty in-pane and the runtime's native heartbeat
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
# safe single bash token regardless of the name's characters.
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
PANE_SHELL_SNIPPET="export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
else else
PANE_SHELL_SNIPPET="exec ${MOSAIC_AGENT_COMMAND}" PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; exec ${MOSAIC_AGENT_COMMAND}"
fi fi
mkdir -p "$MOSAIC_AGENT_WORKDIR" mkdir -p "$MOSAIC_AGENT_WORKDIR"
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ──── # ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \ _tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
bash -c "$PANE_SHELL_SNIPPET" bash -c "$PANE_SHELL_SNIPPET"
# ── Resolve the pane PID (retry briefly to let the session initialise) ──────── # ── Resolve the pane PID (retry briefly to let the session initialise) ────────
PANE_PID="" PANE_PID=""
for _retry in 1 2 3 4 5; do for _retry in 1 2 3 4 5; do
PANE_PID=$(tmux -L "$MOSAIC_TMUX_SOCKET" list-panes \ PANE_PID=$(_tmux list-panes \
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true) -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
[ -n "$PANE_PID" ] && break [ -n "$PANE_PID" ] && break
sleep 0.2 sleep 0.2
@@ -129,7 +152,7 @@ _start_heartbeat_sidecar() {
# references to any variables from this script's environment. # references to any variables from this script's environment.
local sidecar_script local sidecar_script
sidecar_script=$(printf \ sidecar_script=$(printf \
'hb=%s; pid=%s; iv=%s; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \ 'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
"$hb_file" "$pane_pid" "$interval") "$hb_file" "$pane_pid" "$interval")
# setsid + disown ensures the sidecar survives this script exiting. # setsid + disown ensures the sidecar survives this script exiting.

View File

@@ -32,8 +32,15 @@ MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
"$START" "$AGENT" "$START" "$AGENT"
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created" tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}') # Retry: pane_current_path briefly reflects the tmux server's cwd until the pane
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir" # process establishes its own cwd (the -c start dir). Poll until it settles.
actual_dir=""
for _ in $(seq 1 30); do
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
[ "$actual_dir" = "$WORKDIR" ] && break
sleep 0.1
done
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)"
# ── Test 2: idempotency (duplicate start prints 'already running') ───────────── # ── Test 2: idempotency (duplicate start prints 'already running') ─────────────
MOSAIC_TMUX_SOCKET="$SOCKET" \ MOSAIC_TMUX_SOCKET="$SOCKET" \

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# check-resident-budget.sh — resident line-count ceiling (R9 / DESIGN §7).
#
# Budgets the *container* (line count) of the framework-owned files that are
# injected into every agent's context by value — the Constitution (L0), the
# AGENTS dispatcher, and each runtime RUNTIME.md slice. Gate *wording* is never
# capped (a word cap forces paraphrasing law — the exact drift vector P3 killed);
# only the file's line count is bounded, so prose creep is caught in review.
#
# This is the CI-enforceable half of the budget. The per-harness *total* resident
# prompt (which also includes user-generated SOUL.md/USER.md and the per-tier
# slice) is summed by `mosaic doctor` as a runtime advisory — CI cannot see user
# files, so it is deliberately out of scope here (DESIGN §7).
#
# Usage: check-resident-budget.sh [--self-test]
# Exit: 0 = all within budget · 1 = a file exceeds its ceiling · 2 = self-test failed
set -uo pipefail
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
# Per-file ceilings (lines). Headroom above current counts; tighten as files settle.
# Format: "<relative-path>:<max-lines>"
CEILINGS=(
"defaults/CONSTITUTION.md:120"
"defaults/AGENTS.md:120"
"runtime/claude/RUNTIME.md:90"
"runtime/codex/RUNTIME.md:90"
"runtime/opencode/RUNTIME.md:90"
"runtime/pi/RUNTIME.md:90"
)
# check_file <abs-path> <max> → echoes "<n>"; returns 0 if n<=max, 1 otherwise.
check_file() {
local path="$1" max="$2" n
n=$(wc -l <"$path" 2>/dev/null || echo 0)
n=$((n + 0))
echo "$n"
[ "$n" -le "$max" ]
}
run_budget() {
local fail=0 rel max abs n
printf '%-32s %8s %8s %s\n' "FILE" "LINES" "CEILING" "STATUS"
for entry in "${CEILINGS[@]}"; do
rel="${entry%%:*}"
max="${entry##*:}"
abs="$FW/$rel"
if [ ! -f "$abs" ]; then
printf '%-32s %8s %8s %s\n' "$rel" "-" "$max" "MISSING"
fail=1
continue
fi
n=$(check_file "$abs" "$max")
if [ "$n" -le "$max" ]; then
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "ok"
else
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "OVER BUDGET"
fail=1
fi
done
return "$fail"
}
self_test() {
local tmp rc
tmp=$(mktemp)
# 3 lines, ceiling 5 → within budget (rc 0)
printf 'a\nb\nc\n' >"$tmp"
check_file "$tmp" 5 >/dev/null
rc=$?
if [ "$rc" -ne 0 ]; then echo "self-test FAIL: under-budget file flagged"; rm -f "$tmp"; return 2; fi
# 6 lines, ceiling 5 → over budget (rc 1)
printf 'a\nb\nc\nd\ne\nf\n' >"$tmp"
check_file "$tmp" 5 >/dev/null
rc=$?
if [ "$rc" -ne 1 ]; then echo "self-test FAIL: over-budget file not flagged"; rm -f "$tmp"; return 2; fi
rm -f "$tmp"
echo "self-test OK"
return 0
}
if [ "${1:-}" = "--self-test" ]; then
self_test
exit $?
fi
if run_budget; then
echo "Resident budget: all framework-owned resident files within ceiling."
exit 0
else
echo "Resident budget EXCEEDED — trim prose or raise the ceiling deliberately (see DESIGN §7)." >&2
exit 1
fi

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/mosaic", "name": "@mosaicstack/mosaic",
"version": "0.0.36", "version": "0.0.39",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/stack.git",

View File

@@ -26,6 +26,10 @@ import {
checkForAllUpdates, checkForAllUpdates,
formatAllPackagesTable, formatAllPackagesTable,
getInstallAllCommand, getInstallAllCommand,
runFrameworkReseed,
readRosterAgentNames,
buildRelaunchCommands,
FRAMEWORK_RESEED_PACKAGE,
} from './runtime/update-checker.js'; } from './runtime/update-checker.js';
import { runWizard } from './wizard.js'; import { runWizard } from './wizard.js';
import { ClackPrompter } from './prompter/clack-prompter.js'; import { ClackPrompter } from './prompter/clack-prompter.js';
@@ -404,7 +408,12 @@ program
.command('update') .command('update')
.description('Check for and install Mosaic CLI updates') .description('Check for and install Mosaic CLI updates')
.option('--check', 'Check only, do not install') .option('--check', 'Check only, do not install')
.action(async (opts: { check?: boolean }) => { .option(
'--no-reseed',
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
)
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
// checkForAllUpdates imported statically above // checkForAllUpdates imported statically above
const { execSync } = await import('node:child_process'); const { execSync } = await import('node:child_process');
@@ -442,6 +451,51 @@ program
console.error('\nUpdate failed. Try manually: bash tools/install.sh'); console.error('\nUpdate failed. Try manually: bash tools/install.sh');
process.exit(1); process.exit(1);
} }
// F3-m3 / R13: the CLI is updated, but the framework files in
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
// Only when the framework-bearing package itself updated.
const mosaicUpdated = outdated.some(
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
);
if (mosaicUpdated && opts.reseed !== false) {
console.log(
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
);
const reseed = runFrameworkReseed();
if (reseed.ok) {
console.log('✔ Framework re-seeded.');
const agents = readRosterAgentNames();
if (agents.length > 0) {
if (opts.relaunch) {
console.log(
`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`,
);
for (const restart of buildRelaunchCommands(agents)) {
try {
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
} catch {
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
}
}
console.log('✔ Agents relaunched.');
} else {
console.log(
`\n ${agents.length} fleet agent(s) are still running the previous runtime. ` +
'Restart them to activate the update:\n mosaic update --relaunch ' +
'(or: mosaic fleet restart <agent>)',
);
}
}
} else {
console.error(
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
);
}
}
}); });
// ─── wizard ───────────────────────────────────────────────────────────── // ─── wizard ─────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { composeContract } from './launch.js';
/**
* Composer unit test (R7/R8/R9): asserts the launcher-composed runtime contract
*
* - includes the per-tier anchors (CONSTITUTION / AGENTS / USER / runtime),
* - keeps the CONSTITUTION block byte-equal to the on-disk file (Tier-3
* byte-equality — the bare-launch fallback read must match what is injected),
* - merges `*.local.md` operator overlays as deltas-by-value, and omits them
* entirely when absent (base-only),
* - selects the correct per-harness RUNTIME.md.
*
* `composeContract` takes `mosaicHome` as a param, so each test runs against an
* isolated fixture home. We also chdir to an empty temp cwd so the cwd-relative
* mission/PRD blocks contribute nothing (deterministic output).
*/
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
const USER = '# operator\n\nName: Test Operator\n';
const TOOLS = '# tools index\n';
function makeHome(): { home: string; root: string } {
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
const home = join(root, 'mosaic-home');
for (const h of ['claude', 'codex', 'opencode', 'pi']) {
mkdirSync(join(home, 'runtime', h), { recursive: true });
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
}
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
writeFileSync(join(home, 'USER.md'), USER);
writeFileSync(join(home, 'TOOLS.md'), TOOLS);
return { home, root };
}
describe('composeContract — overlay composer', () => {
let fixture: ReturnType<typeof makeHome>;
let prevCwd: string;
let cwdDir: string;
beforeEach(() => {
fixture = makeHome();
prevCwd = process.cwd();
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
});
afterEach(() => {
process.chdir(prevCwd);
rmSync(fixture.root, { recursive: true, force: true });
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
expect(out).toContain('Mosaic Agent Dispatcher'); // AGENTS
expect(out).toContain('# User Profile'); // USER header
expect(out).toContain('Name: Test Operator'); // USER body
expect(out).toContain('# Runtime-Specific Contract');
expect(out).toContain('# claude runtime contract');
});
it('keeps the CONSTITUTION block byte-equal to the on-disk file (Tier-3)', () => {
const out = composeContract('pi', fixture.home);
const onDisk = readFileSync(join(fixture.home, 'CONSTITUTION.md'), 'utf-8');
// The injected L0 must be a byte-equal substring of the composed blob, so a
// bare-launch fallback read of CONSTITUTION.md matches what was injected.
expect(out.includes(onDisk)).toBe(true);
});
it('is base-only when no *.local overlays exist', () => {
const out = composeContract('claude', fixture.home);
expect(out).not.toContain('# Operator Overlays');
expect(out).not.toContain('Operator Overlay (USER.local.md)');
expect(out).not.toContain('Persona Overlay');
expect(out).not.toContain('Standards Overlay');
});
it('merges USER.local.md directly under the operator profile', () => {
writeFileSync(join(fixture.home, 'USER.local.md'), 'Prefer terse status updates.\n');
const out = composeContract('claude', fixture.home);
expect(out).toContain('## Operator Overlay (USER.local.md)');
expect(out).toContain('Prefer terse status updates.');
// Overlay appears AFTER its base profile.
expect(out.indexOf('# User Profile')).toBeLessThan(
out.indexOf('## Operator Overlay (USER.local.md)'),
);
});
it('merges SOUL.local.md + STANDARDS.local.md as deltas in the Operator Overlays block', () => {
writeFileSync(join(fixture.home, 'SOUL.local.md'), 'Tone: dry and direct.\n');
writeFileSync(join(fixture.home, 'STANDARDS.local.md'), 'Require 90% coverage on auth code.\n');
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Operator Overlays');
expect(out).toContain('## Persona Overlay (SOUL.local.md)');
expect(out).toContain('Tone: dry and direct.');
expect(out).toContain('## Standards Overlay (STANDARDS.local.md)');
expect(out).toContain('Require 90% coverage on auth code.');
});
it('ignores whitespace-only *.local overlays (no empty overlay section)', () => {
writeFileSync(join(fixture.home, 'SOUL.local.md'), ' \n\n');
const out = composeContract('claude', fixture.home);
expect(out).not.toContain('# Operator Overlays');
});
it('selects a different RUNTIME.md per harness', () => {
expect(composeContract('codex', fixture.home)).toContain('# codex runtime contract');
expect(composeContract('pi', fixture.home)).toContain('# pi runtime contract');
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
});
});

View File

@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander'; import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
addAgentToRoster,
buildAgentSendCommand, buildAgentSendCommand,
buildAgentWatchAttachCommand, buildAgentWatchAttachCommand,
buildAgentWatchCommand, buildAgentWatchCommand,
@@ -13,11 +14,14 @@ import {
buildEnableLingerCommand, buildEnableLingerCommand,
buildFleetServiceCommand, buildFleetServiceCommand,
buildSystemdEnableCommand, buildSystemdEnableCommand,
buildSystemdDisableCommand,
socketArgs,
buildSystemdShowCommand, buildSystemdShowCommand,
buildTmuxListPanesCommand, buildTmuxListPanesCommand,
buildTmuxListSessionsCommand, buildTmuxListSessionsCommand,
classifySendResult, classifySendResult,
countOrchestrators, countOrchestrators,
countEnhancers,
detectDrift, detectDrift,
enableFleetUnits, enableFleetUnits,
FLEET_PROFILES, FLEET_PROFILES,
@@ -35,9 +39,11 @@ import {
parseTmuxListPanes, parseTmuxListPanes,
parseTmuxListSessions, parseTmuxListSessions,
registerFleetCommand, registerFleetCommand,
removeAgentFromRoster,
resolveFleetPaths, resolveFleetPaths,
resolvePresetFilename, resolvePresetFilename,
RUNTIME_ACCEPTABLE_COMMANDS, RUNTIME_ACCEPTABLE_COMMANDS,
serializeRosterToYaml,
VERIFY_DEFAULT_TIMEOUT_MS, VERIFY_DEFAULT_TIMEOUT_MS,
VERIFY_POLL_INTERVAL_MS, VERIFY_POLL_INTERVAL_MS,
type AgentPsRow, type AgentPsRow,
@@ -68,10 +74,12 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined(); expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'init', 'init',
'install', 'install',
'install-systemd', 'install-systemd',
'ps', 'ps',
'remove',
'restart', 'restart',
'start', 'start',
'status', 'status',
@@ -107,7 +115,7 @@ describe('fleet roster parsing', () => {
} }
}); });
it('defaults local canary rosters to the isolated mosaic-factory socket', async () => { it('defaults a socket-less roster to the literal default tmux socket (empty, no -L)', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml'); const rosterPath = join(cleanup, 'roster.yaml');
await writeFile( await writeFile(
@@ -124,12 +132,55 @@ describe('fleet roster parsing', () => {
const roster = await loadFleetRoster(rosterPath); const roster = await loadFleetRoster(rosterPath);
expect(roster.tmux.socketName).toBe('mosaic-factory'); expect(roster.tmux.socketName).toBe(''); // absent ⇒ default socket (no -L), not mosaic-factory
expect(roster.tmux.holderSession).toBe('_holder'); expect(roster.tmux.holderSession).toBe('_holder');
expect(roster.agents).toHaveLength(1); expect(roster.agents).toHaveLength(1);
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi'); expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
}); });
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
expect(socketArgs('mosaic-factory')).toEqual(['-L', 'mosaic-factory']);
expect(socketArgs('')).toEqual([]);
});
it('honors an explicit socket_name (renders -L) — containment for shipped presets', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'tmux:',
' socket_name: mosaic-factory',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
const roster = await loadFleetRoster(rosterPath);
expect(roster.tmux.socketName).toBe('mosaic-factory');
expect(buildTmuxListSessionsCommand(roster.tmux.socketName)).toContain('-L');
});
it('maps a per-agent model_hint into MOSAIC_AGENT_MODEL', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'pi', model_hint: 'openai-codex/gpt-5.5:high' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
const env = generateAgentEnv(roster, getRosterAgent(roster, 'coder0'));
expect(env).toContain('MOSAIC_AGENT_MODEL=openai-codex/gpt-5.5:high');
// socket-less roster ⇒ a bare empty socket (no quotes), so spawn uses no -L
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
});
it('generates deterministic per-agent EnvironmentFile content', async () => { it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json'); const rosterPath = join(cleanup, 'roster.json');
@@ -149,6 +200,7 @@ describe('fleet roster parsing', () => {
[ [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_MODEL=',
'MOSAIC_AGENT_WORKDIR=/srv/mosaic', 'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
'MOSAIC_TMUX_SOCKET=mosaic-factory', 'MOSAIC_TMUX_SOCKET=mosaic-factory',
'', '',
@@ -348,8 +400,9 @@ describe('fleet command construction', () => {
try { try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']); await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
expect(calls).toEqual([ expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'], // socket-less roster ⇒ default tmux socket (no -L)
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'], ['tmux', 'has-session', '-t', '=_holder:0.0'],
['tmux', 'has-session', '-t', '=coder0:0.0'],
]); ]);
} finally { } finally {
await rm(home, { recursive: true, force: true }); await rm(home, { recursive: true, force: true });
@@ -630,7 +683,7 @@ describe('fleet command construction', () => {
try { try {
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']); await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
expect(calls).toEqual([ expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'], ['tmux', 'has-session', '-t', '=json-agent:0.0'], // socket-less ⇒ no -L
]); ]);
} finally { } finally {
await rm(home, { recursive: true, force: true }); await rm(home, { recursive: true, force: true });
@@ -670,8 +723,6 @@ describe('fleet command construction', () => {
expect(calls).toEqual([ expect(calls).toEqual([
[ [
join(home, 'tools', 'tmux', 'agent-send.sh'), join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S', '-S',
getDefaultOperatorSourceLabel(), getDefaultOperatorSourceLabel(),
'-s', '-s',
@@ -720,8 +771,6 @@ describe('fleet command construction', () => {
expect(calls).toEqual([ expect(calls).toEqual([
[ [
join(home, 'tools', 'tmux', 'agent-send.sh'), join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S', '-S',
'lead:manual', 'lead:manual',
'-s', '-s',
@@ -804,9 +853,11 @@ describe('fleet ps — command construction', () => {
]); ]);
}); });
it('uses DEFAULT_SOCKET_NAME when socket is omitted from list-panes', () => { it('uses the default tmux socket (no -L) when socket is omitted from list-panes', () => {
const cmd = buildTmuxListPanesCommand('canary-pi'); const cmd = buildTmuxListPanesCommand('canary-pi');
expect(cmd[2]).toBe('mosaic-factory'); expect(cmd).not.toContain('-L'); // omitted socket ⇒ default socket
expect(cmd[0]).toBe('tmux');
expect(cmd[1]).toBe('list-panes');
}); });
it('derives heartbeat path under ~/.config/mosaic/fleet/run/', () => { it('derives heartbeat path under ~/.config/mosaic/fleet/run/', () => {
@@ -828,6 +879,17 @@ describe('fleet ps — heartbeat parsing', () => {
expect(hb.pid).toBe(12345); expect(hb.pid).toBe(12345);
expect(hb.status).toBe('ok'); expect(hb.status).toBe('ok');
expect(hb.ageMs).toBe(10_000); expect(hb.ageMs).toBe(10_000);
// No model= line in a legacy/sidecar heartbeat → model is null.
expect(hb.model).toBeNull();
});
it('parses a self-reported model id from a native heartbeat (model= line)', () => {
const ts = new Date(NOW - 5_000).toISOString();
const content = `ts=${ts}\npid=42\nstatus=busy\nmodel=openai-codex/gpt-5.5:high\n`;
const hb = parseHeartbeat(content, NOW);
expect(hb.model).toBe('openai-codex/gpt-5.5:high');
expect(hb.status).toBe('busy');
expect(hb.health).toBe('healthy');
}); });
it('reports stale when heartbeat is older than 3×interval', () => { it('reports stale when heartbeat is older than 3×interval', () => {
@@ -851,6 +913,23 @@ describe('fleet ps — heartbeat parsing', () => {
expect(hb.health).toBe('unknown'); expect(hb.health).toBe('unknown');
expect(hb.ts).toBeNull(); expect(hb.ts).toBeNull();
}); });
it('honors MOSAIC_HEARTBEAT_INTERVAL for the freshness threshold', () => {
const prev = process.env.MOSAIC_HEARTBEAT_INTERVAL;
try {
// A 60s-old beat is STALE at the default 15s interval (3x15 = 45s)...
const ts = new Date(NOW - 60_000).toISOString();
const content = `ts=${ts}\npid=1\nstatus=ok\n`;
delete process.env.MOSAIC_HEARTBEAT_INTERVAL;
expect(parseHeartbeat(content, NOW).health).toBe('stale');
// ...but HEALTHY when the operator widened the interval to 30s (3x30 = 90s).
process.env.MOSAIC_HEARTBEAT_INTERVAL = '30';
expect(parseHeartbeat(content, NOW).health).toBe('healthy');
} finally {
if (prev === undefined) delete process.env.MOSAIC_HEARTBEAT_INTERVAL;
else process.env.MOSAIC_HEARTBEAT_INTERVAL = prev;
}
});
}); });
describe('fleet ps — systemd show parsing', () => { describe('fleet ps — systemd show parsing', () => {
@@ -950,6 +1029,129 @@ 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', () => { describe('fleet install — auto-enable units for boot-survival', () => {
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => { it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([ expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
@@ -1173,8 +1375,9 @@ describe('fleet ps — command sequences issued', () => {
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']); await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
expect(calls).toEqual([ expect(calls).toEqual([
buildSystemdShowCommand('coder0'), buildSystemdShowCommand('coder0'),
buildTmuxListPanesCommand('coder0', 'mosaic-factory'), // socket-less roster ⇒ default socket (no -L)
buildTmuxListSessionsCommand('mosaic-factory'), buildTmuxListPanesCommand('coder0'),
buildTmuxListSessionsCommand(),
]); ]);
} finally { } finally {
console.log = origLog; console.log = origLog;
@@ -1195,9 +1398,10 @@ describe('buildTmuxListSessionsCommand', () => {
]); ]);
}); });
it('uses DEFAULT_SOCKET_NAME when socket is omitted', () => { it('uses the default tmux socket (no -L) when socket is omitted', () => {
const cmd = buildTmuxListSessionsCommand(); const cmd = buildTmuxListSessionsCommand();
expect(cmd[2]).toBe('mosaic-factory'); expect(cmd).not.toContain('-L');
expect(cmd).toEqual(['tmux', 'list-sessions', '-F', '#{session_name}']);
}); });
}); });
@@ -1475,9 +1679,10 @@ describe('agent watch', () => {
]); ]);
}); });
it('buildAgentWatchCommand (deprecated) still uses DEFAULT_SOCKET_NAME when socket is omitted', () => { it('buildAgentWatchCommand (deprecated) uses the default tmux socket (no -L) when socket is omitted', () => {
const cmd = buildAgentWatchCommand('canary-pi'); const cmd = buildAgentWatchCommand('canary-pi');
expect(cmd[2]).toBe('mosaic-factory'); expect(cmd).not.toContain('-L'); // omitted socket ⇒ default socket
expect(cmd[0]).toBe('tmux');
expect(cmd).toContain('-r'); expect(cmd).toContain('-r');
}); });
@@ -1671,9 +1876,10 @@ describe('agent send --verify', () => {
// 3 calls: BEFORE-capture, send, AFTER-capture (pane changed on first poll → accepted immediately) // 3 calls: BEFORE-capture, send, AFTER-capture (pane changed on first poll → accepted immediately)
expect(calls).toHaveLength(3); expect(calls).toHaveLength(3);
expect(calls[0]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5)); // socket-less roster ⇒ default socket (no -L)
expect(calls[0]).toEqual(buildAgentVerifyAcceptedCommand('coder0', '', 5));
expect(calls[1]![0]).toContain('agent-send.sh'); expect(calls[1]![0]).toContain('agent-send.sh');
expect(calls[2]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5)); expect(calls[2]).toEqual(buildAgentVerifyAcceptedCommand('coder0', '', 5));
} finally { } finally {
await rm(home, { recursive: true, force: true }); await rm(home, { recursive: true, force: true });
} }
@@ -2155,47 +2361,63 @@ describe('fleet preset rosters', () => {
}, },
); );
it('general preset: orchestrator + one generalist worker', async () => { it('general preset: orchestrator + enhancer + one generalist worker', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml')); const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']); expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer', 'generalist']);
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude'); 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'); expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
}); });
it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => { it('coding preset: orchestrator + enhancer + coder0 + coder1 + reviewer', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml')); const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([ expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator', 'orchestrator',
'enhancer',
'coder0', 'coder0',
'coder1', 'coder1',
'reviewer', 'reviewer',
]); ]);
}); });
it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => { it('research preset: orchestrator + enhancer + researcher0 + researcher1 + analyst', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml')); const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([ expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator', 'orchestrator',
'enhancer',
'researcher0', 'researcher0',
'researcher1', 'researcher1',
'analyst', 'analyst',
]); ]);
}); });
it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => { it('hybrid preset: orchestrator + enhancer + coder0 + researcher0 + reviewer', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml')); const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([ expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator', 'orchestrator',
'enhancer',
'coder0', 'coder0',
'researcher0', 'researcher0',
'reviewer', '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 () => { 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[]) { for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`)); const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
const workers = roster.agents.filter((a) => a.name !== 'orchestrator'); // Core agents (orchestrator + enhancer) run claude; only ephemeral workers are pi.
const workers = roster.agents.filter(
(a) => a.className !== 'orchestrator' && a.className !== 'enhancer',
);
for (const worker of workers) { for (const worker of workers) {
expect(worker.runtime).toBe('pi'); expect(worker.runtime).toBe('pi');
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high'); expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
@@ -2254,6 +2476,509 @@ describe('resolvePresetFilename', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// Fleet Phase F5: orchestrator-mutable fleet — pure helper tests (R9)
// ---------------------------------------------------------------------------
describe('fleet add/remove — pure helpers', () => {
const baseRoster: FleetRoster = {
version: 1,
transport: 'tmux',
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' },
defaults: { workingDirectory: '~/src' },
runtimes: { codex: { resetCommand: '/clear' } },
agents: [
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
{ name: 'coder0', runtime: 'codex', className: 'worker' },
],
};
it('addAgentToRoster appends a new agent and returns a new roster object', () => {
const newAgent = { name: 'reviewer0', runtime: 'pi', className: 'worker' };
const updated = addAgentToRoster(baseRoster, newAgent);
expect(updated.agents).toHaveLength(3);
expect(updated.agents[2]).toEqual(newAgent);
// immutable — original unchanged
expect(baseRoster.agents).toHaveLength(2);
expect(updated).not.toBe(baseRoster);
});
it('addAgentToRoster throws on duplicate name', () => {
expect(() =>
addAgentToRoster(baseRoster, { name: 'coder0', runtime: 'claude', className: 'worker' }),
).toThrow('Agent "coder0" already exists in the fleet roster.');
});
it('addAgentToRoster throws on invalid name (invalid characters)', () => {
expect(() =>
addAgentToRoster(baseRoster, { name: 'bad name!', runtime: 'claude', className: 'worker' }),
).toThrow('Invalid fleet agent name');
});
it('addAgentToRoster throws on empty name', () => {
expect(() =>
addAgentToRoster(baseRoster, { name: '', runtime: 'claude', className: 'worker' }),
).toThrow('Invalid fleet agent name');
});
it('removeAgentFromRoster removes the agent and returns new roster', () => {
const updated = removeAgentFromRoster(baseRoster, 'coder0');
expect(updated.agents).toHaveLength(1);
expect(updated.agents[0]!.name).toBe('orchestrator');
// immutable
expect(baseRoster.agents).toHaveLength(2);
expect(updated).not.toBe(baseRoster);
});
it('removeAgentFromRoster throws when agent not found', () => {
expect(() => removeAgentFromRoster(baseRoster, 'nonexistent')).toThrow(
'Agent "nonexistent" is not in the fleet roster.',
);
});
it('removeAgentFromRoster throws when removing the sole orchestrator (guard)', () => {
const rosterWithOnlyOrch: FleetRoster = {
...baseRoster,
agents: [{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' }],
};
expect(() => removeAgentFromRoster(rosterWithOnlyOrch, 'orchestrator')).toThrow(
'sole orchestrator',
);
});
it('removeAgentFromRoster allows removing an orchestrator when another remains', () => {
const rosterWithTwoOrchs: FleetRoster = {
...baseRoster,
agents: [
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
{ name: 'orchestrator2', runtime: 'claude', className: 'orchestrator' },
{ name: 'coder0', runtime: 'codex', className: 'worker' },
],
};
const updated = removeAgentFromRoster(rosterWithTwoOrchs, 'orchestrator');
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');
expect(yaml).toContain('version: 1');
expect(yaml).toContain('name: orchestrator');
expect(yaml).toContain('name: coder0');
// Round-trip: write to disk and re-load
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(dir, 'roster.yaml');
try {
await writeFile(rosterPath, yaml);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.agents.map((a) => a.name)).toEqual(['orchestrator', 'coder0']);
expect(loaded.tmux.socketName).toBe('mosaic-factory');
expect(loaded.agents[0]!.className).toBe('orchestrator');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
const rosterWithOptionals: FleetRoster = {
...baseRoster,
agents: [
{
name: 'orchestrator',
runtime: 'claude',
className: 'orchestrator',
modelHint: 'claude-3-5-sonnet',
workingDirectory: '/tmp/work',
persistentPersona: true,
resetBetweenTasks: false,
},
],
};
const yaml = serializeRosterToYaml(rosterWithOptionals);
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
expect(yaml).toContain('working_directory: /tmp/work');
expect(yaml).toContain('persistent_persona: true');
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(dir, 'roster.yaml');
try {
await writeFile(rosterPath, yaml);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
expect(loaded.agents[0]!.persistentPersona).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// Fleet Phase F5: fleet add command tests
// ---------------------------------------------------------------------------
describe('fleet add command', () => {
let home: string;
afterEach(async () => {
if (home) {
await rm(home, { recursive: true, force: true });
}
});
async function makeHome(agents = ['orchestrator']): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
const agentLines = agents.map((name) => {
const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
return ` - name: ${name}\n runtime: claude\n class: ${cls}`;
});
await writeFile(
join(dir, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ...agentLines].join('\n'),
);
return dir;
}
it('appends agent to roster file and writes env file', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'codex',
'--class',
'worker',
]);
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
expect(roster.agents.map((a) => a.name)).toContain('coder0');
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
});
it('--no-start skips the start command', async () => {
home = await makeHome();
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 });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'codex',
'--class',
'worker',
'--no-start',
]);
// No start command should have been issued
const startCalls = calls.filter((c) => c.includes('start'));
expect(startCalls).toHaveLength(0);
});
it('without --no-start, issues start command for the new agent', async () => {
home = await makeHome();
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 });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'codex',
'--class',
'worker',
]);
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']);
});
it('throws when adding a duplicate agent name', async () => {
home = await makeHome(['orchestrator', 'coder0']);
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'codex',
'--class',
'worker',
]),
).rejects.toThrow('already exists');
});
it('throws when runtime is invalid', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'notaruntime',
'--class',
'worker',
]),
).rejects.toThrow('Invalid runtime');
});
it('accepts optional --model and --working-dir options', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'claude',
'--class',
'worker',
'--model',
'claude-sonnet',
'--working-dir',
'/tmp/work',
]);
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
const agent = roster.agents.find((a) => a.name === 'coder0');
expect(agent?.modelHint).toBe('claude-sonnet');
expect(agent?.workingDirectory).toBe('/tmp/work');
});
});
// ---------------------------------------------------------------------------
// Fleet Phase F5: fleet remove command tests
// ---------------------------------------------------------------------------
describe('fleet remove command', () => {
let home: string;
afterEach(async () => {
if (home) {
await rm(home, { recursive: true, force: true });
}
});
async function makeHome(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-remove-'));
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
await mkdir(join(dir, 'fleet', 'run'), { recursive: true });
await writeFile(
join(dir, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: orchestrator',
' runtime: claude',
' class: orchestrator',
' - name: coder0',
' runtime: codex',
' class: worker',
].join('\n'),
);
// Create env and heartbeat files for coder0
await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n');
await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
return dir;
}
it('removes agent from roster and writes back', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
expect(roster.agents.map((a) => a.name)).not.toContain('coder0');
expect(roster.agents.map((a) => a.name)).toContain('orchestrator');
});
it('stop is called before roster write (stop is the first runner call)', async () => {
home = await makeHome();
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 });
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
expect(calls[0]).toEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
});
it('stop failure is non-fatal — warns but still removes from roster', async () => {
home = await makeHome();
const stderrMessages: string[] = [];
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
stderrMessages.push(String(msg));
return true;
});
const runner: CommandRunner = async (command, args) => {
if (args.includes('stop')) {
return { stdout: '', stderr: 'unit not found', exitCode: 5 };
}
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
// Must not reject
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']),
).resolves.toBeDefined();
// Agent should be removed from roster despite stop failure
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
expect(roster.agents.map((a) => a.name)).not.toContain('coder0');
// Warning must have been emitted
expect(stderrMessages.join('')).toMatch(/Warning/);
} finally {
stderrSpy.mockRestore();
}
});
it('--keep-files skips env file deletion', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);
// Env file should still exist
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
});
it('env file is removed by default (no --keep-files)', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow();
});
it('removing the sole orchestrator throws with a clear error about the guard', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
// First remove the worker so only the orchestrator remains
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
// Now attempt to remove the sole orchestrator
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'orchestrator']),
).rejects.toThrow('sole orchestrator');
});
});
describe('fleet init wizard', () => { describe('fleet init wizard', () => {
let cleanup: string | undefined; let cleanup: string | undefined;
@@ -2404,3 +3129,33 @@ describe('fleet init wizard', () => {
expect(content).toContain('name: coder0'); expect(content).toContain('name: coder0');
}); });
}); });
describe('fleet ps — heartbeat path resolution', () => {
const savedRunDir = process.env.MOSAIC_HEARTBEAT_RUN_DIR;
const savedHome = process.env.MOSAIC_HOME;
afterEach(() => {
if (savedRunDir === undefined) delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
else process.env.MOSAIC_HEARTBEAT_RUN_DIR = savedRunDir;
if (savedHome === undefined) delete process.env.MOSAIC_HOME;
else process.env.MOSAIC_HOME = savedHome;
});
it('honors MOSAIC_HEARTBEAT_RUN_DIR (matches the writer sidecar override)', () => {
process.env.MOSAIC_HEARTBEAT_RUN_DIR = '/run/hb';
expect(heartbeatPath('agent-x', '/any/home')).toBe(join('/run/hb', 'agent-x.hb'));
});
it('honors MOSAIC_HOME when no explicit mosaicHome is given', () => {
delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
process.env.MOSAIC_HOME = '/custom/mhome';
expect(heartbeatPath('agent-y')).toBe(join('/custom/mhome', 'fleet', 'run', 'agent-y.hb'));
});
it('falls back to <mosaicHome>/fleet/run by default', () => {
delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
delete process.env.MOSAIC_HOME;
expect(heartbeatPath('agent-z', '/home/u/.config/mosaic')).toBe(
join('/home/u/.config/mosaic', 'fleet', 'run', 'agent-z.hb'),
);
});
});

View File

@@ -1,5 +1,5 @@
import { constants } from 'node:fs'; import { constants } from 'node:fs';
import { access, chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; import { access, chmod, copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { homedir, hostname, userInfo } from 'node:os'; import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -117,10 +117,26 @@ export interface FleetPaths {
type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status'; type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
const DEFAULT_SOCKET_NAME = 'mosaic-factory'; /**
* The named tmux socket the canonical fleet uses. Kept as a public constant for
* rosters/callers that explicitly want isolation; it is NO LONGER the silent
* fallback for a socket-less roster (that now resolves to the default socket).
*/
export const DEFAULT_SOCKET_NAME = 'mosaic-factory';
const DEFAULT_HOLDER_SESSION = '_holder'; const DEFAULT_HOLDER_SESSION = '_holder';
const DEFAULT_WORKING_DIRECTORY = '~/src'; const DEFAULT_WORKING_DIRECTORY = '~/src';
/**
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
* tmux socket (no `-L`), so spawn, observe (`fleet ps`/watch), and the onboarding
* cheat-sheet all agree. A named socket ⇒ `-L <name>`. `DEFAULT_SOCKET_NAME`
* remains a constant for callers that explicitly want mosaic-factory; it is no
* longer the silent fallback for a socket-less roster.
*/
export function socketArgs(socketName: string): string[] {
return socketName ? ['-L', socketName] : [];
}
/** /**
* Default poll interval (ms) between capture-pane checks in `send --verify`. * Default poll interval (ms) between capture-pane checks in `send --verify`.
* Kept short enough to react quickly while not hammering tmux on busy hosts. * Kept short enough to react quickly while not hammering tmux on busy hosts.
@@ -152,13 +168,16 @@ export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths
} }
function defaultMosaicHome(): string { function defaultMosaicHome(): string {
return join(homedir(), '.config', 'mosaic'); // Honor MOSAIC_HOME so the reader matches the writer sidecar (and the launcher),
// even when MOSAIC_HOME is set in the shell without an explicit --mosaic-home flag.
return process.env.MOSAIC_HOME ?? join(homedir(), '.config', 'mosaic');
} }
function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void { function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
if (resolve(mosaicHome) !== resolve(defaultMosaicHome())) { const literalHome = join(homedir(), '.config', 'mosaic');
if (resolve(mosaicHome) !== resolve(literalHome)) {
throw new Error( throw new Error(
`install-systemd only supports the default Mosaic home (${defaultMosaicHome()}) because the user systemd units use %h/.config/mosaic paths.`, `install-systemd only supports the default Mosaic home (${literalHome}) because the user systemd units use %h/.config/mosaic paths.`,
); );
} }
} }
@@ -182,6 +201,10 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string
return [ return [
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`, `MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`, `MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
// Per-agent model hint → start-agent-session.sh appends `--model <hint>` to
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`, `MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`, `MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
'', '',
@@ -224,6 +247,15 @@ export function buildSystemdEnableCommand(unit: string): string[] {
return ['systemctl', '--user', 'enable', unit]; 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. * Returns the loginctl enable-linger command for a given user.
* Linger allows user systemd services to survive logout. * Linger allows user systemd services to survive logout.
@@ -307,13 +339,12 @@ export function buildAgentSendCommand(
paths: FleetPaths, paths: FleetPaths,
agentName: string, agentName: string,
message: string, message: string,
socketName = DEFAULT_SOCKET_NAME, socketName = '',
sourceLabel = getDefaultOperatorSourceLabel(), sourceLabel = getDefaultOperatorSourceLabel(),
): string[] { ): string[] {
return [ return [
join(paths.tmuxToolsDir, 'agent-send.sh'), join(paths.tmuxToolsDir, 'agent-send.sh'),
'-L', ...socketArgs(socketName),
socketName,
'-S', '-S',
sourceLabel, sourceLabel,
'-s', '-s',
@@ -332,12 +363,11 @@ export function buildAgentResetCommand(
paths: FleetPaths, paths: FleetPaths,
agentName: string, agentName: string,
resetCommand: string, resetCommand: string,
socketName = DEFAULT_SOCKET_NAME, socketName = '',
): string[] { ): string[] {
return [ return [
join(paths.tmuxToolsDir, 'send-message.sh'), join(paths.tmuxToolsDir, 'send-message.sh'),
'-L', ...socketArgs(socketName),
socketName,
'-t', '-t',
`=${agentName}`, `=${agentName}`,
'-m', '-m',
@@ -345,15 +375,10 @@ export function buildAgentResetCommand(
]; ];
} }
export function buildAgentTailCommand( export function buildAgentTailCommand(agentName: string, lines: number, socketName = ''): string[] {
agentName: string,
lines: number,
socketName = DEFAULT_SOCKET_NAME,
): string[] {
return [ return [
'tmux', 'tmux',
'-L', ...socketArgs(socketName),
socketName,
'capture-pane', 'capture-pane',
'-t', '-t',
`=${agentName}:0.0`, `=${agentName}:0.0`,
@@ -368,6 +393,16 @@ export function buildAgentTailCommand(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const HEARTBEAT_INTERVAL_MS = 15_000; export const HEARTBEAT_INTERVAL_MS = 15_000;
/**
* Heartbeat interval in ms, honoring MOSAIC_HEARTBEAT_INTERVAL (seconds) so the
* `fleet ps` freshness threshold matches the writer sidecar's actual cadence
* (start-agent-session.sh). Falls back to HEARTBEAT_INTERVAL_MS (15s).
*/
export function heartbeatIntervalMs(): number {
const sec = Number.parseInt(process.env.MOSAIC_HEARTBEAT_INTERVAL ?? '', 10);
return Number.isFinite(sec) && sec > 0 ? sec * 1000 : HEARTBEAT_INTERVAL_MS;
}
export const HEARTBEAT_HEALTHY_MULTIPLIER = 3; export const HEARTBEAT_HEALTHY_MULTIPLIER = 3;
export interface HeartbeatInfo { export interface HeartbeatInfo {
@@ -377,6 +412,8 @@ export interface HeartbeatInfo {
/** healthy | stale | unknown */ /** healthy | stale | unknown */
health: 'healthy' | 'stale' | 'unknown'; health: 'healthy' | 'stale' | 'unknown';
ageMs: number | null; ageMs: number | null;
/** Model id the runtime self-reported in its heartbeat (native HB only), else null. */
model: string | null;
} }
export interface AgentPsRow { export interface AgentPsRow {
@@ -425,14 +462,10 @@ export function buildSystemdShowCommand(agentName: string): string[] {
* Returns the tmux list-panes command for an agent pane. * Returns the tmux list-panes command for an agent pane.
* Format: `#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}` * Format: `#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}`
*/ */
export function buildTmuxListPanesCommand( export function buildTmuxListPanesCommand(agentName: string, socketName = ''): string[] {
agentName: string,
socketName = DEFAULT_SOCKET_NAME,
): string[] {
return [ return [
'tmux', 'tmux',
'-L', ...socketArgs(socketName),
socketName,
'list-panes', 'list-panes',
'-t', '-t',
`=${agentName}:0.0`, `=${agentName}:0.0`,
@@ -446,8 +479,8 @@ export function buildTmuxListPanesCommand(
* Format: `tmux -L <socket> list-sessions -F '#{session_name}'` * Format: `tmux -L <socket> list-sessions -F '#{session_name}'`
* Used to discover ad-hoc sessions that are not in the roster. * Used to discover ad-hoc sessions that are not in the roster.
*/ */
export function buildTmuxListSessionsCommand(socketName = DEFAULT_SOCKET_NAME): string[] { export function buildTmuxListSessionsCommand(socketName = ''): string[] {
return ['tmux', '-L', socketName, 'list-sessions', '-F', '#{session_name}']; return ['tmux', ...socketArgs(socketName), 'list-sessions', '-F', '#{session_name}'];
} }
/** /**
@@ -465,7 +498,10 @@ export function parseTmuxListSessions(output: string): string[] {
* Returns the heartbeat file path for an agent. * Returns the heartbeat file path for an agent.
*/ */
export function heartbeatPath(agentName: string, mosaicHome = defaultMosaicHome()): string { export function heartbeatPath(agentName: string, mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'run', `${agentName}.hb`); // Honor MOSAIC_HEARTBEAT_RUN_DIR (the writer sidecar's override); otherwise the
// canonical <mosaicHome>/fleet/run. Keeps reader and writer on the same path.
const runDir = process.env.MOSAIC_HEARTBEAT_RUN_DIR ?? join(mosaicHome, 'fleet', 'run');
return join(runDir, `${agentName}.hb`);
} }
/** /**
@@ -474,15 +510,17 @@ export function heartbeatPath(agentName: string, mosaicHome = defaultMosaicHome(
* ts=<iso8601> * ts=<iso8601>
* pid=<pid> * pid=<pid>
* status=<ok|busy> * status=<ok|busy>
* model=<model-id> (optional — native runtime heartbeats self-report it)
*/ */
export function parseHeartbeat(content: string | null, nowMs = Date.now()): HeartbeatInfo { export function parseHeartbeat(content: string | null, nowMs = Date.now()): HeartbeatInfo {
if (content === null) { if (content === null) {
return { ts: null, pid: null, status: null, health: 'unknown', ageMs: null }; return { ts: null, pid: null, status: null, health: 'unknown', ageMs: null, model: null };
} }
const lines = content.split('\n'); const lines = content.split('\n');
let ts: Date | null = null; let ts: Date | null = null;
let pid: number | null = null; let pid: number | null = null;
let status: 'ok' | 'busy' | null = null; let status: 'ok' | 'busy' | null = null;
let model: string | null = null;
for (const line of lines) { for (const line of lines) {
const [key, ...rest] = line.split('='); const [key, ...rest] = line.split('=');
const val = rest.join('=').trim(); const val = rest.join('=').trim();
@@ -494,16 +532,18 @@ export function parseHeartbeat(content: string | null, nowMs = Date.now()): Hear
if (Number.isFinite(n)) pid = n; if (Number.isFinite(n)) pid = n;
} else if (key === 'status' && (val === 'ok' || val === 'busy')) { } else if (key === 'status' && (val === 'ok' || val === 'busy')) {
status = val; status = val;
} else if (key === 'model' && val) {
model = val;
} }
} }
const thresholdMs = HEARTBEAT_INTERVAL_MS * HEARTBEAT_HEALTHY_MULTIPLIER; const thresholdMs = heartbeatIntervalMs() * HEARTBEAT_HEALTHY_MULTIPLIER;
let health: 'healthy' | 'stale' | 'unknown' = 'unknown'; let health: 'healthy' | 'stale' | 'unknown' = 'unknown';
let ageMs: number | null = null; let ageMs: number | null = null;
if (ts !== null) { if (ts !== null) {
ageMs = nowMs - ts.getTime(); ageMs = nowMs - ts.getTime();
health = ageMs <= thresholdMs ? 'healthy' : 'stale'; health = ageMs <= thresholdMs ? 'healthy' : 'stale';
} }
return { ts, pid, status, health, ageMs }; return { ts, pid, status, health, ageMs, model };
} }
/** /**
@@ -622,12 +662,11 @@ export function getDefaultTenantAndHost(): { tenant_id: string; host: string } {
export function buildAgentWatchCreateViewerCommand( export function buildAgentWatchCreateViewerCommand(
agentName: string, agentName: string,
viewerSessionName: string, viewerSessionName: string,
socketName = DEFAULT_SOCKET_NAME, socketName = '',
): string[] { ): string[] {
return [ return [
'tmux', 'tmux',
'-L', ...socketArgs(socketName),
socketName,
'new-session', 'new-session',
'-d', '-d',
'-t', '-t',
@@ -641,11 +680,8 @@ export function buildAgentWatchCreateViewerCommand(
* Builds the interactive attach command for a viewer session (read-only). * Builds the interactive attach command for a viewer session (read-only).
* Must be run via interactiveRunner (stdio: 'inherit'). * Must be run via interactiveRunner (stdio: 'inherit').
*/ */
export function buildAgentWatchAttachCommand( export function buildAgentWatchAttachCommand(viewerSessionName: string, socketName = ''): string[] {
viewerSessionName: string, return ['tmux', ...socketArgs(socketName), 'attach', '-r', '-t', viewerSessionName];
socketName = DEFAULT_SOCKET_NAME,
): string[] {
return ['tmux', '-L', socketName, 'attach', '-r', '-t', viewerSessionName];
} }
/** /**
@@ -654,9 +690,9 @@ export function buildAgentWatchAttachCommand(
*/ */
export function buildAgentWatchKillViewerCommand( export function buildAgentWatchKillViewerCommand(
viewerSessionName: string, viewerSessionName: string,
socketName = DEFAULT_SOCKET_NAME, socketName = '',
): string[] { ): string[] {
return ['tmux', '-L', socketName, 'kill-session', '-t', viewerSessionName]; return ['tmux', ...socketArgs(socketName), 'kill-session', '-t', viewerSessionName];
} }
/** /**
@@ -674,11 +710,8 @@ export function buildViewerSessionName(agentName: string): string {
* *
* Kept for backward compatibility only. * Kept for backward compatibility only.
*/ */
export function buildAgentWatchCommand( export function buildAgentWatchCommand(agentName: string, socketName = ''): string[] {
agentName: string, return ['tmux', ...socketArgs(socketName), 'attach', '-r', '-t', `=${agentName}`];
socketName = DEFAULT_SOCKET_NAME,
): string[] {
return ['tmux', '-L', socketName, 'attach', '-r', '-t', `=${agentName}`];
} }
/** /**
@@ -688,13 +721,12 @@ export function buildAgentWatchCommand(
*/ */
export function buildAgentVerifyAcceptedCommand( export function buildAgentVerifyAcceptedCommand(
agentName: string, agentName: string,
socketName = DEFAULT_SOCKET_NAME, socketName = '',
lines = 5, lines = 5,
): string[] { ): string[] {
return [ return [
'tmux', 'tmux',
'-L', ...socketArgs(socketName),
socketName,
'capture-pane', 'capture-pane',
'-t', '-t',
`=${agentName}:0.0`, `=${agentName}:0.0`,
@@ -850,20 +882,33 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
await mkdir(dirname(destination), { recursive: true }); await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content); await writeFile(destination, content);
// Validate: exactly one orchestrator required (R5) — friendly summary on success. // 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.
const written = await loadFleetRoster(destination); const written = await loadFleetRoster(destination);
const orchCount = countOrchestrators(written); const orchCount = countOrchestrators(written);
if (orchCount !== 1) { const enhancerCount = countEnhancers(written);
process.stderr.write( if (profile === 'minimal') {
`Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`,
);
console.log( console.log(
`Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`, `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.`,
); );
} else { } else {
const workerCount = written.agents.length - 1; const workerCount = written.agents.length - 1 - enhancerCount;
console.log( console.log(
`Initialized ${profile} fleet: 1 orchestrator + ${workerCount} agent(s). Next: mosaic fleet install`, `Initialized ${profile} fleet: 1 orchestrator + ${enhancerCount} enhancer(s) + ` +
`${workerCount} worker(s). Next: mosaic fleet install`,
); );
} }
}); });
@@ -945,8 +990,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
const socketName = roster.tmux.socketName; const socketName = roster.tmux.socketName;
await runChecked(runner, [ await runChecked(runner, [
'tmux', 'tmux',
'-L', ...socketArgs(socketName),
socketName,
'has-session', 'has-session',
'-t', '-t',
`=${roster.tmux.holderSession}:0.0`, `=${roster.tmux.holderSession}:0.0`,
@@ -954,8 +998,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
for (const agent of roster.agents) { for (const agent of roster.agents) {
await runChecked(runner, [ await runChecked(runner, [
'tmux', 'tmux',
'-L', ...socketArgs(socketName),
socketName,
'has-session', 'has-session',
'-t', '-t',
`=${agent.name}:0.0`, `=${agent.name}:0.0`,
@@ -1107,6 +1150,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
'PID'.padEnd(8), 'PID'.padEnd(8),
'IDLE'.padEnd(8), 'IDLE'.padEnd(8),
'HB'.padEnd(12), 'HB'.padEnd(12),
'MODEL'.padEnd(22),
'FLAGS', 'FLAGS',
].join(' '); ].join(' ');
console.log(header); console.log(header);
@@ -1121,6 +1165,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
row.heartbeat.ageMs !== null row.heartbeat.ageMs !== null
? `${Math.round(row.heartbeat.ageMs / 1000)}s/${row.heartbeat.health}` ? `${Math.round(row.heartbeat.ageMs / 1000)}s/${row.heartbeat.health}`
: `unknown`; : `unknown`;
const model = row.heartbeat.model ?? '-';
const flags: string[] = []; const flags: string[] = [];
if (!row.managed) flags.push('UNMANAGED'); if (!row.managed) flags.push('UNMANAGED');
if (row.driftFlag) flags.push('DRIFT'); if (row.driftFlag) flags.push('DRIFT');
@@ -1137,12 +1182,157 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
pid.padEnd(8), pid.padEnd(8),
idle.padEnd(8), idle.padEnd(8),
hbAge.padEnd(12), hbAge.padEnd(12),
model.padEnd(22),
flags.join(','), flags.join(','),
].join(' '), ].join(' '),
); );
} }
}); });
cmd
.command('add <name>')
.description('Add a new agent to the fleet roster and optionally start it')
.requiredOption('--runtime <runtime>', `Agent runtime (${VALID_FLEET_RUNTIMES.join(', ')})`)
.requiredOption('--class <class>', 'Agent class (e.g. worker, orchestrator, canary)')
.option('--model <hint>', 'Model hint for the agent')
.option('--working-dir <path>', 'Working directory for the agent')
.option('--no-start', 'Skip starting the agent after adding')
.action(
async (
name: string,
opts: {
runtime: string;
class: string;
model?: string;
workingDir?: string;
start: boolean;
},
) => {
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
throw new Error(
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
);
}
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
const roster = await loadFleetRoster(rosterPath);
const newAgent: FleetAgent = {
name,
runtime: opts.runtime,
className: opts.class,
...(opts.workingDir !== undefined && { workingDirectory: opts.workingDir }),
...(opts.model !== undefined && { modelHint: opts.model }),
};
const updatedRoster = addAgentToRoster(roster, newAgent);
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
const envPath = join(activePaths.agentEnvDir, `${name}.env`);
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
await mkdir(activePaths.agentEnvDir, { recursive: true });
await writeFile(
envPath,
mergeAgentEnv(generateAgentEnv(updatedRoster, newAgent), existingEnv),
);
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.`);
} else {
console.log(`Agent queued (--no-start); run: mosaic fleet start ${name}`);
}
},
);
cmd
.command('remove <name>')
.description('Remove an agent from the fleet roster')
.option('--keep-files', 'Skip deleting env and heartbeat files')
.action(async (name: string, opts: { keepFiles?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
const roster = await loadFleetRoster(rosterPath);
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
const updatedRoster = removeAgentFromRoster(roster, name);
// Stop agent (non-fatal)
try {
const stopResult = await runner(...splitCommand(buildFleetServiceCommand('stop', name)));
if (stopResult.exitCode !== 0) {
process.stderr.write(
`Warning: could not stop mosaic-agent@${name}.service: ${stopResult.stderr || stopResult.stdout || 'non-zero exit'}\n`,
);
}
} catch (err) {
process.stderr.write(
`Warning: stop command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
// 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));
// Delete env and heartbeat files (best-effort, non-fatal)
if (!opts.keepFiles) {
try {
await unlink(join(activePaths.agentEnvDir, `${name}.env`));
} catch {
// best-effort
}
try {
await unlink(heartbeatPath(name, activePaths.mosaicHome));
} catch {
// best-effort
}
}
console.log(`Removed ${name} from the fleet.`);
});
return cmd; return cmd;
} }
@@ -1179,8 +1369,8 @@ export function registerFleetAgentCommands(
getRosterAgent(roster, agent); getRosterAgent(roster, agent);
} }
const command = agent const command = agent
? ['tmux', '-L', roster.tmux.socketName, 'has-session', '-t', `=${agent}:0.0`] ? ['tmux', ...socketArgs(roster.tmux.socketName), 'has-session', '-t', `=${agent}:0.0`]
: ['tmux', '-L', roster.tmux.socketName, 'ls']; : ['tmux', ...socketArgs(roster.tmux.socketName), 'ls'];
const result = await runner(...splitCommand(command)); const result = await runner(...splitCommand(command));
if (opts.json) { if (opts.json) {
console.log( console.log(
@@ -1322,15 +1512,19 @@ export function registerFleetAgentCommands(
await runChecked(runner, buildAgentWatchCreateViewerCommand(agent, viewerName, socketName)); await runChecked(runner, buildAgentWatchCreateViewerCommand(agent, viewerName, socketName));
let exitCode = 0;
try {
const [bin, args] = splitCommand(buildAgentWatchAttachCommand(viewerName, socketName)); const [bin, args] = splitCommand(buildAgentWatchAttachCommand(viewerName, socketName));
const exitCode = await iRunner(bin, args); exitCode = await iRunner(bin, args);
} finally {
// Best-effort cleanup of the viewer session regardless of how the user detached. // ALWAYS clean up the viewer session — even if attach threw or the process was
// Errors here are intentionally suppressed — the agent session is unaffected. // interrupted — so stale grouped *-watch-* sessions never accumulate. Errors here
// are intentionally suppressed; the agent session is unaffected.
const killResult = await runner( const killResult = await runner(
...splitCommand(buildAgentWatchKillViewerCommand(viewerName, socketName)), ...splitCommand(buildAgentWatchKillViewerCommand(viewerName, socketName)),
); );
void killResult; // result is intentionally ignored void killResult;
}
if (exitCode !== 0) { if (exitCode !== 0) {
process.exitCode = exitCode; process.exitCode = exitCode;
@@ -1494,9 +1688,12 @@ function normalizeRoster(raw: RawFleetRoster): FleetRoster {
version: 1, version: 1,
transport: 'tmux', transport: 'tmux',
tmux: { tmux: {
// Absent socket_name ⇒ '' (the literal default tmux socket, no -L) — NOT
// mosaic-factory. Shipped presets set socket_name explicitly, so they are
// unaffected; only socket-less rosters get default-socket behavior.
socketName: stringValue( socketName: stringValue(
raw.tmux?.socket_name ?? raw.tmux?.socketName, raw.tmux?.socket_name ?? raw.tmux?.socketName,
DEFAULT_SOCKET_NAME, '',
'Fleet roster tmux socket_name', 'Fleet roster tmux socket_name',
), ),
holderSession: stringValue( holderSession: stringValue(
@@ -1662,6 +1859,12 @@ function expandHome(path: string): string {
} }
function shellEnvValue(value: string): string { function shellEnvValue(value: string): string {
// Empty ⇒ a bare `VAR=` (unambiguous empty in a systemd EnvironmentFile and
// when shell-sourced). Quoting it as '' risks a literal two-char value (e.g.
// a tmux socket named "''"), which would defeat the default-socket behavior.
if (value === '') {
return '';
}
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) { if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value; return value;
} }
@@ -1759,6 +1962,123 @@ export function countOrchestrators(roster: FleetRoster): number {
return roster.agents.filter((a) => a.className === 'orchestrator').length; 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',
'claude',
'codex',
'opencode',
'dogfood',
];
/**
* Add a new agent to a fleet roster (immutable — returns a new FleetRoster).
* Throws on invalid name, duplicate name.
*/
export function addAgentToRoster(roster: FleetRoster, agent: FleetAgent): FleetRoster {
if (!agent.name || !/^[A-Za-z0-9_.-]+$/.test(agent.name)) {
throw new Error(`Invalid fleet agent name: ${agent.name || '<empty>'}`);
}
if (roster.agents.some((a) => a.name === agent.name)) {
throw new Error(`Agent "${agent.name}" already exists in the fleet roster.`);
}
return {
...roster,
agents: [...roster.agents, agent],
};
}
/**
* Remove an agent from a fleet roster (immutable — returns a new FleetRoster).
* Throws if the agent is not found, or if removal would leave zero orchestrators.
*/
export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetRoster {
const agent = roster.agents.find((a) => a.name === name);
if (!agent) {
throw new Error(`Agent "${name}" is not in the fleet roster.`);
}
const remaining = roster.agents.filter((a) => a.name !== name);
const remainingOrchCount = remaining.filter((a) => a.className === 'orchestrator').length;
if (remainingOrchCount === 0 && agent.className === 'orchestrator') {
throw new Error(
`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,
};
}
/**
* Serialize a FleetRoster to YAML text (snake_case keys).
* The output is parseable by loadFleetRoster.
*/
export function serializeRosterToYaml(roster: FleetRoster): string {
const agents = roster.agents.map((agent) => {
const raw: Record<string, unknown> = {
name: agent.name,
runtime: agent.runtime,
class: agent.className,
};
if (agent.workingDirectory !== undefined) {
raw['working_directory'] = agent.workingDirectory;
}
if (agent.modelHint !== undefined) {
raw['model_hint'] = agent.modelHint;
}
if (agent.persistentPersona !== undefined) {
raw['persistent_persona'] = agent.persistentPersona;
}
if (agent.resetBetweenTasks !== undefined) {
raw['reset_between_tasks'] = agent.resetBetweenTasks;
}
if (agent.kickstartTemplate !== undefined) {
raw['kickstart_template'] = agent.kickstartTemplate;
}
return raw;
});
const runtimes: Record<string, { reset_command: string }> = {};
for (const [runtime, config] of Object.entries(roster.runtimes)) {
runtimes[runtime] = { reset_command: config.resetCommand };
}
const raw: Record<string, unknown> = {
version: roster.version,
transport: roster.transport,
tmux: {
socket_name: roster.tmux.socketName,
holder_session: roster.tmux.holderSession,
},
defaults: {
working_directory: roster.defaults.workingDirectory,
},
runtimes,
agents,
};
return YAML.stringify(raw);
}
/** /**
* Prompt interactively for a fleet profile via stdin readline. * Prompt interactively for a fleet profile via stdin readline.
* AI-free: no LLM calls — pure readline menu. * AI-free: no LLM calls — pure readline menu.

View File

@@ -19,6 +19,7 @@ import { createRequire } from 'node:module';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -291,12 +292,23 @@ function buildPrdBlock(): string {
// ─── Runtime prompt builder ────────────────────────────────────────────────── // ─── Runtime prompt builder ──────────────────────────────────────────────────
function buildRuntimePrompt(runtime: RuntimeName): string { /**
* Compose the full runtime contract for a harness: the resident-by-value core
* (CONSTITUTION + AGENTS + USER + TOOLS + runtime) plus operator overlays
* (`*.local.md` deltas), merged in precedence order so the model gets one
* pre-merged blob (DESIGN §3.2 / R7). Overlays are injected as deltas by value;
* base files keep their existing residency (USER injected; SOUL/STANDARDS are
* load-on-demand, so only their small `.local` deltas are injected here).
*
* `mosaicHome` is parameterized for testability; production callers use the
* module-level default.
*/
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
const runtimeContractPaths: Record<RuntimeName, string> = { const runtimeContractPaths: Record<RuntimeName, string> = {
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'), claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'), codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'), opencode: join(mosaicHome, 'runtime', 'opencode', 'RUNTIME.md'),
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'), pi: join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'),
}; };
const runtimeFile = runtimeContractPaths[runtime]; const runtimeFile = runtimeContractPaths[runtime];
@@ -331,27 +343,61 @@ For required push/merge/issue-close/release actions, execute without routine con
`); `);
// CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of // CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
// pre-constitution installs that have not been re-seeded yet. // pre-constitution installs that have not been re-seeded yet. Injected by
const constitution = readOptional(join(MOSAIC_HOME, 'CONSTITUTION.md')); // value verbatim so the bare-launch fallback read is byte-equal (R8).
const constitution = readOptional(join(mosaicHome, 'CONSTITUTION.md'));
if (constitution) parts.push(constitution); if (constitution) parts.push(constitution);
// AGENTS.md // AGENTS.md
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8')); parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
// USER.md // USER.md (+ USER.local.md operator overlay, appended directly under the
const user = readOptional(join(MOSAIC_HOME, 'USER.md')); // profile its base owns).
const user = readOptional(join(mosaicHome, 'USER.md'));
if (user) parts.push('\n\n# User Profile\n\n' + user); if (user) parts.push('\n\n# User Profile\n\n' + user);
const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
if (userLocal.trim()) {
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
}
// TOOLS.md // TOOLS.md
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md')); const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools); if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
// inject only the small `.local` delta by value so the customization reaches
// the model without re-injecting the full base prose (preserves the byte
// budget). Absent `.local` files → base-only, automatically (R7 §3.2).
const overlayBlocks: string[] = [];
const soulLocal = readOptional(join(mosaicHome, 'SOUL.local.md'));
if (soulLocal.trim()) {
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
}
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
if (standardsLocal.trim()) {
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
}
if (overlayBlocks.length > 0) {
parts.push('\n\n# Operator Overlays\n\n' + overlayBlocks.join('\n\n'));
}
// Runtime-specific contract // Runtime-specific contract
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8')); 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'); return parts.join('\n');
} }
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
function buildRuntimePrompt(runtime: RuntimeName): string {
return composeContract(runtime);
}
// ─── Session lock ──────────────────────────────────────────────────────────── // ─── Session lock ────────────────────────────────────────────────────────────
function writeSessionLock(runtime: string): void { function writeSessionLock(runtime: string): void {
@@ -976,6 +1022,22 @@ export function registerLaunchCommands(program: Command): void {
launchRuntime(runtime, extraArgs, yolo); launchRuntime(runtime, extraArgs, yolo);
}); });
// compose-contract — emit the composed runtime contract (base + operator
// overlays) for a harness to stdout, without launching. For inspection,
// `mosaic doctor`, diffing, and the composer test (R7).
program
.command('compose-contract <harness>')
.description('Print the composed runtime contract (base + *.local overlays) for a harness')
.action((harness: string) => {
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
if (!valid.includes(harness as RuntimeName)) {
console.error(`Unknown harness '${harness}'. Expected one of: ${valid.join(', ')}.`);
process.exitCode = 64;
return;
}
process.stdout.write(composeContract(harness as RuntimeName));
});
// Coord (mission orchestrator) // Coord (mission orchestrator)
program program
.command('coord') .command('coord')

View 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('');
});
});

View 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');

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';

View File

@@ -0,0 +1,85 @@
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 {
buildReseedCommand,
buildRelaunchCommands,
readRosterAgentNames,
runFrameworkReseed,
} from './update-checker.js';
/**
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
* durable agents so shipped launcher/runtime changes activate. These cover the
* pure builders + the missing-installer guard (the exec path is integration).
*/
describe('buildReseedCommand', () => {
it('invokes the package install.sh in data-safe sync-only keep mode', () => {
const out = buildReseedCommand('/pkg/framework', '/home/u/.config/mosaic');
expect(out.installer).toBe('/pkg/framework/install.sh');
expect(out.command).toBe('bash /pkg/framework/install.sh');
expect(out.env).toEqual({
MOSAIC_SYNC_ONLY: '1',
MOSAIC_INSTALL_MODE: 'keep',
MOSAIC_HOME: '/home/u/.config/mosaic',
});
});
});
describe('buildRelaunchCommands', () => {
it('builds a systemctl --user restart per agent unit', () => {
expect(buildRelaunchCommands(['orchestrator', 'coder0'])).toEqual([
['systemctl', '--user', 'restart', 'mosaic-agent@orchestrator.service'],
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
]);
});
it('is empty for an empty roster', () => {
expect(buildRelaunchCommands([])).toEqual([]);
});
});
describe('readRosterAgentNames', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mosaic-roster-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it('returns [] when no roster exists', () => {
expect(readRosterAgentNames(home)).toEqual([]);
});
it('extracts agent names from roster.yaml', () => {
mkdirSync(join(home, 'fleet'), { recursive: true });
writeFileSync(
join(home, 'fleet', 'roster.yaml'),
[
'version: 1',
'agents:',
' - name: orchestrator',
' runtime: pi',
' - name: coder0',
' runtime: claude',
' - name: "reviewer-1"',
' runtime: codex',
].join('\n') + '\n',
);
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
});
});
describe('runFrameworkReseed', () => {
it('reports not-ok (not throw) when the installer is absent', () => {
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
const res = runFrameworkReseed(missing, join(missing, 'home'));
expect(res.ok).toBe(false);
expect(res.reason).toContain('installer not found');
rmSync(missing, { recursive: true, force: true });
});
});

View File

@@ -16,7 +16,8 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -453,6 +454,98 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
return `npm i -g ${pkgs.join(' ')}`; return `npm i -g ${pkgs.join(' ')}`;
} }
// ─── Post-update framework re-seed + agent relaunch (F3-m3 / R13) ─────────────
//
// `mosaic update` installs the new npm CLI but, on its own, leaves the framework
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
// These helpers run the package's own install.sh in sync-only mode (the P4
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
/** Resolve the framework/ directory bundled in the installed package. */
export function resolveBundledFrameworkRoot(): string {
// dist/runtime/update-checker.js → ../../framework (package files: dist + framework)
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework');
}
export const FRAMEWORK_RESEED_PACKAGE = PKG;
/**
* Build the framework re-seed invocation: the package's install.sh in
* sync-only mode (file phase only — no environment-touching post-install),
* keep mode (never overwrite user files). Returned as data so it is unit
* testable; `runFrameworkReseed` executes it.
*/
export function buildReseedCommand(
frameworkRoot: string,
mosaicHome: string,
): { installer: string; command: string; env: Record<string, string> } {
const installer = join(frameworkRoot, 'install.sh');
return {
installer,
command: `bash ${installer}`,
env: {
MOSAIC_SYNC_ONLY: '1',
MOSAIC_INSTALL_MODE: 'keep',
MOSAIC_HOME: mosaicHome,
},
};
}
/**
* Re-seed the framework from the freshly-installed package. Returns a result
* describing what happened (so callers can message + decide on relaunch).
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
*/
export function runFrameworkReseed(
frameworkRoot = resolveBundledFrameworkRoot(),
mosaicHome = join(homedir(), '.config', 'mosaic'),
): { ok: boolean; reason?: string } {
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
if (!existsSync(installer)) {
return { ok: false, reason: `installer not found: ${installer}` };
}
try {
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
return { ok: true };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
}
/**
* Best-effort parse of the fleet roster for agent names (used to relaunch
* durable agents after a re-seed). Returns [] when no roster exists.
*/
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
if (!existsSync(rosterPath)) return [];
let text: string;
try {
text = readFileSync(rosterPath, 'utf-8');
} catch {
return [];
}
// Roster agents are listed as `- name: <id>` entries under `agents:`.
const names: string[] = [];
for (const line of text.split('\n')) {
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
if (m && m[1]) names.push(m[1]);
}
return names;
}
/** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */
export function buildRelaunchCommands(agentNames: string[]): string[][] {
return agentNames.map((name) => [
'systemctl',
'--user',
'restart',
`mosaic-agent@${name}.service`,
]);
}
/** /**
* Format a table showing all packages with their current/latest versions. * Format a table showing all packages with their current/latest versions.
*/ */