Compare commits

..

14 Commits

Author SHA1 Message Date
Jarvis
7210b7391a fix(ci): gitignore vite/vitest *.timestamp-*.mjs to stop turbo traversal race
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
The push/ci lint step intermittently failed with:

  x Package traversal error: .../packages/macp/vitest.config.ts.timestamp-
    <n>.mjs: IO error ... No such file or directory (os error 2)

vite/vitest/esbuild write a transient *.timestamp-*.mjs next to a TS
config while loading it, then unlink it. The files were untracked but not
ignored, so turbo's package traversal hashed them and raced the unlink.
Ignoring them excludes them from turbo's input set and removes the race.

Same class of fix as the pglite timeout/OOM change in this PR: transient
test tooling artifacts destabilising CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:37:33 -05:00
Jarvis
80570f7040 fix(db): stop pglite migration tests flaking CI on timeout + WASM OOM
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline was successful
packages/db's migrate.test.ts spins up a real PGlite (WASM Postgres)
instance per test and applies the full drizzle migration set. Each case
takes ~3-5s locally and longer on CI, where turbo runs ~20 packages'
suites concurrently. Two failure modes resulted, bouncing between the
push/ci and pr/ci pipelines on identical SHAs:

  FAIL src/migrate.test.ts > runPgliteMigrations > ...
    Error: Test timed out in 5000ms.
    -> memory access out of bounds  (wasm:/wasm/...)

1. The 5s vitest default timeout expires mid-migration -> phantom
   'Test timed out in 5000ms'. Raise testTimeout/hookTimeout to 120s so
   legitimately-slow migrations finish.
2. Each PGlite WASM heap is multi-hundred-MB (RSS ~705MB for this file
   alone); parallel forks multiply the peak and tip the runner into the
   WASM OOM. Pin the package to a single fork so only one instance is
   resident at a time.

Also register packages/db/vitest.config.ts in eslint's allowDefaultProject
(alongside the gateway/storage vitest configs) so the typed lint can parse
the now-non-trivial config.

Verified: full db suite green 3x locally with the new config; each run
~13s, no timeouts, no OOM. eslint clean on both files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:29:41 -05:00
e6b53ea103 fix(tools): default AGENT_WORK_ROOT to $HOME/mosaic/agent-work (#641)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
2026-06-23 13:40:13 +00:00
4da87640e8 feat(tmux): agent-send.sh --class triage tag for the comms daemon (#552)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-23 03:25:16 +00:00
a38a491403 chore(release): mosaic CLI 0.0.41 (#640)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-23 02:21:04 +00:00
78d67c6261 chore(ci): bump ci-base image node 22 → 24-alpine (#639)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-23 00:59:39 +00:00
94e5cd7a81 ci: eliminate cold pnpm install via pre-baked CI base image (Phase 1) (#635)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-22 22:50:21 +00:00
4e84f8e850 feat(fleet): comms-block emitter + FLEET-LAUNCH runbook (#633) (#638)
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 22:23:50 +00:00
cf8ceb3095 CI: add pre-baked ci-base image (producer) [Phase 1a] (#637)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-22 22:20:48 +00:00
bf2a6745c8 fix(install): preserve user fleet data on re-seed + refresh active units (CRITICAL) (#632)
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 21:38:09 +00:00
d539d61e0e refactor(fleet): rename tmux socket mosaic-factory → mosaic-fleet (#630)
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 21:08:43 +00:00
3f69d45334 docs(fleet): consolidate north-star doctrine (budget + control plane + identity) (#629)
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 21:08:41 +00:00
e2336bb0ca chore(release): mosaic CLI 0.0.40 (#624)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-22 19:49:45 +00:00
7342415a32 fix(fleet): consume model_hint + fix socket-default trap (stand-up fixes) (#627)
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 19:18:01 +00:00
50 changed files with 1320 additions and 203 deletions

7
.gitignore vendored
View File

@@ -15,3 +15,10 @@ infra/step-ca/dev-password
# Scratch dirs created by the framework git-wrapper shell test harnesses # Scratch dirs created by the framework git-wrapper shell test harnesses
.mosaic-test-work/ .mosaic-test-work/
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
# loading it, then unlink. They are untracked but were not ignored, so turbo's
# package traversal hashed them and intermittently failed CI with "Package
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs

4
.npmrc
View File

@@ -1 +1,5 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
# instead of repopulating a fresh one.
store-dir=/root/.local/share/pnpm/store

40
.woodpecker/ci-image.yml Normal file
View File

@@ -0,0 +1,40 @@
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
# for their install step.
#
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
# code push must not trigger a 25-min image build. `path` applies to push/PR
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
# always ships a fresh base.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'pnpm-lock.yaml'
- 'Dockerfile.ci'
steps:
build-ci-base:
image: gcr.io/kaniko-project/executor:debug
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
# Lockfile-hash tag: an immutable identity for the exact dep set baked
# into this image. `:latest` is the mutable pointer pipelines consume.
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS

View File

@@ -1,5 +1,9 @@
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
# store. The install step resolves from the baked store (--prefer-offline)
# instead of paying a ~731s cold fetch + native compile every run.
variables: variables:
- &node_image 'node:22-alpine' - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable' - &enable_pnpm 'corepack enable'
when: when:
@@ -15,8 +19,9 @@ steps:
image: *node_image image: *node_image
commands: commands:
- corepack enable - corepack enable
- apk add --no-cache python3 make g++ # python3/make/g++ are baked into ci-base; --prefer-offline resolves from
- pnpm install --frozen-lockfile # the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# Blocking gate: public framework package must contain no operator-specific # Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed). # personal data or private $HOME defaults. Runs early (no node_modules needed).
@@ -64,8 +69,7 @@ steps:
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands: commands:
- *enable_pnpm - *enable_pnpm
# Install postgresql-client for pg_isready # postgresql-client (pg_isready) is baked into ci-base.
- apk add --no-cache postgresql-client
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up. # Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- | - |
ready=0 ready=0

View File

@@ -2,7 +2,9 @@
# Runs only on main branch push/tag # Runs only on main branch push/tag
variables: variables:
- &node_image 'node:22-alpine' # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
# toolchain + warm pnpm store. Kills the second cold install publish pays.
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable' - &enable_pnpm 'corepack enable'
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches # 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 # the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
@@ -31,7 +33,8 @@ steps:
image: *node_image image: *node_image
commands: commands:
- corepack enable - corepack enable
- pnpm install --frozen-lockfile # Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
build: build:
image: *node_image image: *node_image

45
Dockerfile.ci Normal file
View File

@@ -0,0 +1,45 @@
# Pre-baked CI base image for Woodpecker pipelines.
#
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
# already fetched at build time. `pnpm fetch` only populates the store from the
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
# which is exactly why the musl toolchain stays baked into this image. A
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
# tarballs from local hard-links (no network) and compiles natives against the
# already-present toolchain, in tens of seconds instead of ~731s.
#
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
# (see .woodpecker/ci-image.yml).
#
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
# carries zero cache variables. node:26 stays held until it reaches LTS
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
# `apk add bash`.
RUN apk add --no-cache python3 make g++ postgresql-client bash
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
WORKDIR /app
# Pin the store location so the pipeline can point `store-dir` at the same path.
ENV PNPM_HOME=/root/.local/share/pnpm
RUN pnpm config set store-dir /root/.local/share/pnpm/store
# Warm the store. `pnpm fetch` populates the content-addressable store with the
# dependency tarballs directly from the lockfile (no package.json / workspace
# needed), so a baked store stays valid until the lockfile changes. Note:
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
# in the pipeline, against the toolchain baked above.
COPY pnpm-lock.yaml ./
RUN pnpm fetch --frozen-lockfile

View File

@@ -74,3 +74,19 @@ Active workstream is **W1 — Federation v1**. Workers should:
## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding ## 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. - 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.
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
- Status: implemented + tested. PRIMARY: install.sh PRESERVE_PATHS += fleet/\*.yaml + fleet/agents + fleet/run (glob-aware cp-fallback); TS parity. SECONDARY: refreshActiveFleetUnits propagates unit fixes to ~/.config/systemd/user on mosaic update. bash F6 + TS + unit tests green. Detail: scratchpads/631-reseed-preserves-fleet.md.
## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook
- Status: implemented + tested (TDD). `mosaic fleet comms-block <role> [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md.

114
docs/fleet/FLEET-LAUNCH.md Normal file
View File

@@ -0,0 +1,114 @@
# Fleet Launch Runbook
How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to
configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke
per-agent launch script; the roster plus per-agent `.env` files are the single source of launch
config.
## The launch chain
| Layer | File | Responsibility |
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| systemd unit | `mosaic-agent@<role>.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. |
| session launcher | `tools/fleet/start-agent-session.sh <role>` | Builds the launch command, opens the tmux pane, wires the heartbeat. |
| launch command | `mosaic yolo <runtime>` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. |
| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. |
Per-agent overrides live in `fleet/agents/<role>.env`, generated from `roster.yaml` by
`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher.
## Worker launch path (default)
1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`.
2. `generateAgentEnv` emits `fleet/agents/<role>.env` with `MOSAIC_AGENT_NAME`,
`MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`.
3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default
(line ~44):
```sh
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
```
4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract`
can inject the Fleet-Comms cheat-sheet for that role.
That is the whole worker path: roster → `.env` → `mosaic yolo <runtime>` → seeded pane.
## Orchestrator fold (PATH A — ships today)
The orchestrator is **just another roster agent** launched through the canonical path — not a
snowflake script.
| Piece | Value |
| ------------------ | ----------------------------------- |
| host-side launcher | `orchestrator-launch.sh` |
| systemd unit | `mosaic-fleet-orchestrator.service` |
| tmux session | `orchestrator` (role-named) |
Set its launch command via `fleet/agents/orchestrator.env`:
```sh
MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@<channel>'
```
When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]`
guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped
entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude`
(rather than a raw `claude` invocation) is what gives the orchestrator the same full
`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any
other flags passed straight through to the `claude` binary.
## Launch gotchas
1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do
**not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both.
Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo
`mosaic claude --permission-mode bypassPermissions …`. Never mix the two.
2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and
`composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and**
the role must be a member of `roster.yaml`, or the block resolves empty.
3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` /
`checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the
launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a
throwaway tmux session before swapping a live launcher.
## Why per-agent `.env` survives upgrades (#632)
`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so
`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides
(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed
could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now.
## Inspecting the comms wiring
- `mosaic fleet comms-block <role>` prints the Fleet-Comms cheat-sheet a given role receives at
launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the
FLIP / `--verify` conventions. `--host <h>` previews a cross-host view. An unknown role or missing
roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op.
- Versus `mosaic compose-contract <runtime>`: that emits the **whole** system prompt and reads the
role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted,
explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer.
## North Star / future direction
**Vision:** a webUI lets the user edit each agent's launch config — switch **harness**
(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels**
override — with no terminal.
**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the
existing roster-driven launcher. Field-by-field status today:
| Launch-config field | Roster-native today? | Mechanism / gap |
| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. |
| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. |
| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. |
| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. |
**The arc:**
- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632.
- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle
(line-44 conditional) and **command/channels** emission to complete the data model.
- **webUI** — binds dropdowns/toggles directly to those four roster fields.
PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the
webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636.

View File

@@ -7,10 +7,10 @@
## Problem ## Problem
The durable tmux fleet runs on the isolated `mosaic-factory` socket. That isolation The durable tmux fleet runs on the isolated `mosaic-fleet` socket. That isolation
(which protects the operator's default tmux) makes the fleet **invisible** to default (which protects the operator's default tmux) makes the fleet **invisible** to default
tooling, and truth is split across three planes no single command joins — systemd tooling, and truth is split across three planes no single command joins — systemd
(`systemctl --user`), tmux (`-L mosaic-factory`), and the process tree (`pstree`). (`systemctl --user`), tmux (`-L mosaic-fleet`), and the process tree (`pstree`).
`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send` `agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send`
confirms only keystroke injection, not acceptance. Net: the operator has near-zero confirms only keystroke injection, not acceptance. Net: the operator has near-zero
observability and no safe way to watch a session. observability and no safe way to watch a session.
@@ -56,7 +56,7 @@ observability and no safe way to watch a session.
## Acceptance criteria ## Acceptance criteria
- `mosaic fleet ps` shows all 5 live sessions on `mosaic-factory` with correct - `mosaic fleet ps` shows all 5 live sessions on `mosaic-fleet` with correct
pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs
`dogfood-agent.py`) and the **boot-enable** gap (active but disabled). `dogfood-agent.py`) and the **boot-enable** gap (active but disabled).
- Killing one agent's pane flips its row to dead/stale within one `interval`. - Killing one agent's pane flips its row to dead/stale within one `interval`.
@@ -72,7 +72,7 @@ observability and no safe way to watch a session.
- Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new - Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new
`fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert `fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert
exact tmux/systemd command construction and JSON shape (tenant+host present). exact tmux/systemd command construction and JSON shape (tenant+host present).
- Situational: run against the live `mosaic-factory` fleet; capture `fleet ps` output, - Situational: run against the live `mosaic-fleet` fleet; capture `fleet ps` output,
a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair. a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair.
## Known limitations ## Known limitations

View File

@@ -8,11 +8,11 @@
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed` > Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
| id | status | description | depends_on | agent | pr | notes | | id | status | description | depends_on | agent | pr | notes |
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | ----------------------------------------------------------------------------------------------------------------------------- | | ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- |
| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` | | FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` |
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD | | FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD |
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) | | FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) |
| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-factory; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null | | FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
| FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired | | FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired |
| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify | | FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead | | FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead |

View File

@@ -56,13 +56,21 @@ The Fleet inherits — does not re-invent — the MVP's hard requirements:
One **definition** is the source of truth; the **session** is how it runs. One **definition** is the source of truth; the **session** is how it runs.
| Layer | Owner | Phase-2 reality | Destination | | Layer | Owner | Phase-2 reality | Destination |
| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- | | -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it | | **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it |
| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts | | **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs | | **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs |
| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" | | **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" |
| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams | | **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams |
| **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 | | **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 |
| **Central register** | Postgres `fleet` schema (gateway instance); access via gateway API only | _none in PoC_ (files + `roster.yaml`) | agents, missions, tasks, heartbeats, spend — single network-accessible SSOT; docs = generated projections |
| **Budget / spend governance** | **per-tenant budget policy** ingested by the orchestrator + routing layer | none today (spend is unmetered) | usage-vs-limit feedback ingested; spend auto-paced to the limit window; per-provider/per-account/concurrency/API-$ budgets enforced |
> **PoC socket hygiene:** the PoC fleet runs on the **default tmux socket** (no `-L`).
> The named production-isolation socket is **`mosaic-fleet`** (matches the product brand);
> an absent roster `socket_name` means the default socket everywhere (spawn, `fleet ps`,
> onboarding cheat-sheet). The legacy dogfood canary still runs on the old `mosaic-factory`
> socket pending migration.
## Operating model (inherited, not reinvented) ## Operating model (inherited, not reinvented)
@@ -113,6 +121,67 @@ Every artifact, starting Phase 2, MUST:
3. Define **healthy = answered a heartbeat within N seconds**, never just "pane alive". 3. Define **healthy = answered a heartbeat within N seconds**, never just "pane alive".
4. Make **observation read-only by default**; control is an explicit, separate, opt-in verb. 4. Make **observation read-only by default**; control is an explicit, separate, opt-in verb.
> **OPS INVARIANT — runtime agents need a real TTY.** Claude/Codex/pi/opencode agents
> cannot be bare-launched from a systemd `ExecStart`; a durable harness with a real PTY is
> required. This is **why `start-agent-session.sh` launches into tmux** and uses a
> `MOSAIC_AGENT_COMMAND` override rather than running the runtime directly under systemd.
## Budget & token governance (first-class fleet concern)
Spend is a fleet-level resource, not a per-agent afterthought. The fleet treats token
and API-dollar budget the way it treats liveness: a signal every runtime exposes and the
control plane is accountable for. This rides the same primitives as everything else —
`tenant_id` + `host` on every spend record, **read-only metering by default**, and the
**federation** layer as the cross-host aggregation point (W1) — so budgeting is zero-foreclosure
from day one even while one tenant exists.
**Two spend regimes, one policy surface:**
| Regime | Feedback signal | Fleet obligation |
| ------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| **OAuth-subscription runtimes** (Claude sub, Codex sub) | runtime exposes **current-usage-vs-limit** within a rolling limit window | **ingest** the signal per sub-account; **auto-pace** agentic spend so the window is not exhausted early |
| **API-token runtimes** (metered per token) | provider billing / token counts | enforce **hard $-spend ceilings**; on breach, **downgrade → queue → refuse** (below) |
**Auto-pacing law (OAuth subs) — EVEN-SPREAD default (Jason override, 2026-06-22):** the fleet
paces agentic token spend to consume the limit window **evenly over remaining time**:
target rate = _(remaining usage available)_ ÷ _(remaining time in the window)_. Example: 100% of
a 7-day window = **~14.285%/day**; the system tracks current usage and continuously re-splits the
remainder evenly to hold pace. **Anticipated token-spend-per-task is the budgeting informant**
tasks are scheduled against the daily pace, not run until the quota is gone. Rationale: spreading
delivery evenly beats rapidly exhausting usage and losing **multiple days of momentum**.
**Rapid pacing / overspend requires EXPLICIT user authorization;** absent it, even-spread holds.
Pacing is a control-plane decision, surfaced read-only before it throttles a lane.
**Hard-cap breach behavior (ladder):** when a budget ceiling is hit mid-work, the fleet
**downgrades first** (opus → sonnet → haiku, then Claude → Codex), **queues** the lane at the
cheapest floor until the window resets, and **refuses** only as a last resort. Refusal is never
the first response to a breach.
**Spend accounting, learning & telemetry:**
- **Multi-subscription auto-routing:** a tenant with multiple subscriptions may let the fleet
**auto-route work to the account with the most available usage** (within budget policy).
- **Historical spend learning:** every task's token spend is **recorded**; historical data
continuously updates known **spend-per-task**, **typical daily spend**, and projections — so
estimates self-correct and pacing stays on target.
- **Projected + actual spend on artifacts (Mosaic Stack mandate):** PRDs, missions, and task
decomposition **MUST note projected AND actual token spend** — a Mosaic Stack process standard
(template-level), tracked separately as **#622**.
- **Anonymized telemetry → mosaicstack.dev:** spend data is reported (anonymous) to the
mosaicstack.dev telemetry endpoint so other agents/fleets budget and optimize from real,
anonymized data. Product workstream, tracked separately as **#623**.
**User-settable budgets (the policy surface).** A tenant operator can set budgets for every
configured **provider** (per-provider ceilings), the **account-to-task mapping**, the **agentic
routing flow**, **concurrency** (the spend multiplier), and **hard API-token $-limits**. Budgets
are enforced at the orchestrator + routing boundary, not inside individual workers (a worker never
decides its own budget — see delegation discipline).
**Budget CLI UX (#558):** `mosaic budget set --reset-at` sets the window reset; reset-datetimes
carry **confidence tags** (`user` / `provider` / `estimated` / `unknown`); and **urgency/criticality
is a dispatch-gate modifier** — high-urgency work may override even-spread pacing **within
authorization**. (Also feeds the budgeting workstream, not only this doc.)
## Observation model ## Observation model
| Verb | Behavior | | Verb | Behavior |
@@ -127,15 +196,83 @@ Every artifact, starting Phase 2, MUST:
> (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The > (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The
> verbs above restore "join and observe" safely. > verbs above restore "join and observe" safely.
## Control plane & central register
### Why the register must be Postgres
The fleet is multi-host (w-jarvis + dragon-lin + future). A SQLite file is a local
file — it is not a network service and cannot be shared across hosts. Beyond topology,
Postgres MVCC eliminates the concurrent-writer corruption class Hermes hit with SQLite
under multi-agent access.
Access is exclusively through the **gateway API** (`apps/gateway` — typed, auth-gated,
scoped tokens). No agent or dispatcher pane ever holds a raw DB credential; a
compromised pane cannot corrupt or exfiltrate the register.
### Architecture (layers)
| Layer | Responsibility | Implementation |
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Register** | Source of truth: agents, missions, tasks, heartbeats, spend | Postgres `fleet` schema — existing stack instance (`@mosaicstack/db`) |
| **Access** | Typed, auth-gated API | Gateway `fleet/*` routes |
| **Dispatcher** | Brief classification, BOD review, planning/coding/review/test/deploy sequencing + gates → fleet task dispatch | **forge pipeline engine** (`runPipeline`/`resumePipeline`, brief classifier, BOD) **+ thin `forge-exec` adapter → `agent-send.sh`**; NOT a new daemon — forge is reused, only stage→agent dispatch is new |
| **Orchestrator (Mos)** | Goals, missions, judgment, user/PA interface | Context-light; sets intent → re-engages only for decisions |
### Dispatcher = forge (reuse, do not rebuild)
The dispatcher is **not new work**: it is `@mosaicstack/forge`, a fully-implemented
software-factory pipeline engine (brief → Board-of-Directors review → 3 planning stages →
coding → review/remediation → testing → deploy). Forge already provides
`runPipeline`/`resumePipeline`, a brief classifier, and a BOD persona loader, so the fleet
does **not** re-implement sequencing, gate logic, or brief classification. The only new
fleet-owned code is a thin **`forge-exec` TaskExecutor adapter** (`ForgeTask`
`agent-send.sh` to a named agent) — forge's single missing piece — tracked as a Gitea
issue and built post-PoC. The Postgres register backs forge's pipeline state (durable
`resumePipeline`, cross-host) in addition to cross-project missions/tasks/Kanban. The
north-star **'board' role IS forge's Board-of-Directors** — reused from forge, not a new
role implementation.
### Docs as projections
`docs/TASKS.md` and `MISSION-MANIFEST.md` are **generated projections** of the DB,
not hand-maintained. The dispatcher (or a scheduled job) renders Markdown from
`fleet.*` tables and commits the output. DB is authoritative; docs are for human
reference.
### Spend
`fleet.spend_ledger` records projected and actual token spend per agent/mission/task
(ties to issue #622). The dispatcher enforces budget caps before dispatching. Mos reads
the roll-up via API — no raw DB access, no context-bloating dumps.
### Federation
Cross-host fleet state flows through federated gateway queries (existing
`federation_peers` / `federation_grants` machinery). This is the existing north-star
invariant: **control plane rides federation (W1), not a bespoke broker.** No new
broker introduced.
### Scope
This is Phase 45 of this roadmap, materialized. It MUST NOT block the PoC (which
runs correctly on files + `roster.yaml`). Begin when Phase 2 heartbeat protocol is
stable and concurrent-agent count makes file coordination the bottleneck.
### Open sub-decision
Dedicated Postgres **instance** vs. dedicated **schema** in the existing instance.
Recommendation: dedicated schema, existing instance (a migration file, not new infra);
re-evaluate if isolation or write-volume demands it.
## Phased roadmap ## Phased roadmap
| Phase | Outcome | Status | | Phase | Outcome | Status |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| 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 + enhancer**; 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; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | 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; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned |
## Decisions of record (2026-06-20, with Jason) ## Decisions of record (2026-06-20, with Jason)
@@ -164,6 +301,57 @@ Every artifact, starting Phase 2, MUST:
- **Orchestrator chat connector:** the orchestrator is reachable over a user-chosen connector - **Orchestrator chat connector:** the orchestrator is reachable over a user-chosen connector
(tmux now; Telegram/Discord/Matrix/Slack configurable). Validated live: **"Mos" orchestrator (tmux now; Telegram/Discord/Matrix/Slack configurable). Validated live: **"Mos" orchestrator
on Discord** via the Claude Code discord channel plugin (w-jarvis). on Discord** via the Claude Code discord channel plugin (w-jarvis).
- **Session context cap = 200k tokens (GLOBAL to all Claude sessions):** Claude Code sessions are
capped at a **max 200k-token context window**. Long-running sessions extended toward 1M tokens
have proven **worse in practice** (degraded steering, off-plan divergence); 200k is the standard.
**Enforcement split:** the _window_ lives in **`~/.claude/settings.json`** (host-global) as
`"autoCompactWindow": 200000` + `"autoCompactEnabled": true`; the _1M-disable_ lives in **launch
ENV** (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1`, plus `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`) wherever
a `[1m]` model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude
agent is capped at spawn. (settings = window; env = 1M-disable.)
- **Worker context bound (#8):** workers are kept context-bounded via the **ephemeral-per-lane
lifecycle + native compaction**, not via the 200k knob. The explicit `autoCompactWindow` 200k knob
**stays Claude-specific** — the _principle_ (bounded context) extends to workers, the _knob_ does not.
- **Orchestrator delegation discipline:** the orchestrator **delegates all delivery work** to
subagents / workflows / ultracode / coder agents and confines its own context to \*\*orchestration
- the personal-assistant lane\*\*. Keeping delivery out of the orchestrator's window keeps its
context unpolluted and measurably reduces off-plan divergence. The orchestrator coordinates and
decides; it does not implement.
- **Budget governance is fleet doctrine:** token/API-dollar budgeting is a first-class fleet concern
(see "Budget & token governance"). OAuth-sub usage-vs-limit feedback is ingested per account, spend
is **auto-paced EVEN-SPREAD over remaining time** (rapid/overspend only on explicit authorization),
spend is **tracked historically** to self-correct per-task/daily estimates, multi-sub tenants may
**auto-route by available usage**, and operators set budgets per provider, per account-to-task
mapping, per routing flow, per concurrency level, and as hard API-$ ceilings.
- **Spend accounting is a Mosaic Stack process mandate:** PRDs, missions, and task decomposition
**MUST carry projected + actual token spend**; used locally for pacing and reported as **anonymized
telemetry to mosaicstack.dev**. The template standard (#622) and telemetry product (#623) are
tracked separately.
- **Unified identity = "Fleet" (Jason, 2026-06-22):** the product is **Mosaic Fleet** — one unified
user-facing identity and CLI surface. **forge** is the Fleet's **internal** delivery/orchestration
engine (not a separate product); the control-plane **Postgres register is the Fleet's register**;
workers/runtime are the **Fleet substrate**. **"factory" is RETIRED as a product term** — it was
only ever the software-factory concept (which forge implements) and the old `mosaic-factory` tmux
socket name. The production-isolation socket is now **`mosaic-fleet`** (matches the product brand);
the legacy dogfood canary remains on the old `mosaic-factory` socket pending migration. **Code stays
layered** (forge + fleet + control-plane as internal layers);
only the **identity + CLI surface unify under Fleet.**
- **Role-based session naming (Jason, 2026-06-22):** agent tmux sessions are named by **role**
(`orchestrator`, `enhancer`, `research`, `coder0-0`, …), not by persona. **Persona lives in
`SOUL.md`**; the front-end / Discord presents a **friendly alias** (e.g. "Mos" = the orchestrator's
alias). The session name is the stable addressing handle; the alias is presentation.
### Control plane & central register
- **Store:** Postgres (existing stack instance, dedicated `fleet` schema via `@mosaicstack/db`). SQLite rejected: (1) it is a local file — structurally incompatible with a multi-host fleet; (2) concurrent multi-agent writes caused repeated corruption in Hermes. "SQLite + access service" rejected as reinventing a DB server badly; "LLM agent gating DB access" rejected as slow, expensive, and a single point of failure.
- **Access:** gateway API only (`apps/gateway`, `fleet/*` routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern.
- **Dispatcher = forge (reuse, not a new build):** the dispatcher IS `@mosaicstack/forge`'s pipeline engine (`runPipeline`/`resumePipeline` + brief classifier + BOD persona loader), a fully-implemented software-factory pipeline (brief → BOD review → 3 planning stages → coding → review/remediation → testing → deploy). We do **not** design/build a new dispatcher and do **not** re-implement sequencing, gate logic, or brief classification. The only new fleet-owned piece is a thin **`forge-exec` TaskExecutor adapter** (suggested package `packages/forge-exec`) mapping a `ForgeTask``agent-send.sh` dispatch to a named fleet agent — forge's single missing piece. It is tracked as a Gitea issue and built **post-PoC** (not now).
- **Register backs forge:** the Postgres `fleet` register is genuinely new (neither forge nor the fleet has cross-project state). It BACKS forge's pipeline state (durable `resumePipeline`, cross-host) plus cross-project missions/tasks/Kanban.
- **'board' role = forge BOD:** the north-star role-library 'board' role IS forge's Board-of-Directors — reused, not reinvented.
- **Orchestration vs. dispatch:** Orchestrator (Mos) sets intent and handles judgment; forge works the mechanical pipeline (sequencing, gates, status transitions, spend ledger). LLM escalation reserved for judgment: mission decomposition, re-planning on failure.
- **Spend in the register:** `fleet.spend_ledger` tracks projected vs. actual tokens per agent/mission/task; ties to issue #622.
- **Docs as projections:** `docs/TASKS.md` and `MISSION-MANIFEST.md` become generated exports of the DB, not hand-maintained.
- **Sub-decision pending:** dedicated schema in existing PG instance (recommended) vs. dedicated PG instance. Revisit if isolation or write-volume demands it.
## Future enhancements (north-star, post-MVP — not on the MVP track) ## Future enhancements (north-star, post-MVP — not on the MVP track)
@@ -173,6 +361,16 @@ Every artifact, starting Phase 2, MUST:
A major enhancement over the current third-party channel plugin; **not required for the MVP**, 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 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. controls Discord UX (threads, reactions, attachments, per-thread context) end-to-end.
- **Matrix on a local homeserver — strategic future transport.** **F4 (in progress) IS the Matrix
connector**: an orchestrator chat connector speaking the Matrix client-server API against a
self-hosted homeserver (Conduit default, Synapse alt). Matrix is named here as the strategic
future transport — peer to tmux/Discord, not superseded by them.
- **tmux fleet attack-surface hardening.** Many always-on tmux sessions are an attack surface;
`tmux send-keys` / socket access could enable malicious action against agents directly.
Mitigations to build toward: socket ownership/perms, per-tenant socket isolation (already an
invariant), authenticated `agent-send`, and an audit of who can write to any pane. **Post-MVP
unless a P0 surfaces.** The control-plane register reinforces this (gateway-API access = no raw
DB creds in panes). A not-started risk-assessment + mitigation-plan task rides the Fleet `TASKS.md`.
## Assumptions (veto-able) ## Assumptions (veto-able)
@@ -184,3 +382,30 @@ Every artifact, starting Phase 2, MUST:
- `ASSUMPTION:` Fleet is workstream **W-FLEET** under `mvp-20260312`; a rollup row in - `ASSUMPTION:` Fleet is workstream **W-FLEET** under `mvp-20260312`; a rollup row in
`docs/TASKS.md` and a workstream declaration in `MISSION-MANIFEST.md` are proposed to `docs/TASKS.md` and a workstream declaration in `MISSION-MANIFEST.md` are proposed to
the MVP orchestrator, not written by this workstream. the MVP orchestrator, not written by this workstream.
- `ASSUMPTION:` OAuth-subscription runtimes (Claude sub, Codex sub) expose a machine-readable
current-usage-vs-limit signal the fleet can poll/ingest; if a provider exposes no such signal,
that provider's accounts fall back to API-style hard-ceiling budgeting only (no auto-pacing).
- `ASSUMPTION:` budget policy lives at the orchestrator + routing layer and is surfaced through the
same CLI→TUI→webUI parity (MVP-X1) as the rest of fleet state — not a separate budgeting daemon.
- `ASSUMPTION:` the 200k session cap is enforced by Claude Code settings/env composition (model
variant + `autoCompactWindow`), not by a Mosaic wrapper; a wrapper is the fallback only if the
harness later removes those knobs.
- `ASSUMPTION:` The central register (Postgres `fleet` schema + gateway API + forge as dispatcher) is
the Phase 45 control plane, begun after Phase 2 observability is proven. It is a dedicated
**W-FLEET** sub-workstream entry, not a separate mission. The dispatcher is `@mosaicstack/forge`
(reused, not a new daemon); the only new fleet-owned code is the thin **`forge-exec` TaskExecutor
adapter** (suggested package `packages/forge-exec`, `ForgeTask``agent-send.sh`), tracked as a
Gitea issue and built post-PoC.
---
> **Release procedure (drift re-capture, 2026-06-22):** `mosaic update` only propagates new fleet
> commands when the **CLI version is bumped** — without a version bump, fleet command changes never
> reach installed hosts. The release/version-bump procedure (bump → publish → `mosaic update`
> [→ `--relaunch`]) must be documented so fleet changes actually land. (Also feeds the budgeting
> workstream.)
>
> **Tracked separately (not in scope for this doc PR):** **#622** PRD/mission/task projected+actual
> spend template standard · **#623** anonymized spend telemetry → mosaicstack.dev (product) ·
> **#625** `tenant_id` roster-schema field (multi-tenant; invariant #1 home) · **#628** `forge-exec`
> TaskExecutor adapter (post-PoC). This PR records **doctrine only** — no implementation.

View File

@@ -1,7 +1,7 @@
# Local Fleet Canary # Local Fleet Canary
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
isolated tmux socket. The default socket is `mosaic-factory`; the commands do isolated tmux socket. The default socket is `mosaic-fleet`; the commands do
not use or stop the default tmux server. not use or stop the default tmux server.
## Files ## Files
@@ -67,7 +67,7 @@ mosaic agent tail canary-pi -n 80
These commands read the roster and target the configured tmux socket. The These commands read the roster and target the configured tmux socket. The
generated systemd agent services use `start-agent-session.sh`; message delivery generated systemd agent services use `start-agent-session.sh`; message delivery
uses the tmux send tools with `-L mosaic-factory`. uses the tmux send tools with `-L mosaic-fleet`.
`mosaic agent send` is operator-origin traffic unless a caller explicitly says `mosaic agent send` is operator-origin traffic unless a caller explicitly says
otherwise. The CLI always passes a deterministic source label to otherwise. The CLI always passes a deterministic source label to
@@ -82,7 +82,7 @@ impersonating a known handoff lane. The lower-level inter-agent wrapper
Use these checks before expanding the roster: Use these checks before expanding the roster:
```bash ```bash
tmux -L mosaic-factory ls tmux -L mosaic-fleet ls
tmux ls tmux ls
mosaic fleet verify mosaic fleet verify
systemctl --user status mosaic-tmux-holder.service systemctl --user status mosaic-tmux-holder.service
@@ -90,7 +90,7 @@ systemctl --user status mosaic-tmux-holder.service
Expected results: Expected results:
- `tmux -L mosaic-factory ls` shows `_holder` and roster agent sessions. - `tmux -L mosaic-fleet ls` shows `_holder` and roster agent sessions.
- `tmux ls` shows only the default tmux server sessions and is not changed by - `tmux ls` shows only the default tmux server sessions and is not changed by
fleet start/stop operations. fleet start/stop operations.
- `mosaic fleet verify` checks exact session targets on the isolated socket. - `mosaic fleet verify` checks exact session targets on the isolated socket.
@@ -108,7 +108,7 @@ Run this checklist before cutting or dogfooding a fleet release:
repeated `start` against the named socket; verify the default tmux server is repeated `start` against the named socket; verify the default tmux server is
unchanged. unchanged.
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions - Liveness verification: run `mosaic fleet verify` and confirm roster sessions
with `tmux -L mosaic-factory ls` or exact `has-session` checks. with `tmux -L mosaic-fleet ls` or exact `has-session` checks.
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and - Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
confirm `framework/fleet`, `framework/systemd/user`, confirm `framework/fleet`, `framework/systemd/user`,
`framework/tools/fleet`, and `framework/tools/tmux` assets are included. `framework/tools/fleet`, and `framework/tools/tmux` assets are included.
@@ -140,5 +140,5 @@ This rollback leaves the default tmux server untouched. If a canary session is
still present after service stop, remove only the isolated socket server: still present after service stop, remove only the isolated socket server:
```bash ```bash
tmux -L mosaic-factory kill-server tmux -L mosaic-fleet kill-server
``` ```

View File

@@ -17,7 +17,7 @@ Implement enough product surface to use the fleet locally:
- roster schema and examples - roster schema and examples
- local canary docs and rollback instructions - local canary docs and rollback instructions
- tests for CLI behavior where practical - tests for CLI behavior where practical
- canary verification on named tmux socket `mosaic-factory` - canary verification on named tmux socket `mosaic-fleet`
## Non-goals ## Non-goals
@@ -30,7 +30,7 @@ Implement enough product surface to use the fleet locally:
- CLI can initialize a minimal roster outside product defaults. - CLI can initialize a minimal roster outside product defaults.
- CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home. - CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home.
- CLI can start/stop/status/verify a canary fleet using `mosaic-factory`. - CLI can start/stop/status/verify a canary fleet using `mosaic-fleet`.
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling. - `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
- `mosaic agent reset` targets only the named agent session on the named socket. - `mosaic agent reset` targets only the named agent session on the named socket.
- Verification proves default tmux sessions remain untouched. - Verification proves default tmux sessions remain untouched.

View File

@@ -0,0 +1,32 @@
# #631 — re-seed must preserve user fleet data (CRITICAL data-loss)
- **Issue:** #631 · **Branch:** `fix/631-reseed-preserves-fleet-data`
## Root cause
`mosaic update` auto-runs `install.sh` keep-mode sync (#610). install.sh's rsync `--delete` (keep mode)
honored PRESERVE_PATHS, but `fleet/` wasn't listed → the sync WIPED `~/.config/mosaic/fleet/roster.yaml`
(+ run/, agents/). Any user running `mosaic update` lost their roster. (overwrite mode wipes by design;
the live loss was keep mode.)
## Fix (PRIMARY)
- install.sh PRESERVE_PATHS += `fleet/*.yaml`, `fleet/agents`, `fleet/run` — the framework still SEEDS
fleet/examples + fleet/roles + fleet/roster.schema.json (synced), but user files survive.
- Made the cp-fallback (no-rsync) GLOB-AWARE so `fleet/*.yaml` preserves every user roster there too;
fixed the restore to re-glob per-pattern (so only the user file is restored, not the whole fleet/ dir).
- file-adapter.ts (TS installer): mirrored the preserve list for parity. (TS syncDirectory is copy-only,
never --delete, so it never had the bug — belt-and-suspenders + parity.)
## Fix (SECONDARY)
- `refreshActiveFleetUnits()` (update-checker.ts): the re-seed updates ~/.config/mosaic/systemd/user but
systemd runs ~/.config/systemd/user, so unit fixes (#627) didn't take effect. After the re-seed,
`mosaic update` now copies the fresh mosaic-\*.service → the active dir + daemon-reload (best-effort,
only when a fleet is already installed). Wired into the cli.ts update flow.
## Verification
- bash F6 fixture (6 checks: roster/custom-yaml/agents/run survive + examples refreshed + schema seeded);
20/20 migration matrix green. TS file-adapter test (roster/run/agents survive keep sync). 2 unit tests
for refreshActiveFleetUnits. tsc/eslint/prettier/sanitize clean.

View File

@@ -0,0 +1,54 @@
# #633 — comms-block emitter + FLEET-LAUNCH runbook
Branch: `feat/633-comms-block-runbook` (off `bf2a6745`, post-#632 merge)
Issue: #633 · Follow-up filed: #636 (PATH B)
## Goal
PATH A of the orchestrator-launch fix: give every launch path the Fleet-Comms onboarding, and
document the canonical roster-driven launcher so the orchestrator stops being a bespoke snowflake.
## Deliverables
1. **`mosaic fleet comms-block <role> [--host <h>]`** — explicit-arg, comms-block-only emitter.
- Backed by new `resolveCommsBlock(mosaicHome, role, fleetHost?)` in `fleet/comms-onboarding.ts`
returning `{ ok, output, error }`.
- Unlike `readFleetCommsBlock` (returns `''` on any miss so `composeContract` can no-op silently
during launch), the emitter **fails loud**: unknown role / missing roster → `ok:false` → CLI
prints to stderr + sets `process.exitCode = 1`. A typo is never a silent no-op.
- Distinct from `mosaic compose-contract <runtime>` (whole prompt, env-coupled via
`MOSAIC_AGENT_NAME`); comms-block is the targeted, explicit-arg, comms-only view.
2. **`docs/fleet/FLEET-LAUNCH.md`** — worker path + orchestrator `.env` fold + 3 launch gotchas +
#632 preserve note + North-Star 4-field arc.
## Key findings (drove the design)
- `mosaic yolo claude` **already** forwards `--channels`/`--permission-mode` to the binary
(`launch.ts` claude case `cliArgs.push(...args)`) AND injects the comms block via
`composeContract``readFleetCommsBlock(home, env.MOSAIC_AGENT_NAME)`. So no `launch.ts` change
was needed — PATH A is `.env` + doc only.
- `start-agent-session.sh` line ~41 `[ -z "$MOSAIC_AGENT_COMMAND" ]` short-circuits the line-44
default, so an `.env` `MOSAIC_AGENT_COMMAND` override bypasses the hardcoded `yolo` entirely — the
yolo-conditional is therefore a PATH B (default-path) concern, not PATH A.
- `generateAgentEnv` (`fleet.ts` ~202-207) emits NAME/RUNTIME/MODEL but **not** `MOSAIC_AGENT_COMMAND`
— the seam PATH B (#636) closes.
## A → B → webUI arc (North Star)
- A = `.env` `MOSAIC_AGENT_COMMAND` hatch (manual, ships now, #632-safe).
- B (#636) = roster-native launch-config: harness ✅ + model ✅ already there; add **yolo** (line-44
conditional `MOSAIC_AGENT_YOLO`) + **command/channels** (`generateAgentEnv` emission).
- webUI binds dropdowns/toggles to those four roster fields. One launcher, no new launch path.
## Results
- TDD: spec first (`comms-onboarding.spec.ts`, 6 new `resolveCommsBlock` cases) → red → implement → green.
- `fleet.spec.ts` subcommand-list assertion extended with `comms-block`.
- 177 fleet+comms tests green; typecheck clean; eslint clean; prettier clean.
## Risks / notes
- Pre-existing local-only failure `uninstall.spec.ts > removeFramework > handles missing mosaicHome
gracefully` (EACCES on `/nonexistent` as non-root) — unrelated to #633, passes in CI as root.
- Did NOT run `mosaic update` / anything auto-reseed: installed CLI still 0.0.40 (roster-wipe live
until mos-claude-0 ships 0.0.41). All work is in-repo + vitest, never touches the live mosaic home.

View File

@@ -31,7 +31,7 @@ with a second agent on `dragon-lin`.
## Environment facts (verified 2026-06-20) ## Environment facts (verified 2026-06-20)
- Fleet is live on `W-jarvis` (uid 1000, `jarvis`, `Linger=yes`) on tmux socket - Fleet is live on `W-jarvis` (uid 1000, `jarvis`, `Linger=yes`) on tmux socket
`mosaic-factory`: `_holder`, `canary-pi`, `dogfood-coder`, `dogfood-orchestrator`, `mosaic-fleet`: `_holder`, `canary-pi`, `dogfood-coder`, `dogfood-orchestrator`,
`dogfood-reviewer`. All panes run `~/.config/mosaic/fleet/dogfood-agent.py` (stub), `dogfood-reviewer`. All panes run `~/.config/mosaic/fleet/dogfood-agent.py` (stub),
including `canary-pi` (roster says runtime=pi → **drift**). including `canary-pi` (roster says runtime=pi → **drift**).
- Holder + `mosaic-agent@*` units are `active (exited)` but `UnitFileState=disabled` - Holder + `mosaic-agent@*` units are `active (exited)` but `UnitFileState=disabled`
@@ -56,7 +56,7 @@ with a second agent on `dragon-lin`.
with dragon-lin coder, commit docs, begin Phase-2 delivery (heartbeat + `fleet ps`). with dragon-lin coder, commit docs, begin Phase-2 delivery (heartbeat + `fleet ps`).
- 2026-06-20 (session 2): Built Phase-2 CLI via worker (commit ab47831): `fleet ps`, - 2026-06-20 (session 2): Built Phase-2 CLI via worker (commit ab47831): `fleet ps`,
`agent watch`, `agent send --verify`, 62 tests. LIVE-verified `fleet ps` on `agent watch`, `agent send --verify`, 62 tests. LIVE-verified `fleet ps` on
mosaic-factory — correctly flagged canary-pi DRIFT + BOOT-ENABLE, tenant_id+host in JSON. mosaic-fleet — correctly flagged canary-pi DRIFT + BOOT-ENABLE, tenant_id+host in JSON.
Heartbeat responder added to dogfood-agent.py (FLEET-OBS-002) — `fleet ps` HB now Heartbeat responder added to dogfood-agent.py (FLEET-OBS-002) — `fleet ps` HB now
`healthy` for all 4 agents. `healthy` for all 4 agents.
- Coordination: dual-engine-reviewed (Claude+Codex) and merged framework PRs #572 - Coordination: dual-engine-reviewed (Claude+Codex) and merged framework PRs #572

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-fleet` + `ExecStop ${…:-mosaic-fleet}`; start-agent-session
defaulted `:-mosaic-fleet`. 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-fleet 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,19 @@
# north-star doctrine consolidation (#620-adjacent doc PR)
- **Branch:** `feat/north-star-doctrine` (off main). Source: Mos's consolidated handoff + 2 drafts (budgeting/200k/delegation + control-plane). ONE conflict-free PR per the merge-map.
## Applied (merge-map, in order)
1. Stack table: +2 rows (Central register, Budget/spend governance) after Control plane + PoC-socket-hygiene note.
2. `## Budget & token governance` after Invariants (even-spread pacing [Jason override], hard-cap ladder, multi-sub auto-routing, historical learning, #558 CLI UX) + TTY OPS INVARIANT note.
3. `## Control plane & central register` after Observation model (Postgres fleet schema, gateway-API access, dispatcher = forge pipeline engine + forge-exec adapter [NOT a daemon], register backs forge, board = forge BOD).
4. Phased roadmap Phase 4/5 annotated (fleet schema migration + forge-exec; central register live).
5. Decisions of record (2026-06-22): doctrine §1(c) bullets (200k cap, worker bound #8, delegation, budget, spend mandate, unified identity Fleet, role-based session naming) + control-plane 6c `### Control plane & central register` subgroup.
6. Future enhancements: Matrix-future-transport (#10, F4 IS Matrix) + tmux security hardening (§5).
7. Assumptions: doctrine §1(d) (3) + control-plane 6e (1) + release-procedure note + tracked-separately note.
## Conflict checklist: all ✓
1 Decisions-2026-06-22; order Invariants→Budget→Observation→Control plane→Roadmap; 2 stack rows; even-spread (no opportunistic/HOLD); control-plane UNHELD; forge-exec = tracked #628 post-PoC; §7 drift re-captures all present (#8/#10/#558/TTY/release).
## Out of scope (cited in doc + PR): #622 (spend template std), #623 (telemetry product), #625 (tenant_id schema), #628 (forge-exec adapter). Doctrine only — no implementation.

View File

@@ -28,6 +28,7 @@ export default tseslint.config(
'apps/web/e2e/helpers/*.ts', 'apps/web/e2e/helpers/*.ts',
'apps/web/playwright.config.ts', 'apps/web/playwright.config.ts',
'apps/gateway/vitest.config.ts', 'apps/gateway/vitest.config.ts',
'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts', 'packages/storage/vitest.config.ts',
'packages/mosaic/__tests__/*.ts', 'packages/mosaic/__tests__/*.ts',
'tools/federation-harness/*.ts', 'tools/federation-harness/*.ts',

View File

@@ -4,5 +4,22 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
// The migration suite spins up a real PGlite (WASM Postgres) instance per
// test and applies the full drizzle migration set. Each case legitimately
// takes ~5s locally and considerably longer on CI, where turbo runs many
// packages' test suites concurrently. The 5s vitest default then expires
// mid-migration and the run fails as a phantom "Test timed out in 5000ms"
// (often surfacing the underlying WASM `memory access out of bounds` when
// the heap is starved). Give migrations real headroom.
testTimeout: 120_000,
hookTimeout: 120_000,
// Each PGlite instance carries a multi-hundred-MB WASM heap. Running test
// files in parallel forks multiplies that peak and is what tips the CI
// runner into the WASM OOM. A single fork keeps only one instance resident
// at a time — slightly slower, but deterministic.
pool: 'forks',
poolOptions: {
forks: { singleFork: true },
},
}, },
}); });

View File

@@ -8,7 +8,7 @@ package, normally at:
~/.config/mosaic/fleet/roster.yaml ~/.config/mosaic/fleet/roster.yaml
``` ```
The default tmux socket is `mosaic-factory` so fleet commands do not touch the The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
default tmux server. default tmux server.
## Examples ## Examples

View File

@@ -1,7 +1,7 @@
version: 1 version: 1
transport: tmux transport: tmux
tmux: tmux:
socket_name: mosaic-factory socket_name: mosaic-fleet
holder_session: _holder holder_session: _holder
defaults: defaults:
working_directory: ~ working_directory: ~

View File

@@ -1,7 +1,7 @@
version: 1 version: 1
transport: tmux transport: tmux
tmux: tmux:
socket_name: mosaic-factory socket_name: mosaic-fleet
holder_session: _holder holder_session: _holder
defaults: defaults:
working_directory: ~ working_directory: ~

View File

@@ -1,7 +1,7 @@
version: 1 version: 1
transport: tmux transport: tmux
tmux: tmux:
socket_name: mosaic-factory socket_name: mosaic-fleet
holder_session: _holder holder_session: _holder
defaults: defaults:
working_directory: ~ working_directory: ~

View File

@@ -1,7 +1,7 @@
version: 1 version: 1
transport: tmux transport: tmux
tmux: tmux:
socket_name: mosaic-factory socket_name: mosaic-fleet
holder_session: _holder holder_session: _holder
defaults: defaults:
working_directory: ~/src working_directory: ~/src

View File

@@ -1,7 +1,7 @@
version: 1 version: 1
transport: tmux transport: tmux
tmux: tmux:
socket_name: mosaic-factory socket_name: mosaic-fleet
holder_session: _holder holder_session: _holder
defaults: defaults:
working_directory: ~/src working_directory: ~/src

View File

@@ -1,7 +1,7 @@
version: 1 version: 1
transport: tmux transport: tmux
tmux: tmux:
socket_name: mosaic-factory socket_name: mosaic-fleet
holder_session: _holder holder_session: _holder
defaults: defaults:
working_directory: ~ working_directory: ~

View File

@@ -18,11 +18,11 @@
"properties": { "properties": {
"socket_name": { "socket_name": {
"type": "string", "type": "string",
"default": "mosaic-factory" "default": "mosaic-fleet"
}, },
"socketName": { "socketName": {
"type": "string", "type": "string",
"default": "mosaic-factory" "default": "mosaic-fleet"
}, },
"holder_session": { "holder_session": {
"type": "string", "type": "string",

View File

@@ -23,7 +23,15 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by # entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned. # reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
# User-created content in these paths survives rsync --delete. # User-created content in these paths survives rsync --delete.
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials") #
# fleet/* — the framework SEEDS only fleet/examples, fleet/roles, and
# fleet/roster.schema.json (synced normally). The user's own fleet files MUST
# survive `mosaic update` (which runs this sync automatically): the active
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
# (`fleet/agents/`), and heartbeat run dir (`fleet/run/`). Without these, an
# update wipes the operator's fleet. Glob entries are honored by both the rsync
# path (`--exclude`) and the glob-aware cp fallback below.
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the # Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite). # user must not edit them; a divergent copy is backed up once before overwrite).
@@ -179,15 +187,23 @@ sync_framework() {
return return
fi fi
# Fallback: cp-based sync # Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
# every matching user file (parity with the rsync --exclude path above).
local preserve_tmp="" local preserve_tmp=""
if [[ "$INSTALL_MODE" == "keep" ]]; then if [[ "$INSTALL_MODE" == "keep" ]]; then
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")" preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
local match rel
for path in "${PRESERVE_PATHS[@]}"; do for path in "${PRESERVE_PATHS[@]}"; do
if [[ -e "$TARGET_DIR/$path" ]]; then # Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
mkdir -p "$preserve_tmp/$(dirname "$path")" # non-matching pattern vanish instead of staying literal.
cp -R "$TARGET_DIR/$path" "$preserve_tmp/$path" shopt -s nullglob
fi for match in "$TARGET_DIR/"$path; do
[[ -e "$match" ]] || continue
rel="${match#"$TARGET_DIR/"}"
mkdir -p "$preserve_tmp/$(dirname "$rel")"
cp -R "$match" "$preserve_tmp/$rel"
done
shopt -u nullglob
done done
fi fi
@@ -196,12 +212,19 @@ sync_framework() {
rm -rf "$TARGET_DIR/.git" rm -rf "$TARGET_DIR/.git"
if [[ -n "$preserve_tmp" ]]; then if [[ -n "$preserve_tmp" ]]; then
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
# preserved item is restored at its own relative path (e.g. only
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
for path in "${PRESERVE_PATHS[@]}"; do for path in "${PRESERVE_PATHS[@]}"; do
if [[ -e "$preserve_tmp/$path" ]]; then shopt -s nullglob
rm -rf "$TARGET_DIR/$path" for match in "$preserve_tmp/"$path; do
mkdir -p "$TARGET_DIR/$(dirname "$path")" [[ -e "$match" ]] || continue
cp -R "$preserve_tmp/$path" "$TARGET_DIR/$path" rel="${match#"$preserve_tmp/"}"
fi rm -rf "$TARGET_DIR/$rel"
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
cp -R "$match" "$TARGET_DIR/$rel"
done
shopt -u nullglob
done done
rm -rf "$preserve_tmp" rm -rf "$preserve_tmp"
fi fi

View File

@@ -33,7 +33,7 @@ Per-agent overrides live outside the package in:
Example: Example:
```dotenv ```dotenv
MOSAIC_TMUX_SOCKET=mosaic-factory MOSAIC_TMUX_SOCKET=mosaic-fleet
MOSAIC_AGENT_RUNTIME=claude MOSAIC_AGENT_RUNTIME=claude
MOSAIC_AGENT_WORKDIR=$HOME/src/your-project MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
# Optional escape hatch for PoC/canary agents: # Optional escape hatch for PoC/canary agents:
@@ -50,8 +50,8 @@ chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user start mosaic-tmux-holder.service systemctl --user start mosaic-tmux-holder.service
systemctl --user start mosaic-agent@canary.service systemctl --user start mosaic-agent@canary.service
tmux -L mosaic-factory ls tmux -L mosaic-fleet ls
``` ```
Do not use `tmux kill-server` without `-L mosaic-factory`; this pattern is meant Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
to avoid disturbing the user's default tmux server. to avoid disturbing the user's default tmux server.

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

@@ -6,7 +6,7 @@ After=default.target
[Service] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet
Environment=MOSAIC_TMUX_HOLDER=_holder Environment=MOSAIC_TMUX_HOLDER=_holder
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"' ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"'
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server' ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'

View File

@@ -2,8 +2,12 @@
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-fleet
# (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:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run} MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
@@ -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 ─────────────────────────────────────────
@@ -107,13 +123,13 @@ 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

View File

@@ -128,8 +128,8 @@ PY
merge_gitea_with_api() { merge_gitea_with_api() {
local host="$1" api_url token basic_auth body_file raw_code payload local host="$1" api_url token basic_auth body_file raw_code payload
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}" mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-api-response.XXXXXX") body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
payload='{"Do":"squash"}' payload='{"Do":"squash"}'
token=$(get_gitea_token "$host" || true) token=$(get_gitea_token "$host" || true)
@@ -214,8 +214,8 @@ case "$PLATFORM" in
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)" TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ -n "$TEA_LOGIN" ]]; then if [[ -n "$TEA_LOGIN" ]]; then
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}" mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-tea-error.XXXXXX") TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
rm -f "$TEA_ERROR_FILE" rm -f "$TEA_ERROR_FILE"
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then

View File

@@ -4,7 +4,7 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_ROOT="${AGENT_WORK_ROOT:-/home/hermes/agent-work}" WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$" SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
MOCK_BIN="$SANDBOX/bin" MOCK_BIN="$SANDBOX/bin"
REPO_DIR="$SANDBOX/repo" REPO_DIR="$SANDBOX/repo"

View File

@@ -61,7 +61,25 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]" chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'" chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" # F6 — keep-mode re-seed (the `mosaic update` path) MUST NOT wipe user fleet data.
# Regression for the roster-loss bug: fleet/ was not in PRESERVE_PATHS.
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
printf 'version: 1\nagents:\n - name: custom\n' > "$T6/fleet/my-fleet.yaml"
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
echo 3 > "$T6/.framework-version"
run "$T6" keep
chk "F6 reseed: user roster.yaml SURVIVES keep-mode sync" "grep -q coder0 '$T6/fleet/roster.yaml'"
chk "F6 reseed: other user fleet/*.yaml survives (glob)" "[ -f '$T6/fleet/my-fleet.yaml' ]"
chk "F6 reseed: per-agent env (fleet/agents) survives" "[ -f '$T6/fleet/agents/coder0.env' ]"
chk "F6 reseed: heartbeat run dir (fleet/run) survives" "[ -f '$T6/fleet/run/coder0.hb' ]"
chk "F6 reseed: framework examples ARE refreshed (not preserved stale)" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
chk "F6 reseed: framework roster.schema.json seeded" "[ -f '$T6/fleet/roster.schema.json' ]"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6"
echo echo
echo "RESULT: $pass passed, $fail failed" echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

View File

@@ -2,12 +2,20 @@
when: when:
- event: [push, pull_request, manual] - event: [push, pull_request, manual]
# Dependencies are installed ONCE in the `install` step and every downstream
# step depends on it, reusing the populated node_modules from the shared
# workspace volume. Do NOT re-run `npm ci` per step — that pays the full cold
# install (network fetch + native rebuilds) N times and is the dominant cost
# in a pipeline.
#
# For best results, replace `&node_image` with a pre-baked CI base image that
# ships your toolchain (python3/make/g++ for native modules) and a warm npm
# cache, then keep `--prefer-offline` so installs resolve from the cache. See
# the Mosaic Stack repo's Dockerfile.ci + .woodpecker/ci-image.yml for the
# baked-image pattern.
variables: variables:
- &node_image 'node:20-alpine' - &node_image 'node:20-alpine'
- &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0' - &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0'
- &install_deps |
corepack enable
npm ci --ignore-scripts
steps: steps:
# Secret scanning (runs in parallel with install, no deps) # Secret scanning (runs in parallel with install, no deps)
@@ -17,15 +25,18 @@ steps:
- gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD" - gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD"
depends_on: [] depends_on: []
# Single cached install. Every other step depends on this and reuses the
# node_modules it produces in the shared workspace.
install: install:
image: *node_image image: *node_image
commands: commands:
- *install_deps - corepack enable
- npm ci --ignore-scripts --prefer-offline
depends_on: []
security-audit: security-audit:
image: *node_image image: *node_image
commands: commands:
- *install_deps
- npm audit --audit-level=high - npm audit --audit-level=high
depends_on: depends_on:
- install - install
@@ -35,7 +46,6 @@ steps:
environment: environment:
SKIP_ENV_VALIDATION: 'true' SKIP_ENV_VALIDATION: 'true'
commands: commands:
- *install_deps
- npm run lint - npm run lint
depends_on: depends_on:
- install - install
@@ -45,7 +55,6 @@ steps:
environment: environment:
SKIP_ENV_VALIDATION: 'true' SKIP_ENV_VALIDATION: 'true'
commands: commands:
- *install_deps
- npm run type-check - npm run type-check
depends_on: depends_on:
- install - install
@@ -55,7 +64,6 @@ steps:
environment: environment:
SKIP_ENV_VALIDATION: 'true' SKIP_ENV_VALIDATION: 'true'
commands: commands:
- *install_deps
- npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}' - npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
depends_on: depends_on:
- install - install
@@ -66,7 +74,6 @@ steps:
SKIP_ENV_VALIDATION: 'true' SKIP_ENV_VALIDATION: 'true'
NODE_ENV: 'production' NODE_ENV: 'production'
commands: commands:
- *install_deps
- npm run build - npm run build
depends_on: depends_on:
- lint - lint

View File

@@ -35,7 +35,7 @@ delivers reliably to local OR remote panes.
agent-send.sh -s <dst_session> -m "message" agent-send.sh -s <dst_session> -m "message"
# Local target on a Mosaic fleet socket # Local target on a Mosaic fleet socket
agent-send.sh -L mosaic-factory -s '=coder0' -m "message" agent-send.sh -L mosaic-fleet -s '=coder0' -m "message"
# Remote target (over ssh) # Remote target (over ssh)
agent-send.sh -H user@host -s <dst_session> -m "message" agent-send.sh -H user@host -s <dst_session> -m "message"
@@ -58,9 +58,9 @@ commands do not fall back to tmux's prefix matching behavior.
Durable Mosaic fleets should use a dedicated tmux socket, for example: Durable Mosaic fleets should use a dedicated tmux socket, for example:
```bash ```bash
tmux -L mosaic-factory ls tmux -L mosaic-fleet ls
agent-send.sh -L mosaic-factory -s '=coder0' -m "status?" agent-send.sh -L mosaic-fleet -s '=coder0' -m "status?"
send-message.sh -L mosaic-factory -t '=coder0' -m "raw pane message" send-message.sh -L mosaic-fleet -t '=coder0' -m "raw pane message"
``` ```
This keeps fleet operations away from the user's default tmux server. It is the This keeps fleet operations away from the user's default tmux server. It is the

View File

@@ -12,6 +12,10 @@
# ambiguity about lanes or origin. Recipients replying should FLIP the # ambiguity about lanes or origin. Recipients replying should FLIP the
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply). # preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
# #
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
# field instead of re-deriving intent from the body.
#
# WHY A WRAPPER # WHY A WRAPPER
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly: # Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
# a trailing Enter is often swallowed and the message sits as an unsubmitted # a trailing Enter is often swallowed and the message sits as an unsubmitted
@@ -26,6 +30,7 @@
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target # agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target # agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt # agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received"
# echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session> # echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
# #
# OPTIONS # OPTIONS
@@ -36,27 +41,61 @@
# Default: local hostname, or (remote) resolved via one ssh. # Default: local hostname, or (remote) resolved via one ssh.
# -m MESSAGE message text (single- or multi-line) # -m MESSAGE message text (single- or multi-line)
# -f FILE read message from FILE instead of -m # -f FILE read message from FILE instead of -m
# -C CLASS triage class for a comms daemon. One of:
# terminal-log log-only; never needs the agent's attention
# actionable carries a decision/blocker/gate — deliver
# human from a human operator — deliver
# reaction an emoji/ack reaction
# Long form: --class CLASS (or --class=CLASS). When SET, the
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
# [<src> -> <dst> class=terminal-log] <message>
# When OMITTED, NO token is emitted and the preamble is
# byte-for-byte identical to the classic format. Consumers MUST
# treat an absent class as 'actionable' (fail-safe: agent sees it).
# -S SRC_LABEL override source label "<host>:<session>" (default: auto) # -S SRC_LABEL override source label "<host>:<session>" (default: auto)
# -r N Enter-flush attempts passed through (default 2) # -r N Enter-flush attempts passed through (default 2)
# -v verbose: print pane tail after delivery # -v verbose: print pane tail after delivery
# -h help # -h help
# #
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction))?\] (.*)$
# group 1 = src label group 2 = dst host:session
# group 3 = class (absent => actionable) group 4 = message body
#
# EXIT CODES (passed through from send-message.sh) # EXIT CODES (passed through from send-message.sh)
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error # 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
set -uo pipefail set -uo pipefail
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd) SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
SENDER="$SELF_DIR/send-message.sh" # Sender is overridable via env purely for testing (inject a capture stub). The
# default is the canonical send-message.sh beside this script; production callers
# never set AGENT_SEND_SENDER, so behavior is unchanged.
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
# Translate the long option --class[=value] into "-C value" so getopts (which is
# short-option-only) can parse it. Every other argument passes through untouched,
# so callers that never use --class hit the exact original getopts path.
args=()
while [ $# -gt 0 ]; do
case "$1" in
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
args+=(-C "$2"); shift 2 ;;
--class=*) args+=(-C "${1#*=}"); shift ;;
*) args+=("$1"); shift ;;
esac
done
set -- ${args[@]+"${args[@]}"}
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME="" DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
SRC_LABEL=""; RETRIES=2; VERBOSE=0 SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; } usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
while getopts "L:s:H:n:m:f:S:r:vh" o; do while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
case "$o" in case "$o" in
L) SOCKET_NAME=$OPTARG ;; L) SOCKET_NAME=$OPTARG ;;
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;; s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
C) CLASS=$OPTARG ;;
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;; r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
esac esac
done done
@@ -64,6 +103,17 @@ done
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; } [ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; } [ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
# Validate the triage class only when one was given. An absent class emits NO
# token (preamble byte-identical to the classic format); the consumer defaults
# absent => actionable.
CLASS_TOKEN=""
if [ -n "$CLASS" ]; then
case "$CLASS" in
terminal-log|actionable|human|reaction) CLASS_TOKEN=" class=${CLASS}" ;;
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction)" >&2; exit 3 ;;
esac
fi
# Message body from -f / -m / stdin. # Message body from -f / -m / stdin.
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE") if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat) elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
@@ -90,7 +140,7 @@ if [ -z "$DST_HOST" ]; then
fi fi
fi fi
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}]" PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
FULL="${PREAMBLE} ${MSG}" FULL="${PREAMBLE} ${MSG}"
B64=$(printf '%s' "$FULL" | base64 -w0) B64=$(printf '%s' "$FULL" | base64 -w0)

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
#
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
# base64 payload and prints the FULL message (preamble + body) so we can assert
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
# the preamble is deterministic across machines.
#
# Guarantees locked here:
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
# 3. --class=<c> (equals form) parses identically to the space form.
# 4. -C <c> short form parses identically.
# 5. invalid class => exit 3, nothing sent.
# 6. --class with no value => exit 3.
# 7. the documented consumer regex parses producer output for every class.
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
TOOL="$HERE/agent-send.sh"
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
STUB=$(mktemp)
trap 'rm -f "$STUB"' EXIT
cat >"$STUB" <<'STUB_EOF'
#!/usr/bin/env bash
set -uo pipefail
b64=""
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
printf '%s' "$b64" | base64 -d
STUB_EOF
chmod +x "$STUB"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
# Documented consumer grammar — the daemon will mirror exactly this.
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
got=$(run -s mos -m "hello world")
want='[a:src -> dsthost:mos] hello world'
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
# 2. --class space form emits the token.
got=$(run -s mos --class terminal-log -m "ACK")
want='[a:src -> dsthost:mos class=terminal-log] ACK'
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
# 3. --class=value equals form.
got=$(run -s mos --class=actionable -m "decide X")
want='[a:src -> dsthost:mos class=actionable] decide X'
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
# 4. -C short form.
got=$(run -s mos -C human -m "from a person")
want='[a:src -> dsthost:mos class=human] from a person'
[ "$got" = "$want" ] && ok "-C human (short form)" \
|| no "-C human (short form)" "got=[$got] want=[$want]"
# 5. invalid class => exit 3, no send.
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
else
rc=$?
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
fi
# 6. --class with no value => exit 3.
if run -s mos -m "x" --class 2>/dev/null; then
no "--class with no value rejected" "expected non-zero exit, got 0"
else
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
fi
# 7. consumer grammar parses every class + classic line.
for c in terminal-log actionable human reaction; do
line=$(run -s mos --class "$c" -m "body $c")
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
done
classic=$(run -s mos -m "plain body")
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View File

@@ -1,12 +1,12 @@
{ {
"name": "@mosaicstack/mosaic", "name": "@mosaicstack/mosaic",
"version": "0.0.40", "version": "0.0.41",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/mosaic" "directory": "packages/mosaic"
}, },
"description": "Mosaic agent framework \u2014 installation wizard and meta package", "description": "Mosaic agent framework installation wizard and meta package",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -27,6 +27,7 @@ import {
formatAllPackagesTable, formatAllPackagesTable,
getInstallAllCommand, getInstallAllCommand,
runFrameworkReseed, runFrameworkReseed,
refreshActiveFleetUnits,
readRosterAgentNames, readRosterAgentNames,
buildRelaunchCommands, buildRelaunchCommands,
FRAMEWORK_RESEED_PACKAGE, FRAMEWORK_RESEED_PACKAGE,
@@ -466,6 +467,12 @@ program
const reseed = runFrameworkReseed(); const reseed = runFrameworkReseed();
if (reseed.ok) { if (reseed.ok) {
console.log('✔ Framework re-seeded.'); console.log('✔ Framework re-seeded.');
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
const units = refreshActiveFleetUnits();
if (units.refreshed.length > 0) {
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
}
const agents = readRosterAgentNames(); const agents = readRosterAgentNames();
if (agents.length > 0) { if (agents.length > 0) {
if (opts.relaunch) { if (opts.relaunch) {

View File

@@ -15,6 +15,7 @@ import {
buildFleetServiceCommand, buildFleetServiceCommand,
buildSystemdEnableCommand, buildSystemdEnableCommand,
buildSystemdDisableCommand, buildSystemdDisableCommand,
socketArgs,
buildSystemdShowCommand, buildSystemdShowCommand,
buildTmuxListPanesCommand, buildTmuxListPanesCommand,
buildTmuxListSessionsCommand, buildTmuxListSessionsCommand,
@@ -94,6 +95,7 @@ describe('registerFleetCommand', () => {
expect(agent).toBeDefined(); expect(agent).toBeDefined();
expect(agent!.options.map((option) => option.long)).toContain('--list'); expect(agent!.options.map((option) => option.long)).toContain('--list');
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([ expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
'comms-block',
'reset', 'reset',
'roster', 'roster',
'send', 'send',
@@ -114,7 +116,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(
@@ -131,12 +133,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-fleet
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-fleet')).toEqual(['-L', 'mosaic-fleet']);
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-fleet',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
const roster = await loadFleetRoster(rosterPath);
expect(roster.tmux.socketName).toBe('mosaic-fleet');
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');
@@ -145,7 +190,7 @@ describe('fleet roster parsing', () => {
JSON.stringify({ JSON.stringify({
version: 1, version: 1,
transport: 'tmux', transport: 'tmux',
tmux: { socket_name: 'mosaic-factory' }, tmux: { socket_name: 'mosaic-fleet' },
defaults: { working_directory: '/srv/mosaic' }, defaults: { working_directory: '/srv/mosaic' },
agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }], agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }],
}), }),
@@ -156,8 +201,9 @@ 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-fleet',
'', '',
].join('\n'), ].join('\n'),
); );
@@ -168,7 +214,7 @@ describe('fleet roster parsing', () => {
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/new', 'MOSAIC_AGENT_WORKDIR=/srv/new',
'MOSAIC_TMUX_SOCKET=mosaic-factory', 'MOSAIC_TMUX_SOCKET=mosaic-fleet',
'', '',
].join('\n'); ].join('\n');
const existing = [ const existing = [
@@ -186,7 +232,7 @@ describe('fleet roster parsing', () => {
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/new', 'MOSAIC_AGENT_WORKDIR=/srv/new',
'MOSAIC_TMUX_SOCKET=mosaic-factory', 'MOSAIC_TMUX_SOCKET=mosaic-fleet',
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh', 'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
'# site note', '# site note',
'', '',
@@ -279,7 +325,7 @@ describe('fleet roster parsing', () => {
const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml')); const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml'));
expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']); expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']);
expect(localCanary.tmux.socketName).toBe('mosaic-factory'); expect(localCanary.tmux.socketName).toBe('mosaic-fleet');
expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']); expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']);
expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i); expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i);
}); });
@@ -304,11 +350,11 @@ describe('fleet command construction', () => {
it('builds socket-scoped agent send commands', () => { it('builds socket-scoped agent send commands', () => {
const paths = resolveFleetPaths('/home/test/.config/mosaic'); const paths = resolveFleetPaths('/home/test/.config/mosaic');
expect( expect(
buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory', 'operator:mosaic-cli'), buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-fleet', 'operator:mosaic-cli'),
).toEqual([ ).toEqual([
'/home/test/.config/mosaic/tools/tmux/agent-send.sh', '/home/test/.config/mosaic/tools/tmux/agent-send.sh',
'-L', '-L',
'mosaic-factory', 'mosaic-fleet',
'-S', '-S',
'operator:mosaic-cli', 'operator:mosaic-cli',
'-s', '-s',
@@ -355,8 +401,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 });
@@ -637,7 +684,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 });
@@ -677,8 +724,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',
@@ -727,8 +772,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',
@@ -799,10 +842,10 @@ describe('fleet ps — command construction', () => {
}); });
it('builds exact tmux list-panes command with the correct format string', () => { it('builds exact tmux list-panes command with the correct format string', () => {
expect(buildTmuxListPanesCommand('canary-pi', 'mosaic-factory')).toEqual([ expect(buildTmuxListPanesCommand('canary-pi', 'mosaic-fleet')).toEqual([
'tmux', 'tmux',
'-L', '-L',
'mosaic-factory', 'mosaic-fleet',
'list-panes', 'list-panes',
'-t', '-t',
'=canary-pi:0.0', '=canary-pi:0.0',
@@ -811,9 +854,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/', () => {
@@ -1123,7 +1168,7 @@ describe('fleet install — auto-enable units for boot-survival', () => {
const minimalRoster: FleetRoster = { const minimalRoster: FleetRoster = {
version: 1, version: 1,
transport: 'tmux', transport: 'tmux',
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' }, tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '~/src' }, defaults: { workingDirectory: '~/src' },
runtimes: { codex: { resetCommand: '/clear' } }, runtimes: { codex: { resetCommand: '/clear' } },
agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }], agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
@@ -1145,7 +1190,7 @@ describe('fleet install — auto-enable units for boot-survival', () => {
const minimalRoster: FleetRoster = { const minimalRoster: FleetRoster = {
version: 1, version: 1,
transport: 'tmux', transport: 'tmux',
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' }, tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '~/src' }, defaults: { workingDirectory: '~/src' },
runtimes: { codex: { resetCommand: '/clear' } }, runtimes: { codex: { resetCommand: '/clear' } },
agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }], agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
@@ -1172,7 +1217,7 @@ describe('fleet install — auto-enable units for boot-survival', () => {
const minimalRoster: FleetRoster = { const minimalRoster: FleetRoster = {
version: 1, version: 1,
transport: 'tmux', transport: 'tmux',
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' }, tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '~/src' }, defaults: { workingDirectory: '~/src' },
runtimes: { codex: { resetCommand: '/clear' } }, runtimes: { codex: { resetCommand: '/clear' } },
agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }], agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
@@ -1331,8 +1376,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;
@@ -1343,19 +1389,20 @@ describe('fleet ps — command sequences issued', () => {
describe('buildTmuxListSessionsCommand', () => { describe('buildTmuxListSessionsCommand', () => {
it('builds exact list-sessions command with session_name format', () => { it('builds exact list-sessions command with session_name format', () => {
expect(buildTmuxListSessionsCommand('mosaic-factory')).toEqual([ expect(buildTmuxListSessionsCommand('mosaic-fleet')).toEqual([
'tmux', 'tmux',
'-L', '-L',
'mosaic-factory', 'mosaic-fleet',
'list-sessions', 'list-sessions',
'-F', '-F',
'#{session_name}', '#{session_name}',
]); ]);
}); });
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}']);
}); });
}); });
@@ -1596,11 +1643,11 @@ describe('fleet ps — unmanaged socket sessions', () => {
describe('agent watch', () => { describe('agent watch', () => {
it('builds exact grouped-viewer creation command', () => { it('builds exact grouped-viewer creation command', () => {
expect( expect(
buildAgentWatchCreateViewerCommand('canary-pi', 'canary-pi-watch-123', 'mosaic-factory'), buildAgentWatchCreateViewerCommand('canary-pi', 'canary-pi-watch-123', 'mosaic-fleet'),
).toEqual([ ).toEqual([
'tmux', 'tmux',
'-L', '-L',
'mosaic-factory', 'mosaic-fleet',
'new-session', 'new-session',
'-d', '-d',
'-t', '-t',
@@ -1611,10 +1658,10 @@ describe('agent watch', () => {
}); });
it('builds exact viewer attach command (read-only)', () => { it('builds exact viewer attach command (read-only)', () => {
expect(buildAgentWatchAttachCommand('canary-pi-watch-123', 'mosaic-factory')).toEqual([ expect(buildAgentWatchAttachCommand('canary-pi-watch-123', 'mosaic-fleet')).toEqual([
'tmux', 'tmux',
'-L', '-L',
'mosaic-factory', 'mosaic-fleet',
'attach', 'attach',
'-r', '-r',
'-t', '-t',
@@ -1623,19 +1670,20 @@ describe('agent watch', () => {
}); });
it('builds exact viewer kill command', () => { it('builds exact viewer kill command', () => {
expect(buildAgentWatchKillViewerCommand('canary-pi-watch-123', 'mosaic-factory')).toEqual([ expect(buildAgentWatchKillViewerCommand('canary-pi-watch-123', 'mosaic-fleet')).toEqual([
'tmux', 'tmux',
'-L', '-L',
'mosaic-factory', 'mosaic-fleet',
'kill-session', 'kill-session',
'-t', '-t',
'canary-pi-watch-123', 'canary-pi-watch-123',
]); ]);
}); });
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');
}); });
@@ -1722,10 +1770,10 @@ describe('agent watch', () => {
describe('agent send --verify', () => { describe('agent send --verify', () => {
it('builds exact verify capture-pane command', () => { it('builds exact verify capture-pane command', () => {
expect(buildAgentVerifyAcceptedCommand('canary-pi', 'mosaic-factory', 5)).toEqual([ expect(buildAgentVerifyAcceptedCommand('canary-pi', 'mosaic-fleet', 5)).toEqual([
'tmux', 'tmux',
'-L', '-L',
'mosaic-factory', 'mosaic-fleet',
'capture-pane', 'capture-pane',
'-t', '-t',
'=canary-pi:0.0', '=canary-pi:0.0',
@@ -1829,9 +1877,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 });
} }
@@ -2436,7 +2485,7 @@ describe('fleet add/remove — pure helpers', () => {
const baseRoster: FleetRoster = { const baseRoster: FleetRoster = {
version: 1, version: 1,
transport: 'tmux', transport: 'tmux',
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' }, tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '~/src' }, defaults: { workingDirectory: '~/src' },
runtimes: { codex: { resetCommand: '/clear' } }, runtimes: { codex: { resetCommand: '/clear' } },
agents: [ agents: [
@@ -2562,7 +2611,7 @@ describe('fleet add/remove — pure helpers', () => {
await writeFile(rosterPath, yaml); await writeFile(rosterPath, yaml);
const loaded = await loadFleetRoster(rosterPath); const loaded = await loadFleetRoster(rosterPath);
expect(loaded.agents.map((a) => a.name)).toEqual(['orchestrator', 'coder0']); expect(loaded.agents.map((a) => a.name)).toEqual(['orchestrator', 'coder0']);
expect(loaded.tmux.socketName).toBe('mosaic-factory'); expect(loaded.tmux.socketName).toBe('mosaic-fleet');
expect(loaded.agents[0]!.className).toBe('orchestrator'); expect(loaded.agents[0]!.className).toBe('orchestrator');
} finally { } finally {
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });

View File

@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process';
import * as readline from 'node:readline'; import * as readline from 'node:readline';
import type { Command } from 'commander'; import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
/** /**
* A function that spawns a command with inherited stdio (TTY passthrough). * A function that spawns a command with inherited stdio (TTY passthrough).
@@ -117,10 +118,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-fleet';
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-fleet; 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.
@@ -185,6 +202,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)}`,
'', '',
@@ -319,13 +340,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',
@@ -344,12 +364,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',
@@ -357,15 +376,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`,
@@ -449,14 +463,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`,
@@ -470,8 +480,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}'];
} }
/** /**
@@ -653,12 +663,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',
@@ -672,11 +681,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];
} }
/** /**
@@ -685,9 +691,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];
} }
/** /**
@@ -705,11 +711,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}`];
} }
/** /**
@@ -719,13 +722,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`,
@@ -989,8 +991,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`,
@@ -998,8 +999,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`,
@@ -1360,6 +1360,23 @@ export function registerFleetAgentCommands(
} }
}); });
agentCommand
.command('comms-block <role>')
.description(
"Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)",
)
.option('--host <host>', 'Override the fleet host (preview a cross-host peer view)')
.action((role: string, opts: { host?: string }) => {
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
const res = resolveCommsBlock(mosaicHome, role, opts.host);
if (!res.ok) {
console.error(`[mosaic] comms-block: ${res.error}`);
process.exitCode = 1;
return;
}
console.log(res.output);
});
agentCommand agentCommand
.command('status [agent]') .command('status [agent]')
.description('Show tmux status for the local fleet or one agent') .description('Show tmux status for the local fleet or one agent')
@@ -1370,8 +1387,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(
@@ -1689,9 +1706,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-fleet. 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(
@@ -1857,6 +1877,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;
} }

View File

@@ -153,6 +153,30 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n'); expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
}); });
it('preserves user fleet data (roster.yaml, agents/, run/) through a keep-mode sync', async () => {
// Regression for the roster-loss bug (#631): user-authored fleet files must
// survive the framework re-seed that `mosaic update` runs.
mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true });
mkdirSync(join(fixture.mosaicHome, 'fleet', 'agents'), { recursive: true });
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'version: 1\nMINE\n');
writeFileSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'), 'ts=x\n');
writeFileSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'), 'X=1\n');
// The framework ships fleet/examples — it should still seed/refresh.
mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true });
writeFileSync(join(fixture.sourceDir, 'fleet', 'examples', 'general.yaml'), '# preset\n');
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
await adapter.syncFramework('keep');
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe(
'version: 1\nMINE\n',
);
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'))).toBe(true);
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'))).toBe(true);
// framework-owned fleet/examples is seeded
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'examples', 'general.yaml'))).toBe(true);
});
it('is a no-op for seeding when defaults/ dir does not exist', async () => { it('is a no-op for seeding when defaults/ dir does not exist', async () => {
rmSync(fixture.defaultsDir, { recursive: true }); rmSync(fixture.defaultsDir, { recursive: true });

View File

@@ -173,6 +173,13 @@ export class FileConfigAdapter implements ConfigService {
'memory', 'memory',
'sources', 'sources',
'credentials', 'credentials',
// User-authored fleet data MUST survive `mosaic update`'s re-seed.
// The framework seeds only fleet/examples + fleet/roles +
// fleet/roster.schema.json; the operator's roster, per-agent env, and
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
'fleet/*.yaml',
'fleet/agents',
'fleet/run',
] ]
: []; : [];

View File

@@ -7,6 +7,7 @@ import {
buildFleetCommsBlock, buildFleetCommsBlock,
renderPeerReach, renderPeerReach,
readFleetCommsBlock, readFleetCommsBlock,
resolveCommsBlock,
type CommsPeer, type CommsPeer,
} from './comms-onboarding.js'; } from './comms-onboarding.js';
@@ -48,9 +49,9 @@ describe('parseRosterAgents', () => {
it('parses an optional per-agent socket', () => { it('parses an optional per-agent socket', () => {
const peers = parseRosterAgents( const peers = parseRosterAgents(
['agents:', ' - name: a', ' class: worker', ' socket: mosaic-factory'].join('\n'), ['agents:', ' - name: a', ' class: worker', ' socket: mosaic-fleet'].join('\n'),
); );
expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-factory' }); expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-fleet' });
}); });
it('stops at the next top-level key', () => { it('stops at the next top-level key', () => {
@@ -99,9 +100,9 @@ describe('renderPeerReach — same-host vs cross-host', () => {
}); });
it('emits -L <socket> for a named socket', () => { it('emits -L <socket> for a named socket', () => {
const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-factory' }; const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-fleet' };
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe( expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
`${send} -L mosaic-factory -s coder0 -m "…"`, `${send} -L mosaic-fleet -s coder0 -m "…"`,
); );
}); });
@@ -111,10 +112,10 @@ describe('renderPeerReach — same-host vs cross-host', () => {
className: 'implementer', className: 'implementer',
host: '10.1.10.37', host: '10.1.10.37',
ssh: 'jwoltje@10.1.10.37', ssh: 'jwoltje@10.1.10.37',
socket: 'mosaic-factory', socket: 'mosaic-fleet',
}; };
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe( expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
`${send} -L mosaic-factory -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`, `${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
); );
}); });
}); });
@@ -185,3 +186,53 @@ describe('readFleetCommsBlock — situational (the context a spawned agent gets)
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe(''); expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
}); });
}); });
describe('resolveCommsBlock — `mosaic fleet comms-block <role>` emitter semantics', () => {
// The emitter wraps readFleetCommsBlock but must NEVER print an empty string silently:
// an unknown role / missing roster has to fail loud (caller maps !ok → stderr + exit 1)
// so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The
// success path returns the block verbatim for `mosaic fleet comms-block <peer>` previews.
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
mkdirSync(join(home, 'fleet'), { recursive: true });
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
it('returns ok + the cheat-sheet for a roster member', () => {
const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis');
expect(res.ok).toBe(true);
expect(res.output).toContain('# Fleet Comms');
expect(res.output).toContain('| enhancer |');
expect(res.error).toBeUndefined();
});
it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => {
const res = resolveCommsBlock(home, 'stranger', 'w-jarvis');
expect(res.ok).toBe(false);
expect(res.output).toBe('');
expect(res.error).toContain('stranger');
});
it('fails loud when no roster exists at the mosaic home', () => {
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis');
expect(res.ok).toBe(false);
expect(res.error).toBeTruthy();
rmSync(noRoster, { recursive: true, force: true });
});
it('fails loud for a missing role argument', () => {
const res = resolveCommsBlock(home, undefined, 'w-jarvis');
expect(res.ok).toBe(false);
expect(res.error).toBeTruthy();
});
it('honors a host override so a peer can preview its own cross-host view', () => {
// coder0-0 viewing with its own host → its self-identity line uses that host.
const res = resolveCommsBlock(home, 'coder0-0', '10.1.10.37');
expect(res.ok).toBe(true);
expect(res.output).toContain('`[10.1.10.37:coder0-0]`');
});
});

View File

@@ -179,5 +179,48 @@ export function readFleetCommsBlock(
}); });
} }
/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */
export interface CommsBlockResult {
/** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */
ok: boolean;
/** The Fleet-Comms cheat-sheet (empty unless ok). */
output: string;
/** Operator-facing reason when !ok. */
error?: string;
}
/**
* Resolve the Fleet-Comms cheat-sheet for an explicit <role>, backing the
* `mosaic fleet comms-block <role>` command. Unlike readFleetCommsBlock — which
* returns '' on any miss so composeContract can no-op silently during a launch —
* this NEVER silently emits empty: an unknown role or missing roster yields
* ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1)
* rather than printing nothing. That makes it safe to preview any peer's view,
* e.g. `mosaic fleet comms-block coder0-0`.
*/
export function resolveCommsBlock(
mosaicHome: string,
role: string | undefined,
fleetHost?: string,
): CommsBlockResult {
if (!role) {
return { ok: false, output: '', error: 'comms-block requires a <role> argument' };
}
const block = fleetHost
? readFleetCommsBlock(mosaicHome, role, fleetHost)
: readFleetCommsBlock(mosaicHome, role);
if (!block) {
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
return {
ok: false,
output: '',
error: existsSync(rosterPath)
? `role "${role}" is not a member of the fleet roster at ${rosterPath}`
: `no fleet roster at ${rosterPath}`,
};
}
return { ok: true, output: block };
}
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */ /** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic'); export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');

View File

@@ -7,7 +7,9 @@ import {
buildRelaunchCommands, buildRelaunchCommands,
readRosterAgentNames, readRosterAgentNames,
runFrameworkReseed, runFrameworkReseed,
refreshActiveFleetUnits,
} from './update-checker.js'; } from './update-checker.js';
import { existsSync, readFileSync } from 'node:fs';
/** /**
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches * F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
@@ -83,3 +85,41 @@ describe('runFrameworkReseed', () => {
rmSync(missing, { recursive: true, force: true }); rmSync(missing, { recursive: true, force: true });
}); });
}); });
describe('refreshActiveFleetUnits', () => {
let root: string;
let mosaicHome: string;
let configHome: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-units-'));
mosaicHome = join(root, 'mosaic');
configHome = join(root, 'config');
mkdirSync(join(mosaicHome, 'systemd', 'user'), { recursive: true });
mkdirSync(join(configHome, 'systemd', 'user'), { recursive: true });
// Freshly re-seeded units (new content).
writeFileSync(join(mosaicHome, 'systemd', 'user', 'mosaic-agent@.service'), 'NEW\n');
writeFileSync(join(mosaicHome, 'systemd', 'user', 'mosaic-tmux-holder.service'), 'NEW\n');
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it('refreshes active units when a fleet is already installed', () => {
// Active dir already carries mosaic units (stale) → fleet is installed.
writeFileSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'), 'OLD\n');
const res = refreshActiveFleetUnits(mosaicHome, {
XDG_CONFIG_HOME: configHome,
} as NodeJS.ProcessEnv);
expect(res.refreshed).toContain('mosaic-agent@.service');
expect(
readFileSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'), 'utf-8'),
).toBe('NEW\n');
});
it('is a no-op when no fleet is installed (active dir has no mosaic units)', () => {
const res = refreshActiveFleetUnits(mosaicHome, {
XDG_CONFIG_HOME: configHome,
} as NodeJS.ProcessEnv);
expect(res.refreshed).toEqual([]);
expect(existsSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'))).toBe(false);
});
});

View File

@@ -14,7 +14,14 @@
*/ */
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,
readdirSync,
copyFileSync,
} from 'node:fs';
import { homedir } from 'node:os'; import { homedir } 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';
@@ -536,6 +543,47 @@ export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mo
return names; return names;
} }
/**
* Refresh the ACTIVE systemd user units from the freshly re-seeded copies.
*
* The re-seed updates `~/.config/mosaic/systemd/user/*.service`, but the units
* systemd actually runs live at `~/.config/systemd/user/`. Without this copy,
* shipped unit fixes (e.g. the socket-env change) never take effect after
* `mosaic update` until `mosaic fleet install` is re-run. Best-effort + scoped:
* only refreshes when a fleet is already installed (the active dir already
* carries `mosaic-*` units), so non-fleet hosts are untouched.
*/
export function refreshActiveFleetUnits(
mosaicHome = join(homedir(), '.config', 'mosaic'),
env: NodeJS.ProcessEnv = process.env,
): { refreshed: string[]; ok: boolean; reason?: string } {
const src = join(mosaicHome, 'systemd', 'user');
const configHome = env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
const dest = join(configHome, 'systemd', 'user');
if (!existsSync(src)) return { refreshed: [], ok: true };
// Only refresh when a fleet is already installed (active dir has mosaic units).
const fleetInstalled =
existsSync(dest) &&
readdirSync(dest).some((f) => f.startsWith('mosaic-') && f.endsWith('.service'));
if (!fleetInstalled) return { refreshed: [], ok: true };
const units = readdirSync(src).filter((f) => f.startsWith('mosaic-') && f.endsWith('.service'));
const refreshed: string[] = [];
for (const unit of units) {
try {
copyFileSync(join(src, unit), join(dest, unit));
refreshed.push(unit);
} catch {
// best-effort per unit
}
}
try {
execSync('systemctl --user daemon-reload', { stdio: 'ignore', timeout: 15_000 });
} catch {
// non-systemd host or no session bus — non-fatal
}
return { refreshed, ok: true };
}
/** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */ /** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */
export function buildRelaunchCommands(agentNames: string[]): string[][] { export function buildRelaunchCommands(agentNames: string[]): string[][] {
return agentNames.map((name) => [ return agentNames.map((name) => [