Compare commits

..

6 Commits

Author SHA1 Message Date
Jarvis
e7b184b0e2 fix(fleet): harden operator sends for release
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-06-20 15:26:44 -05:00
6dfd78f643 feat(fleet): add local canary CLI (#563)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 17:49:01 +00:00
45e2c2aad8 docs: plan durable tmux fleet install (#557)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 16:19:19 +00:00
57919c38d8 fix(framework/tools): wrapper hardening — TLS validation, cred-path fallback, no-CI fast-exit (#551)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 10:16:38 +00:00
87f561c1f8 fix(launch): include Pi native skill roots in 'all' mode; dedup 'discover' force-loads (#556)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 19:58:09 +00:00
8c45857859 feat(launch): force-load fleet-critical Pi skills + reconcile skill docs (#555)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 18:31:02 +00:00
41 changed files with 3736 additions and 50 deletions

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example # Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password infra/step-ca/dev-password
# Scratch dirs created by the framework git-wrapper shell test harnesses
.mosaic-test-work/

View File

@@ -64,6 +64,7 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
21. `@mosaicstack/cli` — unified `mosaic` CLI 21. `@mosaicstack/cli` — unified `mosaic` CLI
22. Docker Compose deployment + bare-metal capability 22. Docker Compose deployment + bare-metal capability
23. Agent log service — ingest, parse, tier, summarize agent interaction logs 23. Agent log service — ingest, parse, tier, summarize agent interaction logs
24. Local durable agent fleet canary — `mosaic fleet` / `mosaic agent` CLI for an isolated tmux-backed canary fleet using a named socket, with roster-driven local customization and rollback-safe verification
### Out of Scope (v0.1.0) ### Out of Scope (v0.1.0)

View File

@@ -7,6 +7,7 @@
3. [Provider Configuration](#provider-configuration) 3. [Provider Configuration](#provider-configuration)
4. [MCP Server Configuration](#mcp-server-configuration) 4. [MCP Server Configuration](#mcp-server-configuration)
5. [Environment Variables Reference](#environment-variables-reference) 5. [Environment Variables Reference](#environment-variables-reference)
6. [Local Fleet Canary](./fleet-local-canary.md)
--- ---

View File

@@ -9,6 +9,7 @@
5. [Adding New MCP Tools](#adding-new-mcp-tools) 5. [Adding New MCP Tools](#adding-new-mcp-tools)
6. [Database Schema and Migrations](#database-schema-and-migrations) 6. [Database Schema and Migrations](#database-schema-and-migrations)
7. [API Endpoint Reference](#api-endpoint-reference) 7. [API Endpoint Reference](#api-endpoint-reference)
8. [Local Fleet Canary](./fleet-local-canary.md)
--- ---

View File

@@ -0,0 +1,144 @@
# Local Fleet Canary
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
not use or stop the default tmux server.
## Files
Product-owned defaults:
- `packages/mosaic/framework/fleet/roster.schema.json`
- `packages/mosaic/framework/fleet/examples/minimal.yaml`
- `packages/mosaic/framework/fleet/examples/local-canary.yaml`
- `packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service`
- `packages/mosaic/framework/systemd/user/mosaic-agent@.service`
- `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
- `packages/mosaic/framework/tools/tmux/send-message.sh`
These files are published through `packages/mosaic/package.json`, whose `files`
allowlist includes `framework` along with `dist`.
Site-owned local roster:
```text
~/.config/mosaic/fleet/roster.yaml
```
Do not put a host-specific full roster into product defaults. Start from an
example and edit the local roster after `mosaic fleet init --write`.
## Install
Minimal canary:
```bash
mosaic fleet init --profile minimal --write
# If a site-owned roster already exists, inspect it first; overwrite only explicitly:
# mosaic fleet init --profile minimal --write --force
mosaic fleet install-systemd
systemctl --user daemon-reload
mosaic fleet start
mosaic fleet verify
```
Small dogfood roster:
```bash
mosaic fleet init --profile local-canary --write
# Use --force only after preserving any site-owned roster changes.
mosaic fleet install-systemd
systemctl --user daemon-reload
mosaic fleet start
mosaic fleet status
```
## Agent Operations
```bash
mosaic agent roster
mosaic agent status
mosaic agent status canary-pi
mosaic agent send canary-pi --message "status check"
mosaic agent reset canary-pi --new
mosaic agent tail canary-pi -n 80
```
These commands read the roster and target the configured tmux socket. The
generated systemd agent services use `start-agent-session.sh`; message delivery
uses the tmux send tools with `-L mosaic-factory`.
`mosaic agent send` is operator-origin traffic unless a caller explicitly says
otherwise. The CLI always passes a deterministic source label to
`agent-send.sh` with `-S`, defaulting to `<hostname>:operator`, so it does not
query the target tmux socket and accidentally identify as an active agent pane.
Use `--source-label <label>` or `--source <label>` only when deliberately
impersonating a known handoff lane. The lower-level inter-agent wrapper
`agent-send.sh -S <label>` remains the explicit source override for scripts.
## Verification
Use these checks before expanding the roster:
```bash
tmux -L mosaic-factory ls
tmux ls
mosaic fleet verify
systemctl --user status mosaic-tmux-holder.service
```
Expected results:
- `tmux -L mosaic-factory ls` shows `_holder` and roster agent sessions.
- `tmux ls` shows only the default tmux server sessions and is not changed by
fleet start/stop operations.
- `mosaic fleet verify` checks exact session targets on the isolated socket.
- `systemctl --user status ...` may show `active (exited)` for oneshot units;
that means the unit ran, not that an agent pane is live. Treat tmux
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
## Release Preflight
Run this checklist before cutting or dogfooding a fleet release:
- Real AI dogfood: send at least one task through `mosaic agent send`, then
confirm the agent accepted/responded using pane, process, or log evidence.
- Restart/stop/idempotency: run `mosaic fleet start`, `restart`, `stop`, and a
repeated `start` against the named socket; verify the default tmux server is
unchanged.
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions
with `tmux -L mosaic-factory ls` or exact `has-session` checks.
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
confirm `framework/fleet`, `framework/systemd/user`,
`framework/tools/fleet`, and `framework/tools/tmux` assets are included.
- Mosaic update test: install or upgrade from the packed artifact in a temporary
Mosaic home and confirm `mosaic update` or the release upgrade path does not
remove local roster/config files.
## Rollback
Stop the local canary:
```bash
mosaic fleet stop
systemctl --user disable mosaic-agent@canary-pi.service
systemctl --user disable mosaic-tmux-holder.service
systemctl --user daemon-reload
```
For a full local cleanup of generated canary files:
```bash
rm -f ~/.config/systemd/user/mosaic-agent@.service
rm -f ~/.config/systemd/user/mosaic-tmux-holder.service
rm -rf ~/.config/mosaic/fleet
rm -rf ~/.config/mosaic/tools/fleet
```
This rollback leaves the default tmux server untouched. If a canary session is
still present after service stop, remove only the isolated socket server:
```bash
tmux -L mosaic-factory kill-server
```

View File

@@ -10,6 +10,7 @@
6. [CLI Usage](#cli-usage) 6. [CLI Usage](#cli-usage)
7. [Sub-package Commands](#sub-package-commands) 7. [Sub-package Commands](#sub-package-commands)
8. [Telemetry](#telemetry) 8. [Telemetry](#telemetry)
9. [Local Fleet Canary](./fleet-local-canary.md)
--- ---

View File

@@ -0,0 +1,52 @@
# Fleet CLI Local Canary Dogfood — 2026-06-20
## Objective
Move the durable tmux fleet PoC into a functional local canary on this server. This is **not** production deployment. It is a canary/dogfood path for a small local agent fleet using an isolated tmux socket.
## Issue
- Gitea issue: #562`feat(fleet): local CLI canary dogfood`
## Scope
Implement enough product surface to use the fleet locally:
- `mosaic fleet init/install/start/stop/restart/status/verify`
- `mosaic agent roster/status/send/reset/tail`
- roster schema and examples
- local canary docs and rollback instructions
- tests for CLI behavior where practical
- canary verification on named tmux socket `mosaic-factory`
## Non-goals
- No production rollout.
- No migration of existing default tmux sessions.
- No image build/deploy work.
- No hardcoded USC/local roster as product default.
## Acceptance Criteria
- 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 start/stop/status/verify a canary fleet using `mosaic-factory`.
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
- `mosaic agent reset` targets only the named agent session on the named socket.
- Verification proves default tmux sessions remain untouched.
- Baseline repo gates pass.
- PR CI is green before merge.
- Local canary evidence is captured after merge/install.
## Budget / Routing
- Agent: codex preferred.
- Estimate: 25K-40K tokens.
- Worker owns implementation/tests/docs in branch `feat/fleet-cli-local-canary`.
- Orchestrator owns `docs/TASKS.md`, issue/PR/merge, and local canary install verification.
## Progress
- 2026-06-20: #557 PoC primitives merged to `main` as `45e2c2a`.
- 2026-06-20: issue #562 created for local CLI canary dogfood.
- 2026-06-20: worktree created at `/home/jarvis/src/mosaicstack-stack-worktrees/fleet-cli-local-canary`.

View File

@@ -0,0 +1,35 @@
# Fleet release hardening
## Objective
Harden the Mosaic local fleet release path for operator sends, tmux/systemd verification, package contents, and dogfood release documentation.
## Constraints
- Do not edit `docs/TASKS.md`.
- Do not change production deployment refs.
- Keep fleet transport generic and named-socket safe.
- Preserve strict roster validation.
- Add tests first or alongside fixes.
## Plan
1. Add regression tests for deterministic `mosaic agent send` source labels.
2. Strengthen fleet status/verify/package/install-systemd coverage.
3. Implement focused CLI/source-label changes.
4. Update local canary documentation with dogfood preflight.
5. Run formatting, targeted tests, typecheck, lint, and package dry-run evidence.
## Evidence Log
- Started from existing `docs/PRD.md`; durable local fleet canary is in v0.1.0 scope.
- Loaded `mosaic-fleet-operations` skill; key constraints are isolated tmux sockets, no default tmux positive tests, and `active (exited)` is not liveness.
- TDD red: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts` initially failed because `node_modules` was absent; after `pnpm install`, the new source-label tests failed on missing `-S`, missing helper, and unknown `--source-label`.
- Green implementation: `mosaic agent send` now passes `-S <hostname>:operator` by default and accepts `--source-label` / `--source` overrides.
- Test coverage added for tmux-based fleet verify liveness, package `files` allowlist containing `framework`, and explicit operator source-label command construction.
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/guides/fleet-local-canary.md docs/scratchpads/2026-06-20-fleet-release-hardening.md`.
- Targeted tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts` passed with 49 tests.
- Typecheck: `pnpm typecheck` passed.
- Lint: `pnpm lint` passed.
- Package dry-run: `npm pack --dry-run --json` from `packages/mosaic` included `framework/fleet`, `framework/systemd/user`, `framework/tools/fleet/start-agent-session.sh`, and `framework/tools/tmux/{agent-send.sh,send-message.sh}`.
- Review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` approved the supplied diff with no findings; the review tool noted its read-only sandbox could not inspect files directly.

View File

@@ -0,0 +1,87 @@
# Wrapper hardening fold-in: #559 (eval removal) + #560 (host-derived login)
**Branch:** `fix/wrapper-hardening-tls-credpath-cicwait` (PR #551)
**Worker:** coderlite0 (Sonnet lane) · coordinated by mos-claude
**Date:** 2026-06-20
**Scope:** `packages/mosaic/framework/tools/git/*.sh` only
## What the issues asked for vs. what was already landed
Both issues were largely satisfied by prior merged work; this fold-in closes the
remaining gaps (regression tests + a loud diagnostic + one residual word-split site)
rather than re-implementing finished functionality.
### #559 — remove `eval` from issue-create.sh (and siblings)
- `eval`-based command construction was already removed across the wrapper surface
(landed in #549). A full scan of `tools/git/*.sh` finds **zero** `eval` usages.
- `issue-create.sh`, `pr-create.sh`, `issue-edit.sh`, `issue-assign.sh` already build
their `tea`/`gh` invocations as argv arrays (`CMD=(...)`, `"${CMD[@]}"`), so Markdown
bodies pass through verbatim.
- **Residual found & fixed:** `issue-comment.sh` still used unquoted
`$(get_gitea_repo_args)` word-splitting (the comment body itself was already safely
quoted, so no injection bug — but it was the inconsistent, fragile pattern #559 targets,
and it failed silently when no login resolved). Converted to an argv array with an
explicit, loud login-resolution error.
- **Added regression test:** `test-issue-create-body-safety.sh` — feeds a hostile
Markdown body (`$(touch SENTINEL)`, backticks, single/double quotes, `$HOME`/`${PATH}`,
pipes/`&&`/`;`) through `issue-create.sh` and asserts (1) no command substitution
executes (sentinel file never created) and (2) the `--description` `tea` receives is
byte-for-byte the original body.
### #560 — auto-detect Gitea `--login` from repo origin host
- Centralized host→login resolution already exists in `detect-platform.sh`
(`get_gitea_login_for_host``find_tea_login_for_host`, matching `urlparse(url).hostname`).
Every wrapper routes through it (or `get_gitea_login` / `get_gitea_login_for_repo_override`);
**no wrapper hardcodes `${GITEA_LOGIN:-mosaicstack}`**. Explicit `GITEA_LOGIN` wins only
when it matches the host (`tea_login_matches_host`), so stale overrides are rejected.
- **Gap fixed — silent failure → loud diagnostic:** the failure path of
`get_gitea_login_for_host` returned non-zero with no message. Added
`print_gitea_login_diagnostic`, emitted to **stderr** on resolution failure: names the
unresolved host, lists available tea logins (name + host), and gives the `GITEA_LOGIN`
override + `tea login add` fix. Stderr-only, so it never contaminates stdout (the
resolved login name) or the log-grep assertions in the existing harnesses. Callers with
an API fallback (pr-merge, issue-close, pr-create, issue-create) still follow with their
own "using API fallback" line, giving a clear "no login → fallback" trail.
- **Extended test:** `test-gitea-login-resolution.sh` now also asserts (a) the loud
diagnostic fires and lists available logins for an unresolved host, (b) login is derived
from origin host for **both** instances (mosaicstack + usc) via a scoped second `tea`
mock, and (c) a valid `GITEA_LOGIN` override is honored. The scoped mock keeps the
existing API-fallback assertions (which require mosaicstack to have _no_ tea login) valid.
## Files changed (wrapper surface only)
- `detect-platform.sh` — add `print_gitea_login_diagnostic`; call it on the
`get_gitea_login_for_host` failure path.
- `issue-comment.sh` — argv array + loud login-resolution error (was unquoted
`$(get_gitea_repo_args)`).
- `test-issue-create-body-safety.sh`**new** (#559 regression).
- `test-gitea-login-resolution.sh` — extended (#560 diagnostic + both-host + override).
## Verification
All wrapper harnesses pass locally:
- `test-issue-create-body-safety.sh` — PASS
- `test-gitea-login-resolution.sh` — PASS
- `test-pr-merge-gitea-empty-uid.sh` — PASS
- `test-pr-metadata-gitea.sh` — PASS
- `test-lane-brief-pr-linkage.sh` — PASS
## Open items flagged to mos-claude (orchestrator decisions)
1. **CHANGELOG absent.** The task said "update CHANGELOG (append-only), keep the existing
#550/#551 entry." No CHANGELOG file exists anywhere in the repo, and #550/#551 are not
recorded in one. **ASSUMPTION:** documenting #559/#560 in this scratchpad + the PR
description (`Closes #559 Closes #560`) follows the repo's actual convention
(`docs/scratchpads/`). Did not invent a new CHANGELOG structure.
2. **`docs/TASKS.md` is orchestrator single-writer.** It carries a "Workers read but never
modify" banner. As a worker I did **not** edit it; task tracking is via the linked Gitea
issues #559/#560 + this scratchpad. Orchestrator may add a rollup row if desired.
3. **Wrapper `test-*.sh` are not CI-wired.** `.woodpecker/ci.yml` runs `pnpm
typecheck/lint/format:check/test` (`turbo run test`); the framework dir has no
`package.json`, so these shell harnesses run **locally/manually only** — they do not gate
the PR in Woodpecker. **ASSUMPTION:** out of scope to wire a shell-test step into CI in
this PR (would broaden the diff beyond the wrapper surface). Flagging for a follow-up if
the fleet wants these gated.

View File

@@ -0,0 +1,54 @@
# Fleet CLI Local Canary Review Fixes
## Objective
Fix only the two should-fix code review findings:
1. Ensure `@mosaicstack/mosaic` declares `yaml` and lockfile state is current.
2. Validate `mosaic agent status [agent]` against the fleet roster before constructing/running the tmux target.
## Constraints
- Do not modify `docs/TASKS.md`.
- Leave changes uncommitted.
- Run requested formatting and quality gates.
## Plan
1. Inspect manifest/lockfile state for `yaml`.
2. Add failing regression test for `mosaic agent status typo`.
3. Patch `registerFleetAgentCommands` status validation.
4. Format touched files.
5. Run requested tests, typecheck, and lint.
6. Review final diff.
## Progress
- Loaded required repo/global/runtime instructions.
- Confirmed `packages/mosaic/package.json` already declares `yaml`.
- Confirmed `pnpm-lock.yaml` already has `packages/mosaic` importer entry for `yaml`.
- Found `registerFleetAgentCommands` status path does not validate agent before building tmux target.
## Verification
- TDD red check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
failed before the production fix because `mosaic agent status typo` resolved instead of
rejecting.
- Focused green check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
passed after adding roster validation.
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/scratchpads/fleet-cli-local-canary-review-fixes.md`
completed with all files unchanged.
- Requested tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts`
passed with 36 tests.
- Baseline typecheck: `pnpm typecheck` passed.
- Baseline lint: `pnpm lint` passed.
- Independent review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
returned approve with 0 findings. Note: reviewer reported broader context inspection was limited
by its read-only sandbox, so review was based on the supplied diff.
- `docs/TASKS.md` has no diff.
## Risks
- `docs/TASKS.md` intentionally untouched per user instruction.
- Review finding 1 required no file edit: `packages/mosaic/package.json` already declares
`yaml`, and the `packages/mosaic` importer in `pnpm-lock.yaml` already includes `yaml`.

View File

@@ -0,0 +1,26 @@
# Mosaic Fleet Rosters
The local fleet canary uses a product-owned roster schema with site-owned roster
files. Product examples live here; active local rosters should live outside the
package, normally at:
```text
~/.config/mosaic/fleet/roster.yaml
```
The default tmux socket is `mosaic-factory` so fleet commands do not touch the
default tmux server.
## Examples
- `examples/minimal.yaml` starts one local canary slot.
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
Initialize a roster:
```bash
mosaic fleet init --profile minimal --write
mosaic fleet install-systemd
mosaic fleet start
mosaic fleet verify
```

View File

@@ -0,0 +1,27 @@
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
defaults:
working_directory: ~/src
runtimes:
claude:
reset_command: /clear
codex:
reset_command: /clear
pi:
reset_command: /new
agents:
- name: lead
runtime: claude
class: orchestrator
persistent_persona: true
- name: coder0
runtime: codex
class: implementer
reset_between_tasks: true
- name: reviewer0
runtime: pi
class: reviewer
reset_between_tasks: true

View File

@@ -0,0 +1,15 @@
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
defaults:
working_directory: ~/src
runtimes:
pi:
reset_command: /new
agents:
- name: canary-pi
runtime: pi
class: canary
reset_between_tasks: true

View File

@@ -0,0 +1,118 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mosaicstack.dev/schemas/fleet-roster.schema.json",
"title": "Mosaic Fleet Roster",
"type": "object",
"required": ["version", "transport", "agents"],
"additionalProperties": false,
"properties": {
"version": {
"const": 1
},
"transport": {
"const": "tmux"
},
"tmux": {
"type": "object",
"additionalProperties": false,
"properties": {
"socket_name": {
"type": "string",
"default": "mosaic-factory"
},
"socketName": {
"type": "string",
"default": "mosaic-factory"
},
"holder_session": {
"type": "string",
"default": "_holder"
},
"holderSession": {
"type": "string",
"default": "_holder"
}
}
},
"defaults": {
"type": "object",
"additionalProperties": false,
"properties": {
"working_directory": {
"type": "string",
"default": "~/src"
},
"workingDirectory": {
"type": "string",
"default": "~/src"
}
}
},
"runtimes": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"reset_command": {
"type": "string"
},
"resetCommand": {
"type": "string"
}
}
}
},
"agents": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["name", "runtime"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
},
"runtime": {
"type": "string"
},
"class": {
"type": "string"
},
"working_directory": {
"type": "string"
},
"workingDirectory": {
"type": "string"
},
"model_hint": {
"type": "string"
},
"modelHint": {
"type": "string"
},
"persistent_persona": {
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
},
"persistentPersona": {
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
},
"reset_between_tasks": {
"type": "boolean"
},
"resetBetweenTasks": {
"type": "boolean"
},
"kickstart_template": {
"type": "string"
},
"kickstartTemplate": {
"type": "string"
}
}
}
}
}
}

View File

@@ -0,0 +1,57 @@
# Mosaic tmux Fleet PoC
This directory contains the first durable tmux-backed fleet primitives for the
Mosaic software-factory model.
The lifecycle model follows the organization-neutral AI Guide playbook
`mosaicstack/aiguide:playbooks/tmux-fleet.md` (commit `2a0b0b5`): a dedicated
holder owns the tmux server/socket; agent units join it and stop only their own
exact-match session.
## Layout
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
- `mosaic-agent@.service` — user-mode template for one reusable agent session.
- `test-fleet-units.sh` — validates unit syntax and required relationships.
The agent template calls:
```text
~/.config/mosaic/tools/fleet/start-agent-session.sh <agent-name>
```
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
## Local customization
Per-agent overrides live outside the package in:
```text
~/.config/mosaic/fleet/agents/<agent>.env
```
Example:
```dotenv
MOSAIC_TMUX_SOCKET=mosaic-factory
MOSAIC_AGENT_RUNTIME=claude
MOSAIC_AGENT_WORKDIR=/home/jarvis/src/mosaic-stack
# Optional escape hatch for PoC/canary agents:
# MOSAIC_AGENT_COMMAND=mosaic yolo claude
```
## Manual canary sequence
```bash
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/
chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh
systemctl --user daemon-reload
systemctl --user start mosaic-tmux-holder.service
systemctl --user start mosaic-agent@canary.service
tmux -L mosaic-factory ls
```
Do not use `tmux kill-server` without `-L mosaic-factory`; this pattern is meant
to avoid disturbing the user's default tmux server.

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Mosaic tmux fleet agent %i
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
Requires=mosaic-tmux-holder.service
After=mosaic-tmux-holder.service
PartOf=mosaic-tmux-holder.service
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
Environment=MOSAIC_AGENT_NAME=%i
Environment=MOSAIC_AGENT_RUNTIME=pi
Environment=MOSAIC_AGENT_WORKDIR=%h
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
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"'
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Mosaic tmux fleet holder
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
After=default.target
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
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"'
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
if command -v systemd-analyze >/dev/null 2>&1; then
systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
cat /tmp/mosaic-fleet-systemd-verify.log >&2
fail "systemd-analyze verify failed"
}
fi
echo "ok - fleet systemd unit templates"

View File

@@ -16,7 +16,12 @@
# After loading, service-specific env vars are exported. # After loading, service-specific env vars are exported.
# Run `load_credentials --help` for details. # Run `load_credentials --help` for details.
MOSAIC_CREDENTIALS_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}" if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "$HOME/src/jarvis-brain/credentials.json"; do
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
done
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/src/jarvis-brain/credentials.json}"
fi
_mosaic_require_jq() { _mosaic_require_jq() {
if ! command -v jq &>/dev/null; then if ! command -v jq &>/dev/null; then
@@ -34,6 +39,19 @@ _mosaic_read_cred() {
jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE" jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE"
} }
# Decide curl TLS flag for a target URL: validate public hosts (MITM matters on
# WAN); allow self-signed only for private-network IP literals (trusted LAN) or an
# explicit $MOSAIC_INSECURE_TLS opt-in. Echoes "-k" or "" (empty).
_mosaic_tls_opt() {
local url="$1" host
[[ -n "${MOSAIC_INSECURE_TLS:-}" ]] && { echo "-k"; return; }
host=$(printf '%s' "$url" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')
if [[ "$host" =~ ^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
echo "-k"; return
fi
echo ""
}
# Sync Woodpecker credentials to ~/.woodpecker/<instance>.env # Sync Woodpecker credentials to ~/.woodpecker/<instance>.env
# Only writes when values differ to avoid unnecessary disk writes. # Only writes when values differ to avoid unnecessary disk writes.
_mosaic_sync_woodpecker_env() { _mosaic_sync_woodpecker_env() {
@@ -261,7 +279,8 @@ mosaic_http() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
response=$(curl -sk -w "\n%{http_code}" -X "$method" \ local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${base_url}${endpoint}") "${base_url}${endpoint}")
@@ -279,7 +298,8 @@ mosaic_http_post() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
response=$(curl -sk -w "\n%{http_code}" -X POST \ local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$data" \ -d "$data" \
@@ -297,7 +317,8 @@ mosaic_http_patch() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
response=$(curl -sk -w "\n%{http_code}" -X PATCH \ local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$data" \ -d "$data" \

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory}
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
if [ -z "$AGENT_NAME" ]; then
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
exit 64
fi
if ! command -v tmux >/dev/null 2>&1; then
echo "ERROR: tmux is required" >&2
exit 69
fi
if tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
echo "Mosaic agent session already running: $AGENT_NAME on socket $MOSAIC_TMUX_SOCKET"
exit 0
fi
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME"
fi
mkdir -p "$MOSAIC_AGENT_WORKDIR"
exec tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" "$MOSAIC_AGENT_COMMAND"

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
START="$SCRIPT_DIR/start-agent-session.sh"
SOCKET="mosaic-agent-test-$RANDOM-$$"
AGENT="agent-$RANDOM"
WORKDIR=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$WORKDIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
"$START" "$AGENT"
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir"
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
"$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
echo "ok - start-agent-session"

View File

@@ -169,6 +169,43 @@ raise SystemExit(1)
PY PY
} }
# Emit an actionable diagnostic to stderr when no tea login resolves for a host.
# Callers that have a working API fallback may ignore the non-zero return of
# get_gitea_login_for_host; this turns the previously SILENT failure into a loud,
# greppable hint (available logins + override + add-login instructions). Printed to
# stderr only, so it never contaminates stdout (the resolved login name) or log
# assertions that capture tea/curl invocations.
print_gitea_login_diagnostic() {
local host="${1:-<unknown>}"
local available
available=$(
command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; }
logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; }
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
import json, os
from urllib.parse import urlparse
try:
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
except Exception:
logins = []
rows = []
for login in logins if isinstance(logins, list) else []:
name = str(login.get("name") or login.get("Name") or "")
url = str(login.get("url") or login.get("URL") or "")
host = urlparse(url).hostname or "?"
if name:
rows.append(f"{name} (host: {host})")
print("; ".join(rows) if rows else "(none configured)")
PY
)
{
echo "Error: no Gitea tea login matches host '$host'."
echo " Available tea logins: ${available}"
echo " Fix: set GITEA_LOGIN to a login whose URL host is '$host',"
echo " or add one: tea login add --name <name> --url https://$host --token <token>"
} >&2
}
get_gitea_login_for_host() { get_gitea_login_for_host() {
local host="${1:-}" local host="${1:-}"
local login local login
@@ -190,6 +227,7 @@ get_gitea_login_for_host() {
return 0 return 0
fi fi
print_gitea_login_diagnostic "$host"
return 1 return 1
} }

View File

@@ -53,7 +53,15 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER" echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args) # Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
# word-splitting) so the comment body — including Markdown backticks, $(...),
# and quotes — is passed verbatim and never re-split or shell-evaluated.
REPO_SLUG=$(get_repo_slug)
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
exit 1
}
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
echo "Added comment to Gitea issue #$ISSUE_NUMBER" echo "Added comment to Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"

View File

@@ -72,6 +72,11 @@ elif values and all(v == "success" for v in values):
print("success") print("success")
elif any(v in {"pending", "running", "queued", "waiting"} for v in values): elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
print("pending") print("pending")
elif not values and not state:
# No pipeline/status of any kind reported for this commit. Distinct from
# "unknown" (an ambiguous/unrecognized status that should keep polling):
# this signals a repo/commit that simply has no CI configured.
print("no-status")
else: else:
print("unknown") print("unknown")
PY PY
@@ -142,6 +147,21 @@ gitea_get_commit_status_json() {
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
} }
gitea_get_default_branch() {
local host="$1"
local repo="$2"
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
import json, sys
print((json.load(sys.stdin) or {}).get("default_branch", ""))
'
}
github_get_default_branch() {
gh api "repos/${OWNER}/${REPO}" --jq '.default_branch'
}
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
-n|--number) -n|--number)
@@ -245,6 +265,51 @@ else
exit 1 exit 1
fi fi
# No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak).
#
# PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT
# BRANCH's commit status. A repo whose default branch carries CI statuses
# demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply
# has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that
# case we must NEVER fast-green; we keep polling until the pipeline registers or the
# timeout fires (both safe). This closes the webhook-lag false-green: a slow-to-
# register pipeline feeding a merge gate can no longer be mistaken for "no CI".
#
# SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos
# (default branch also has no CI history, e.g. device-imaging class), where burning
# the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0.
#
# Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we
# would rather wait-then-timeout than risk a false-green, per the merge-gate priority.
REPO_HAS_CI=1
detect_repo_ci() {
local def_branch def_status
# Every early exit returns 0: a probe miss must leave the conservative
# REPO_HAS_CI=1 default in place, never abort the caller under `set -e`.
if [[ "$PLATFORM" == "github" ]]; then
def_branch=$(github_get_default_branch 2>/dev/null) || {
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
[[ -n "$def_branch" ]] || return 0
def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
else
def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || {
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
[[ -n "$def_branch" ]] || return 0
def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
fi
if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then
REPO_HAS_CI=0
echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)."
else
REPO_HAS_CI=1
echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green."
fi
}
detect_repo_ci || true
NO_CI_STREAK=0
NO_CI_MAX=3
while true; do while true; do
NOW_TS=$(date +%s) NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then if (( NOW_TS > DEADLINE_TS )); then
@@ -272,11 +337,35 @@ while true; do
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2 echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
exit 1 exit 1
;; ;;
no-status)
if [[ "$REPO_HAS_CI" == "1" ]]; then
# PRIMARY tier: repo demonstrably runs CI but this commit's pipeline
# has not registered yet (webhook/queue lag). Do NOT fast-green — keep
# polling until it registers or the timeout fires. Reset the streak so
# a later genuine CI-less misread can't accumulate across this state.
NO_CI_STREAK=0
echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening."
else
# SECONDARY tier: genuinely CI-less repo (default branch has no CI
# history either). Empty polls => fast-exit green after NO_CI_MAX.
NO_CI_STREAK=$((NO_CI_STREAK + 1))
if (( NO_CI_STREAK >= NO_CI_MAX )); then
echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green."
exit 0
fi
fi
sleep "$INTERVAL_SEC"
;;
pending|unknown) pending|unknown)
# A pipeline exists but hasn't reached a terminal state (or is
# transiently ambiguous) — keep waiting, and reset the no-CI streak
# since this commit is not in the "no CI at all" condition.
NO_CI_STREAK=0
sleep "$INTERVAL_SEC" sleep "$INTERVAL_SEC"
;; ;;
*) *)
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..." echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
NO_CI_STREAK=0
sleep "$INTERVAL_SEC" sleep "$INTERVAL_SEC"
;; ;;
esac esac

View File

@@ -230,4 +230,81 @@ if grep -q -- 'tea issue close 536 .*--login mosaicstack' "$LOG_FILE"; then
exit 1 exit 1
fi fi
# ---------------------------------------------------------------------------
# #560: loud diagnostic + host-derived login for BOTH instances + override-wins
# ---------------------------------------------------------------------------
# Loud diagnostic: a host with no matching tea login must emit an actionable
# error to stderr (the previous behavior was a SILENT failure). The original
# mock defines only usc/evil-usc logins, so mosaicstack resolution fails here.
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
diag_stderr=$(run_in_repo bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
' 2>&1 1>/dev/null || true)
if ! grep -q "no Gitea tea login matches host 'git.mosaicstack.dev'" <<<"$diag_stderr"; then
echo "Expected loud diagnostic naming the unresolved host; got: $diag_stderr" >&2
exit 1
fi
if ! grep -q "Available tea logins:" <<<"$diag_stderr"; then
echo "Expected diagnostic to list available tea logins; got: $diag_stderr" >&2
exit 1
fi
# Both-instance host derivation + override-wins, using a mock that DOES define a
# mosaicstack login. Scoped to this section so the API-fallback assertions above
# (which rely on mosaicstack having NO tea login) remain valid.
BIN_DIR2="$WORK_DIR/bin2"
mkdir -p "$BIN_DIR2"
cp "$BIN_DIR/curl" "$BIN_DIR2/curl"
cat > "$BIN_DIR2/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"jason.woltje"},
{"name":"usc","url":"https://git.uscllc.com","user":"jason.woltje"}
]
JSON
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
exit 0
SH
chmod +x "$BIN_DIR2/tea"
run_in_repo2() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR2:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
)
}
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
mosaic_login=$(run_in_repo2 bash -c 'source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
if [[ "$mosaic_login" != "mosaicstack" ]]; then
echo "Expected mosaicstack origin to derive login 'mosaicstack'; got '$mosaic_login'" >&2
exit 1
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
usc_login_derived=$(run_in_repo2 bash -c 'source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
if [[ "$usc_login_derived" != "usc" ]]; then
echo "Expected usc origin to derive login 'usc'; got '$usc_login_derived'" >&2
exit 1
fi
# Explicit GITEA_LOGIN override is honored when it matches the host.
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
override_wins=$(run_in_repo2 bash -c 'export GITEA_LOGIN=mosaicstack; source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
if [[ "$override_wins" != "mosaicstack" ]]; then
echo "Expected valid GITEA_LOGIN override to win on mosaicstack host; got '$override_wins'" >&2
exit 1
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
echo "Gitea login resolution regression harness passed" echo "Gitea login resolution regression harness passed"

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Regression harness for issue-create.sh Markdown-body safety (#559).
#
# Guards against reintroduction of eval-based command construction. The wrapper
# builds its tea/gh invocation as an argv array, so a body containing command
# substitution ($(...)), backticks, quotes, and dollar signs MUST reach tea
# verbatim and MUST NOT be shell-evaluated. This test asserts both:
# 1. No command-substitution side effect (an injected `touch SENTINEL` never runs).
# 2. The --description value tea receives is byte-for-byte the original body.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
SENTINEL="$WORK_DIR/INJECTION_SENTINEL"
BODY_FILE="$WORK_DIR/body.txt"
RECEIVED_FILE="$WORK_DIR/received-description.txt"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# Hostile Markdown body. The unquoted heredoc expands $SENTINEL (a real path we
# want embedded) but every shell metacharacter we care about is backslash-escaped
# so the TEST shell writes them literally into the file — the bytes the wrapper
# must then preserve.
cat > "$BODY_FILE" <<EOF
# Release notes
Inline code: \`rm -rf /\` must stay literal.
Command sub attempt: \$(touch $SENTINEL)
Backtick cmd attempt: \`touch $SENTINEL\`
Dollars: \$HOME \${PATH} \$5.00 and 100% done
Quotes: "double" and 'single' and \`mixed\`
Trailing pipe-ish: foo | bar && baz ; qux
EOF
BODY="$(cat "$BODY_FILE")"
# Mock tea: resolve a mosaicstack login, then capture the --description verbatim.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"jason.woltje"}
]
JSON
exit 0
fi
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
desc=""
while [[ $# -gt 0 ]]; do
case "$1" in
--description) desc="$2"; shift 2 ;;
*) shift ;;
esac
done
printf '%s' "$desc" > "$MOSAIC_TEST_RECEIVED"
echo "#1 created"
exit 0
fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
) >/dev/null
# 1. No command substitution executed anywhere in the pipeline.
if [[ -e "$SENTINEL" ]]; then
echo "FAIL: injected command substitution executed (sentinel file created): $SENTINEL" >&2
exit 1
fi
# 2. tea actually received the body (issue create path taken, not silently dropped).
if [[ ! -f "$RECEIVED_FILE" ]]; then
echo "FAIL: tea issue create was never invoked with a --description" >&2
exit 1
fi
# 3. The description tea received is byte-for-byte the original body.
if [[ "$(cat "$RECEIVED_FILE")" != "$BODY" ]]; then
echo "FAIL: body was not preserved verbatim through issue-create.sh" >&2
echo "--- expected ---" >&2; printf '%s\n' "$BODY" >&2
echo "--- received ---" >&2; cat "$RECEIVED_FILE" >&2
exit 1
fi
echo "issue-create.sh Markdown body-safety regression harness passed"

View File

@@ -31,9 +31,12 @@ Prepends the preamble automatically (auto-detecting your own `host:session`) and
delivers reliably to local OR remote panes. delivers reliably to local OR remote panes.
```bash ```bash
# Local target (same host) # Local target (same host, default tmux server)
agent-send.sh -s <dst_session> -m "message" agent-send.sh -s <dst_session> -m "message"
# Local target on a Mosaic fleet socket
agent-send.sh -L mosaic-factory -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"
@@ -42,10 +45,27 @@ agent-send.sh -H user@host -s <dst_session> -f msg.txt
echo "msg" | agent-send.sh -s <dst_session> echo "msg" | agent-send.sh -s <dst_session>
``` ```
Key flags: `-s` dst session (required) · `-H` ssh target for remote · `-n` dst Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S` hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
override source label · `-v` verbose · `-r N` Enter-flush attempts. override source label · `-v` verbose · `-r N` Enter-flush attempts.
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
normalizes exact session targets to pane-qualified targets internally so pane
commands do not fall back to tmux's prefix matching behavior.
## Named socket isolation
Durable Mosaic fleets should use a dedicated tmux socket, for example:
```bash
tmux -L mosaic-factory ls
agent-send.sh -L mosaic-factory -s '=coder0' -m "status?"
send-message.sh -L mosaic-factory -t '=coder0' -m "raw pane message"
```
This keeps fleet operations away from the user's default tmux server. It is the
safe rollout path on hosts that already have manual tmux sessions.
## Why a helper exists (the submission gotcha) ## Why a helper exists (the submission gotcha)
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
@@ -67,6 +87,7 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch). - `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input). - `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
## Distribution ## Distribution

View File

@@ -23,12 +23,13 @@
# the remote host; only bash + tmux + base64 (standard). # the remote host; only bash + tmux + base64 (standard).
# #
# USAGE # USAGE
# agent-send.sh -s <dst_session> -m "message" # local target # agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
# agent-send.sh -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 -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
# echo "msg" | agent-send.sh -H user@host -s <dst_session> # echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
# #
# OPTIONS # OPTIONS
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
# -s DST_SESSION target tmux session (or session:window.pane) [required] # -s DST_SESSION target tmux session (or session:window.pane) [required]
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local # -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
# -n DST_HOST hostname to show in the preamble for the target. # -n DST_HOST hostname to show in the preamble for the target.
@@ -47,12 +48,13 @@ set -uo pipefail
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd) SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
SENDER="$SELF_DIR/send-message.sh" SENDER="$SELF_DIR/send-message.sh"
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE="" DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
SRC_LABEL=""; RETRIES=2; VERBOSE=0 SRC_LABEL=""; RETRIES=2; VERBOSE=0
usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; } usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; }
while getopts "s:H:n:m:f:S:r:vh" o; do while getopts "L:s:H:n:m:f:S:r:vh" o; do
case "$o" in case "$o" in
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 ;;
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;; r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
@@ -70,8 +72,12 @@ fi
# Source label: this agent's host:session (auto-detected, overridable). # Source label: this agent's host:session (auto-detected, overridable).
if [ -z "$SRC_LABEL" ]; then if [ -z "$SRC_LABEL" ]; then
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
src_host=$(hostname -s 2>/dev/null || echo "?") src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?") src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?")
SRC_LABEL="${src_host}:${src_sess}" SRC_LABEL="${src_host}:${src_sess}"
fi fi
@@ -89,12 +95,16 @@ FULL="${PREAMBLE} ${MSG}"
B64=$(printf '%s' "$FULL" | base64 -w0) B64=$(printf '%s' "$FULL" | base64 -w0)
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v" vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
socket_args=()
if [ -n "$SOCKET_NAME" ]; then
socket_args=(-L "$SOCKET_NAME")
fi
if [ -z "$SSH_TARGET" ]; then if [ -z "$SSH_TARGET" ]; then
# Local pane: call the canonical sender directly. # Local pane: call the canonical sender directly.
exec "$SENDER" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag exec "$SENDER" "${socket_args[@]}" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
else else
# Remote pane: ship the sender over ssh and run it local to the target. # Remote pane: ship the sender over ssh and run it local to the target.
ssh -o ConnectTimeout=10 "$SSH_TARGET" \ ssh -o ConnectTimeout=10 "$SSH_TARGET" \
"bash -s -- -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER" "bash -s -- ${socket_args[*]@Q} -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
fi fi

View File

@@ -13,12 +13,13 @@
# no-op in Claude Code, so the double-Enter is safe. # no-op in Claude Code, so the double-Enter is safe.
# #
# USAGE # USAGE
# send-message.sh -t <target> -m "message" # send-message.sh [-L socket_name] -t <target> -m "message"
# send-message.sh -t <target> -f <file> # send-message.sh [-L socket_name] -t <target> -f <file>
# echo "message" | send-message.sh -t <target> # echo "message" | send-message.sh [-L socket_name] -t <target>
# ssh host bash -s -- -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh # ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
# #
# OPTIONS # OPTIONS
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
# -t TARGET tmux target: session, or session:window.pane [required] # -t TARGET tmux target: session, or session:window.pane [required]
# -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
@@ -34,11 +35,12 @@
# 3 usage error # 3 usage error
set -uo pipefail set -uo pipefail
TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0 SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; } usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
while getopts "t:m:f:b:r:vh" o; do while getopts "L:t:m:f:b:r:vh" o; do
case "$o" in case "$o" in
L) SOCKET_NAME=$OPTARG ;;
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;; t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$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
@@ -51,8 +53,21 @@ elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
fi fi
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; } [ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
# tmux accepts `=session` for some commands, but pane-level commands such as
# capture-pane require a pane-qualified target. Keep exact-session addressing
# convenient while avoiding accidental prefix matches.
EFFECTIVE_TARGET=$TARGET
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
EFFECTIVE_TARGET="${TARGET}:0.0"
fi
# Target must resolve to a live pane. # Target must resolve to a live pane.
if ! tmux list-panes -t "$TARGET" >/dev/null 2>&1; then if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1 echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
fi fi
@@ -62,18 +77,18 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
# 1) Paste the body as a bracketed paste so multi-line content does not submit # 1) Paste the body as a bracketed paste so multi-line content does not submit
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`. # line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
printf '%s' "$MSG" | tmux load-buffer -b __mosaic_send - printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
# -p = bracketed paste when the client supports it; fall back if not. # -p = bracketed paste when the client supports it; fall back if not.
tmux paste-buffer -d -p -b __mosaic_send -t "$TARGET" 2>/dev/null \ "${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|| tmux paste-buffer -d -b __mosaic_send -t "$TARGET" || "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
sleep 0.5 sleep 0.5
# 2) Submit, then verify; flush with another Enter if it is still a draft. # 2) Submit, then verify; flush with another Enter if it is still a draft.
status="sent" status="sent"
for attempt in $(seq 1 $((RETRIES + 1))); do for attempt in $(seq 1 $((RETRIES + 1))); do
tmux send-keys -t "$TARGET" Enter "${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
sleep 1.2 sleep 1.2
pane=$(tmux capture-pane -t "$TARGET" -p 2>/dev/null) pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
status="queued"; break status="queued"; break

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
AGENT_SEND="$SCRIPT_DIR/agent-send.sh"
SOCKET="mosaic-test-$RANDOM-$$"
TARGET="target-$RANDOM"
DEFAULT_TARGET="default-target-$RANDOM"
TMPDIR=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
require_tmux() {
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
}
capture_named() {
tmux -L "$SOCKET" capture-pane -t "=$TARGET:0.0" -p
}
capture_default() {
tmux capture-pane -t "=$DEFAULT_TARGET:0.0" -p
}
require_tmux
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'bash --noprofile --norc -i'
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'bash --noprofile --norc -i'
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
sleep 0.2
capture_named | grep -qF "named socket hello" || fail "send-message.sh did not deliver to named socket"
if capture_default | grep -qF "named socket hello"; then
fail "send-message.sh leaked named-socket message to default tmux server"
fi
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
sleep 0.2
capture_named | grep -qF "[tester:source ->" || fail "agent-send.sh did not include preamble"
capture_named | grep -qF "agent socket hello" || fail "agent-send.sh did not deliver to named socket"
if capture_default | grep -qF "agent socket hello"; then
fail "agent-send.sh leaked named-socket message to default tmux server"
fi
echo "ok - named tmux socket send tools"

View File

@@ -12,7 +12,7 @@ wp_resolve_repo_id() {
local full_name="$1" local full_name="$1"
local response http_code body repo_id local response http_code body repo_id
response=$(curl -sk -w "\n%{http_code}" \ response=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/lookup/${full_name}") "${WOODPECKER_URL}/api/repos/lookup/${full_name}")

View File

@@ -48,7 +48,7 @@ fi
# Resolve owner/repo to numeric ID (Woodpecker v3 API) # Resolve owner/repo to numeric ID (Woodpecker v3 API)
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1 REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
response=$(curl -sk -w "\n%{http_code}" \ response=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}") "${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")

View File

@@ -50,7 +50,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
_wp_fetch() { _wp_fetch() {
local ep="$1" local ep="$1"
local resp http_code body local resp http_code body
resp=$(curl -sk -w "\n%{http_code}" \ resp=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"$ep") "$ep")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)

View File

@@ -46,7 +46,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
echo "Triggering pipeline for $REPO on branch $BRANCH..." echo "Triggering pipeline for $REPO on branch $BRANCH..."
response=$(curl -sk -w "\n%{http_code}" -X POST \ response=$(curl -sS -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \ -d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \

View File

@@ -13,6 +13,7 @@ import { registerStorageCommand } from '@mosaicstack/storage';
import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerConfigCommand } from './commands/config.js'; import { registerConfigCommand } from './commands/config.js';
import { registerFleetCommand } from './commands/fleet.js';
import { registerMissionCommand } from './commands/mission.js'; import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js'; import { registerUninstallCommand } from './commands/uninstall.js';
// prdy is registered via launch.ts // prdy is registered via launch.ts
@@ -57,7 +58,7 @@ Command Groups:
Runtime: tui, login, sessions Runtime: tui, login, sessions
Gateway: gateway Gateway: gateway
Framework: agent, bootstrap, coord, doctor, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
Platform: update Platform: update
Runtimes: claude, codex, opencode, pi Runtimes: claude, codex, opencode, pi
`, `,
@@ -345,6 +346,10 @@ registerFederationCommand(program);
registerAgentCommand(program); registerAgentCommand(program);
// ─── fleet ─────────────────────────────────────────────────────────────
registerFleetCommand(program);
// ─── config ──────────────────────────────────────────────────────────── // ─── config ────────────────────────────────────────────────────────────
registerConfigCommand(program); registerConfigCommand(program);

View File

@@ -1,4 +1,5 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js';
import { withAuth } from './with-auth.js'; import { withAuth } from './with-auth.js';
import { selectItem } from './select-dialog.js'; import { selectItem } from './select-dialog.js';
import { import {
@@ -30,11 +31,13 @@ function showAgentDetail(a: AgentConfigInfo) {
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`); console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
} }
export function registerAgentCommand(program: Command) { export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDeps = {}) {
const cmd = program const cmd = program
.command('agent') .command('agent')
.description('Manage agent configurations') .description('Manage agent configurations and local fleet agents')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--mosaic-home <path>', 'Mosaic home directory')
.option('--roster <path>', 'Local fleet roster path')
.option('--list', 'List all agents') .option('--list', 'List all agents')
.option('--new', 'Create a new agent') .option('--new', 'Create a new agent')
.option('--show <idOrName>', 'Show agent details') .option('--show <idOrName>', 'Show agent details')
@@ -72,6 +75,8 @@ export function registerAgentCommand(program: Command) {
}, },
); );
registerFleetAgentCommands(cmd, fleetDeps);
return cmd; return cmd;
} }

View File

@@ -0,0 +1,706 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
buildAgentSendCommand,
buildFleetServiceCommand,
generateAgentEnv,
getDefaultOperatorSourceLabel,
getRosterAgent,
loadFleetRoster,
registerFleetCommand,
resolveFleetPaths,
type CommandRunner,
} from './fleet.js';
import { registerAgentCommand } from './agent.js';
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
registerFleetCommand(program);
registerAgentCommand(program);
return program;
}
async function tempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
}
describe('registerFleetCommand', () => {
it('registers local canary fleet subcommands', () => {
const program = buildProgram();
const fleet = program.commands.find((command) => command.name() === 'fleet');
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'init',
'install',
'install-systemd',
'restart',
'start',
'status',
'stop',
'verify',
]);
});
it('adds fleet-backed agent subcommands without removing existing options', () => {
const program = buildProgram();
const agent = program.commands.find((command) => command.name() === 'agent');
expect(agent).toBeDefined();
expect(agent!.options.map((option) => option.long)).toContain('--list');
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
'reset',
'roster',
'send',
'status',
'tail',
]);
});
});
describe('fleet roster parsing', () => {
let cleanup: string | undefined;
afterEach(async () => {
if (cleanup) {
await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
}
});
it('defaults local canary rosters to the isolated mosaic-factory socket', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' class: canary',
].join('\n'),
);
const roster = await loadFleetRoster(rosterPath);
expect(roster.tmux.socketName).toBe('mosaic-factory');
expect(roster.tmux.holderSession).toBe('_holder');
expect(roster.agents).toHaveLength(1);
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
});
it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
tmux: { socket_name: 'mosaic-factory' },
defaults: { working_directory: '/srv/mosaic' },
agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
'MOSAIC_TMUX_SOCKET=mosaic-factory',
'',
].join('\n'),
);
});
it('rejects unknown roster fields instead of silently defaulting', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'tmux:',
' socketNamee: prod-fleet',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster tmux has unknown field(s): socketNamee.',
);
});
it('rejects wrong-typed roster fields instead of silently defaulting', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
tmux: { socket_name: 123 },
defaults: { working_directory: '/srv/mosaic' },
agents: [{ name: 'canary-pi', runtime: 'pi' }],
}),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster tmux socket_name must be a string.',
);
});
it('rejects wrong-typed agent fields', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'canary-pi', runtime: 42 }],
}),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster agent "canary-pi" runtime must be a string.',
);
});
it('rejects duplicate agent names before install can overwrite env files', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' - name: canary-pi',
' runtime: codex',
].join('\n'),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster has duplicate agent name: canary-pi.',
);
});
it('ships generic minimal and local-canary examples without site-specific defaults', async () => {
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
const minimal = await loadFleetRoster(join(examplesDir, 'minimal.yaml'));
const localCanaryText = await readFile(join(examplesDir, 'local-canary.yaml'), 'utf8');
const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml'));
expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']);
expect(localCanary.tmux.socketName).toBe('mosaic-factory');
expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']);
expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i);
});
});
describe('fleet command construction', () => {
it('builds exact systemd user commands for holder and agent operations', () => {
expect(buildFleetServiceCommand('status')).toEqual([
'systemctl',
'--user',
'status',
'mosaic-tmux-holder.service',
]);
expect(buildFleetServiceCommand('restart', 'coder0')).toEqual([
'systemctl',
'--user',
'restart',
'mosaic-agent@coder0.service',
]);
});
it('builds socket-scoped agent send commands', () => {
const paths = resolveFleetPaths('/home/test/.config/mosaic');
expect(
buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory', 'operator:mosaic-cli'),
).toEqual([
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
'-L',
'mosaic-factory',
'-S',
'operator:mosaic-cli',
'-s',
'coder0',
'-m',
'hello',
]);
});
it('runs fleet status through injected runner without touching tmux in tests', async () => {
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: 'ok\n', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner });
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
});
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: 'active (exited)\n', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'],
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('writes init output to the explicit roster path', async () => {
const home = await tempDir();
const rosterPath = join(home, 'custom', 'roster.yaml');
const frameworkRoot = resolve(process.cwd(), 'framework');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
]);
const content = await readFile(rosterPath, 'utf8');
expect(content).toContain('name: canary-pi');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('refuses to overwrite an existing roster unless --force is provided', async () => {
const home = await tempDir();
const rosterPath = join(home, 'custom', 'roster.yaml');
await mkdir(dirname(rosterPath), { recursive: true });
await writeFile(rosterPath, 'site-owned: true\n');
const frameworkRoot = resolve(process.cwd(), 'framework');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
try {
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
]),
).rejects.toThrow('Fleet roster already exists');
expect(await readFile(rosterPath, 'utf8')).toBe('site-owned: true\n');
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
'--force',
]);
expect(await readFile(rosterPath, 'utf8')).toContain('name: canary-pi');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects unknown init profiles instead of silently falling back', async () => {
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'typo']),
).rejects.toThrow('Unsupported fleet profile');
});
it('sets process exitCode when status runner fails', async () => {
const originalExitCode = process.exitCode;
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(process.exitCode).toBe(3);
} finally {
process.exitCode = originalExitCode;
stderrSpy.mockRestore();
}
});
it('loads default fleet/roster.json when roster.yaml is absent', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-canary', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'status', 'json-canary']);
expect(calls).toEqual([
['systemctl', '--user', 'status', 'mosaic-agent@json-canary.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('starts the holder before agents and stops agents before the holder', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);
expect(calls).toEqual([
['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'start', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: coder0',
' runtime: codex',
' - name: reviewer0',
' runtime: pi',
].join('\n'),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
if (args.includes('mosaic-agent@coder0.service')) {
return { stdout: '', stderr: 'coder0 failed\n', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await expect(program.parseAsync(['node', 'mosaic', 'fleet', 'stop'])).rejects.toThrow(
'Fleet stop completed with 1 failure(s)',
);
expect(calls).toEqual([
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-agent@reviewer0.service'],
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects install-systemd with a non-default Mosaic home because units use %h/.config/mosaic', async () => {
const home = await tempDir();
const program = new Command();
program.exitOverride();
registerFleetCommand(program, {
mosaicHome: home,
frameworkRoot: resolve(process.cwd(), 'framework'),
});
try {
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'install-systemd']),
).rejects.toThrow('install-systemd only supports the default Mosaic home');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it.each(['start', 'stop', 'restart', 'status'] as const)(
'rejects single-agent %s for agents outside the roster',
async (action) => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', action, 'typo']),
).rejects.toThrow('Agent "typo" is not in the fleet roster');
expect(runner).not.toHaveBeenCalled();
} finally {
await rm(home, { recursive: true, force: true });
}
},
);
it('loads default fleet/roster.json for agent commands when roster.yaml is absent', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-agent', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('passes a deterministic operator source label for agent sends', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-agent', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'json-agent',
'--message',
'status check',
]);
expect(calls).toEqual([
[
join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S',
getDefaultOperatorSourceLabel(),
'-s',
'json-agent',
'-m',
'status check',
],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('allows agent sends to override the source label explicitly', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'codex' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'handoff',
'--source-label',
'lead:manual',
]);
expect(calls).toEqual([
[
join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S',
'lead:manual',
'-s',
'coder0',
'-m',
'handoff',
],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects agent status typos before invoking the runner', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await expect(
program.parseAsync(['node', 'mosaic', 'agent', 'status', 'typo']),
).rejects.toThrow('Agent "typo" is not in the fleet roster');
expect(runner).not.toHaveBeenCalled();
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('keeps fleet framework assets in the published package file list', async () => {
const packageJson = JSON.parse(
await readFile(resolve(process.cwd(), 'package.json'), 'utf8'),
) as {
files?: string[];
};
expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework']));
});
});

View File

@@ -0,0 +1,866 @@
import { constants } from 'node:fs';
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir, hostname } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import type { Command } from 'commander';
import YAML from 'yaml';
export interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
export type CommandRunner = (command: string, args: string[]) => Promise<CommandResult>;
export interface FleetCommandDeps {
runner?: CommandRunner;
mosaicHome?: string;
frameworkRoot?: string;
}
interface RawFleetRoster {
version?: unknown;
transport?: unknown;
tmux?: {
socket_name?: unknown;
socketName?: unknown;
holder_session?: unknown;
holderSession?: unknown;
};
defaults?: {
working_directory?: unknown;
workingDirectory?: unknown;
};
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
agents?: Array<{
name?: unknown;
runtime?: unknown;
class?: unknown;
working_directory?: unknown;
workingDirectory?: unknown;
model_hint?: unknown;
modelHint?: unknown;
persistent_persona?: unknown;
persistentPersona?: unknown;
reset_between_tasks?: unknown;
resetBetweenTasks?: unknown;
kickstart_template?: unknown;
kickstartTemplate?: unknown;
}>;
}
export interface FleetAgent {
name: string;
runtime: string;
className: string;
workingDirectory?: string;
modelHint?: string;
persistentPersona?: boolean | string;
resetBetweenTasks?: boolean;
kickstartTemplate?: string;
}
export interface FleetRoster {
version: 1;
transport: 'tmux';
tmux: {
socketName: string;
holderSession: string;
};
defaults: {
workingDirectory: string;
};
runtimes: Record<string, { resetCommand: string }>;
agents: FleetAgent[];
}
export interface FleetPaths {
mosaicHome: string;
rosterPath: string;
toolsDir: string;
fleetToolsDir: string;
tmuxToolsDir: string;
systemdUserDir: string;
agentEnvDir: string;
}
type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
const DEFAULT_SOCKET_NAME = 'mosaic-factory';
const DEFAULT_HOLDER_SESSION = '_holder';
const DEFAULT_WORKING_DIRECTORY = '~/src';
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
claude: { resetCommand: '/clear' },
codex: { resetCommand: '/clear' },
opencode: { resetCommand: '/clear' },
pi: { resetCommand: '/new' },
};
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
return {
mosaicHome,
rosterPath: join(mosaicHome, 'fleet', 'roster.yaml'),
toolsDir: join(mosaicHome, 'tools'),
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
};
}
function defaultMosaicHome(): string {
return join(homedir(), '.config', 'mosaic');
}
function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
if (resolve(mosaicHome) !== resolve(defaultMosaicHome())) {
throw new Error(
`install-systemd only supports the default Mosaic home (${defaultMosaicHome()}) because the user systemd units use %h/.config/mosaic paths.`,
);
}
}
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
const rawText = await readFile(path, 'utf8');
const parsed = parseRosterText(rawText, path);
return normalizeRoster(parsed);
}
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
const agent = roster.agents.find((candidate) => candidate.name === name);
if (!agent) {
throw new Error(`Agent "${name}" is not in the fleet roster.`);
}
return agent;
}
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
return [
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
'',
].join('\n');
}
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
const service = agentName ? `mosaic-agent@${agentName}.service` : 'mosaic-tmux-holder.service';
return ['systemctl', '--user', action, service];
}
export function buildAgentSendCommand(
paths: FleetPaths,
agentName: string,
message: string,
socketName = DEFAULT_SOCKET_NAME,
sourceLabel = getDefaultOperatorSourceLabel(),
): string[] {
return [
join(paths.tmuxToolsDir, 'agent-send.sh'),
'-L',
socketName,
'-S',
sourceLabel,
'-s',
agentName,
'-m',
message,
];
}
export function getDefaultOperatorSourceLabel(): string {
const shortHostname = hostname().split('.')[0] || 'localhost';
return `${shortHostname}:operator`;
}
export function buildAgentResetCommand(
paths: FleetPaths,
agentName: string,
resetCommand: string,
socketName = DEFAULT_SOCKET_NAME,
): string[] {
return [
join(paths.tmuxToolsDir, 'send-message.sh'),
'-L',
socketName,
'-t',
`=${agentName}`,
'-m',
resetCommand,
];
}
export function buildAgentTailCommand(
agentName: string,
lines: number,
socketName = DEFAULT_SOCKET_NAME,
): string[] {
return [
'tmux',
'-L',
socketName,
'capture-pane',
'-t',
`=${agentName}:0.0`,
'-p',
'-S',
`-${lines}`,
];
}
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
const runner = deps.runner ?? runCommand;
const paths = resolveFleetPaths(deps.mosaicHome);
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
const cmd = program
.command('fleet')
.description('Manage the local Mosaic tmux fleet canary')
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
.option('--roster <path>', 'Fleet roster path');
cmd
.command('init')
.description('Initialize a local fleet roster')
.option('--profile <name>', 'Roster profile: minimal or local-canary', 'minimal')
.option('--write', 'Write the roster to Mosaic home')
.option('--force', 'Overwrite an existing roster when used with --write')
.action(async (opts: { profile: string; write?: boolean; force?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const profile = parseInitProfile(opts.profile);
const source = join(frameworkRoot, 'fleet', 'examples', `${profile}.yaml`);
const content = await readFile(source, 'utf8');
if (!opts.write) {
console.log(content.trimEnd());
return;
}
const destination = commandOpts.roster ?? activePaths.rosterPath;
if (!opts.force && (await canRead(destination))) {
throw new Error(
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
);
}
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content);
console.log(`Wrote fleet roster: ${destination}`);
});
cmd
.command('install')
.description('Install local fleet tools and user systemd units')
.action(async () => installFleet(cmd, frameworkRoot));
cmd
.command('install-systemd')
.description('Install local fleet tools and user systemd units')
.action(async () => installFleet(cmd, frameworkRoot));
for (const action of ['start', 'stop', 'restart'] as const) {
cmd
.command(`${action} [agent]`)
.description(`${action} the fleet holder or one agent`)
.action(async (agent?: string) => {
const roster = await loadRosterForCommand(cmd);
if (agent) {
getRosterAgent(roster, agent);
await runChecked(runner, buildFleetServiceCommand(action, agent));
return;
}
if (action === 'stop') {
await stopFleetBestEffort(
runner,
roster.agents.map((rosterAgent) => rosterAgent.name),
);
return;
}
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
}
});
}
cmd
.command('status [agent]')
.description('Show fleet holder or agent systemd status')
.option('--json', 'Print JSON status')
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
if (agent) {
const roster = await loadRosterForCommand(cmd);
getRosterAgent(roster, agent);
}
const result = await runner(...splitCommand(buildFleetServiceCommand('status', agent)));
if (opts.json) {
console.log(
JSON.stringify({
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
}),
);
setExitCodeFromResult(result);
return;
}
writeCommandOutput(result);
});
cmd
.command('verify')
.description('Verify the local canary holder and roster sessions on the isolated socket')
.action(async () => {
const roster = await loadRosterForCommand(cmd);
const socketName = roster.tmux.socketName;
await runChecked(runner, [
'tmux',
'-L',
socketName,
'has-session',
'-t',
`=${roster.tmux.holderSession}:0.0`,
]);
for (const agent of roster.agents) {
await runChecked(runner, [
'tmux',
'-L',
socketName,
'has-session',
'-t',
`=${agent.name}:0.0`,
]);
}
console.log(`Verified fleet on tmux socket ${socketName}.`);
});
return cmd;
}
export function registerFleetAgentCommands(
agentCommand: Command,
deps: FleetCommandDeps = {},
): void {
const runner = deps.runner ?? runCommand;
agentCommand
.command('roster')
.description('List agents from the local fleet roster')
.option('--json', 'Print JSON')
.action(async (opts: { json?: boolean }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
if (opts.json) {
console.log(JSON.stringify(roster, null, 2));
return;
}
for (const agent of roster.agents) {
console.log(`${agent.name}\t${agent.runtime}\t${agent.className}`);
}
});
agentCommand
.command('status [agent]')
.description('Show tmux status for the local fleet or one agent')
.option('--json', 'Print JSON')
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
if (agent) {
getRosterAgent(roster, agent);
}
const command = agent
? ['tmux', '-L', roster.tmux.socketName, 'has-session', '-t', `=${agent}:0.0`]
: ['tmux', '-L', roster.tmux.socketName, 'ls'];
const result = await runner(...splitCommand(command));
if (opts.json) {
console.log(
JSON.stringify({
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
}),
);
setExitCodeFromResult(result);
return;
}
writeCommandOutput(result);
});
agentCommand
.command('send <agent>')
.description('Send a message to a local fleet agent')
.requiredOption('--message <text>', 'Message text')
.option('--source-label <label>', 'Source label for the message preamble')
.option('--source <label>', 'Alias for --source-label')
.action(
async (agent: string, opts: { message: string; sourceLabel?: string; source?: string }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
getRosterAgent(roster, agent);
const paths = resolveFleetPaths(
resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome),
);
const sourceLabel = opts.sourceLabel ?? opts.source ?? getDefaultOperatorSourceLabel();
await runChecked(
runner,
buildAgentSendCommand(paths, agent, opts.message, roster.tmux.socketName, sourceLabel),
);
},
);
agentCommand
.command('reset <agent>')
.description('Reset a local fleet agent by sending the runtime reset command')
.option('--clear', 'Send /clear')
.option('--new', 'Send /new')
.action(async (agent: string, opts: { clear?: boolean; new?: boolean }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
const rosterAgent = getRosterAgent(roster, agent);
const paths = resolveFleetPaths(resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome));
const resetCommand = opts.clear
? '/clear'
: opts.new
? '/new'
: (roster.runtimes[rosterAgent.runtime]?.resetCommand ?? '/clear');
await runChecked(
runner,
buildAgentResetCommand(paths, agent, resetCommand, roster.tmux.socketName),
);
});
agentCommand
.command('tail <agent>')
.description('Print recent pane output for a local fleet agent')
.option('-n, --lines <number>', 'Number of pane history lines', '80')
.action(async (agent: string, opts: { lines: string }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
getRosterAgent(roster, agent);
const lines = Number.parseInt(opts.lines, 10);
const result = await runner(
...splitCommand(
buildAgentTailCommand(agent, Number.isFinite(lines) ? lines : 80, roster.tmux.socketName),
),
);
writeCommandOutput(result);
});
}
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
const roster = await loadRosterForCommand(cmd);
await mkdir(activePaths.fleetToolsDir, { recursive: true });
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
await mkdir(activePaths.systemdUserDir, { recursive: true });
await mkdir(activePaths.agentEnvDir, { recursive: true });
await copyFile(
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
join(activePaths.fleetToolsDir, 'start-agent-session.sh'),
);
await copyFile(
join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'),
join(activePaths.tmuxToolsDir, 'send-message.sh'),
);
await copyFile(
join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'),
join(activePaths.tmuxToolsDir, 'agent-send.sh'),
);
await copyFile(
join(frameworkRoot, 'systemd', 'user', 'mosaic-tmux-holder.service'),
join(activePaths.systemdUserDir, 'mosaic-tmux-holder.service'),
);
await copyFile(
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
);
for (const agent of roster.agents) {
await writeFile(
join(activePaths.agentEnvDir, `${agent.name}.env`),
generateAgentEnv(roster, agent),
);
}
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
}
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
}
async function loadRosterFromAgentCommand(
command: Command,
mosaicHomeOverride?: string,
): Promise<FleetRoster> {
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
}
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
const opts = command.optsWithGlobals<{ mosaicHome?: string }>();
return opts.mosaicHome ?? override ?? defaultMosaicHome();
}
function parseRosterText(text: string, path: string): RawFleetRoster {
const trimmed = text.trim();
if (path.endsWith('.json')) {
return JSON.parse(trimmed) as RawFleetRoster;
}
return YAML.parse(trimmed) as RawFleetRoster;
}
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
assertObject(raw, 'Fleet roster');
assertKnownKeys(raw, 'Fleet roster', [
'version',
'transport',
'tmux',
'defaults',
'runtimes',
'agents',
]);
if (raw.tmux !== undefined) {
assertObject(raw.tmux, 'Fleet roster tmux');
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
'socket_name',
'socketName',
'holder_session',
'holderSession',
]);
}
if (raw.defaults !== undefined) {
assertObject(raw.defaults, 'Fleet roster defaults');
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
'working_directory',
'workingDirectory',
]);
}
if (raw.runtimes !== undefined) {
assertObject(raw.runtimes, 'Fleet roster runtimes');
for (const [runtime, config] of Object.entries(raw.runtimes)) {
assertObject(config, `Fleet roster runtime "${runtime}"`);
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
'reset_command',
'resetCommand',
]);
}
}
if (raw.version !== 1) {
throw new Error('Fleet roster version must be 1.');
}
if (raw.transport !== 'tmux') {
throw new Error('Fleet roster transport must be "tmux".');
}
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
throw new Error('Fleet roster must define at least one agent.');
}
const agents = raw.agents.map(normalizeAgent);
assertUniqueAgentNames(agents);
return {
version: 1,
transport: 'tmux',
tmux: {
socketName: stringValue(
raw.tmux?.socket_name ?? raw.tmux?.socketName,
DEFAULT_SOCKET_NAME,
'Fleet roster tmux socket_name',
),
holderSession: stringValue(
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
DEFAULT_HOLDER_SESSION,
'Fleet roster tmux holder_session',
),
},
defaults: {
workingDirectory: stringValue(
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
DEFAULT_WORKING_DIRECTORY,
'Fleet roster defaults working_directory',
),
},
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
agents,
};
}
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
assertObject(raw, 'Fleet roster agent');
assertKnownKeys(raw, 'Fleet roster agent', [
'name',
'runtime',
'class',
'working_directory',
'workingDirectory',
'model_hint',
'modelHint',
'persistent_persona',
'persistentPersona',
'reset_between_tasks',
'resetBetweenTasks',
'kickstart_template',
'kickstartTemplate',
]);
const name = stringValue(raw.name, '', 'Fleet roster agent name');
const runtime = stringValue(
raw.runtime,
'',
`Fleet roster agent "${name || '<unknown>'}" runtime`,
);
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
}
if (!runtime) {
throw new Error(`Fleet agent "${name}" must define a runtime.`);
}
return {
name,
runtime,
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
workingDirectory: optionalString(
raw.working_directory ?? raw.workingDirectory,
`Fleet roster agent "${name}" working_directory`,
),
modelHint: optionalString(
raw.model_hint ?? raw.modelHint,
`Fleet roster agent "${name}" model_hint`,
),
persistentPersona: optionalBooleanOrString(
raw.persistent_persona ?? raw.persistentPersona,
`Fleet roster agent "${name}" persistent_persona`,
),
resetBetweenTasks: optionalBoolean(
raw.reset_between_tasks ?? raw.resetBetweenTasks,
`Fleet roster agent "${name}" reset_between_tasks`,
),
kickstartTemplate: optionalString(
raw.kickstart_template ?? raw.kickstartTemplate,
`Fleet roster agent "${name}" kickstart_template`,
),
};
}
function normalizeRuntimes(
raw: RawFleetRoster['runtimes'] | undefined,
): Record<string, { resetCommand: string }> {
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
for (const [runtime, config] of Object.entries(raw ?? {})) {
result[runtime] = {
resetCommand: stringValue(
config.reset_command ?? config.resetCommand,
'/clear',
`Fleet roster runtime "${runtime}" reset_command`,
),
};
}
return result;
}
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
}
function assertKnownKeys(
value: Record<string, unknown>,
label: string,
allowedKeys: readonly string[],
): void {
const allowed = new Set(allowedKeys);
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
if (unknownKeys.length > 0) {
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
}
}
function assertUniqueAgentNames(agents: FleetAgent[]): void {
const seen = new Set<string>();
for (const agent of agents) {
if (seen.has(agent.name)) {
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
}
seen.add(agent.name);
}
}
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
if (value === undefined) {
return fallback;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string.`);
}
return value;
}
function optionalString(value: unknown, label = 'Value'): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string.`);
}
return value;
}
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'boolean') {
throw new Error(`${label} must be a boolean.`);
}
return value;
}
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'boolean' && typeof value !== 'string') {
throw new Error(`${label} must be a boolean or string.`);
}
return value;
}
function expandHome(path: string): string {
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
}
function shellEnvValue(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = [];
for (const agentName of agentNames) {
const command = buildFleetServiceCommand('stop', agentName);
const result = await runner(...splitCommand(command));
writeSuccessfulCommandOutput(result);
if (result.exitCode !== 0) {
failures.push(result.stderr || result.stdout || `Command failed: ${command.join(' ')}`);
}
}
const holderCommand = buildFleetServiceCommand('stop');
const holderResult = await runner(...splitCommand(holderCommand));
writeSuccessfulCommandOutput(holderResult);
if (holderResult.exitCode !== 0) {
failures.push(
holderResult.stderr || holderResult.stdout || `Command failed: ${holderCommand.join(' ')}`,
);
}
if (failures.length > 0) {
throw new Error(
`Fleet stop completed with ${failures.length} failure(s): ${failures.join('; ')}`,
);
}
}
async function runChecked(runner: CommandRunner, command: string[]): Promise<void> {
const result = await runner(...splitCommand(command));
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || `Command failed: ${command.join(' ')}`);
}
if (result.stdout) {
process.stdout.write(result.stdout);
}
}
function splitCommand(command: string[]): [string, string[]] {
const [bin, ...args] = command;
if (!bin) {
throw new Error('Cannot run an empty command.');
}
return [bin, args];
}
function parseInitProfile(profile: string): 'minimal' | 'local-canary' {
if (profile === 'minimal' || profile === 'local-canary') {
return profile;
}
throw new Error(`Unsupported fleet profile "${profile}". Use: minimal, local-canary.`);
}
function writeCommandOutput(result: CommandResult): void {
if (result.stdout) {
process.stdout.write(result.stdout);
} else if (result.stderr) {
process.stderr.write(result.stderr);
}
setExitCodeFromResult(result);
}
function writeSuccessfulCommandOutput(result: CommandResult): void {
if (result.exitCode !== 0) {
return;
}
if (result.stdout) {
process.stdout.write(result.stdout);
}
}
function setExitCodeFromResult(result: CommandResult): void {
if (result.exitCode !== 0) {
process.exitCode = result.exitCode;
}
}
function runCommand(command: string, args: string[]): Promise<CommandResult> {
return new Promise((resolvePromise) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error) => {
resolvePromise({ stdout, stderr: error.message, exitCode: 127 });
});
child.on('close', (code) => {
resolvePromise({ stdout, stderr, exitCode: code ?? 1 });
});
});
}
function resolveFrameworkRoot(): string {
const currentFile = fileURLToPath(import.meta.url);
return resolve(dirname(currentFile), '..', '..', 'framework');
}
async function canRead(path: string): Promise<boolean> {
try {
await access(path, constants.R_OK);
return true;
} catch {
return false;
}
}
export async function resolveRosterPath(
mosaicHome: string,
explicitPath?: string,
): Promise<string> {
if (explicitPath) {
return explicitPath;
}
const yamlPath = resolveFleetPaths(mosaicHome).rosterPath;
if (await canRead(yamlPath)) {
return yamlPath;
}
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
return jsonPath;
}

View File

@@ -1,7 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { Command } from 'commander'; import { Command } from 'commander';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { import {
buildPiSkillArgs, buildPiSkillArgs,
enumerateSkillDirs,
piForceSkillNames, piForceSkillNames,
registerRuntimeLaunchers, registerRuntimeLaunchers,
type RuntimeLaunchHandler, type RuntimeLaunchHandler,
@@ -116,11 +120,101 @@ describe('buildPiSkillArgs', () => {
]); ]);
}); });
it('force-loads fleet skills even under native Pi discovery', () => { it('force-loads fleet skills under native Pi discovery when not already discoverable', () => {
// Empty native set => Pi would not find mosaic-tools on its own, so force it.
expect( expect(
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills, fakeForced), buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills, fakeForced, new Set()),
).toEqual(['--skill', '/skills/mosaic-tools']); ).toEqual(['--skill', '/skills/mosaic-tools']);
}); });
it('discover mode drops a forced skill Pi already discovers natively (no double-load)', () => {
// mosaic-tools is reachable from a Pi native root, so native discovery
// covers it — forcing it again would register the same skill twice.
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
fakeForced,
new Set(['/skills/mosaic-tools']),
),
).toEqual([]);
});
it('discover mode keeps a forced skill that no native root provides', () => {
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
fakeForced,
new Set(['/skills/some-other-skill']),
),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
it('discover mode collapses a forced skill listed twice to a single --skill', () => {
// Mirror 'all' mode: intra-forced-set duplicates (same realpath) dedup.
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
['--skill', '/skills/mosaic-tools', '--skill', '/skills/mosaic-tools'],
new Set(),
),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
});
describe('enumerateSkillDirs (real FS)', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-skills-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function makeSkill(parent: string, name: string): string {
const dir = join(parent, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), `# ${name}\n`);
return dir;
}
it('accepts a symlinked skill dir (regression: synced fleet skills are symlinks)', () => {
// Real skill lives under `canonical/`; the scanned root only has a symlink to it.
const canonical = makeSkill(join(root, 'canonical'), 'mosaic-tools');
const scanned = join(root, 'scanned');
mkdirSync(scanned, { recursive: true });
symlinkSync(canonical, join(scanned, 'mosaic-tools'), 'dir');
expect(enumerateSkillDirs([scanned])).toEqual(['--skill', join(scanned, 'mosaic-tools')]);
});
it('dedups by real path when the same skill is reachable from two roots', () => {
// Root A holds the real dir; root B symlinks to it — one --skill, not two.
const rootA = join(root, 'a');
const rootB = join(root, 'b');
const real = makeSkill(rootA, 'mosaic-tools');
mkdirSync(rootB, { recursive: true });
symlinkSync(real, join(rootB, 'mosaic-tools'), 'dir');
expect(enumerateSkillDirs([rootA, rootB])).toEqual(['--skill', real]);
});
it('skips directories without a SKILL.md and missing roots', () => {
mkdirSync(join(root, 'present', 'not-a-skill'), { recursive: true });
makeSkill(join(root, 'present'), 'real-skill');
expect(enumerateSkillDirs([join(root, 'present'), join(root, 'does-not-exist')])).toEqual([
'--skill',
join(root, 'present', 'real-skill'),
]);
});
}); });
describe('piForceSkillNames', () => { describe('piForceSkillNames', () => {

View File

@@ -6,7 +6,15 @@
*/ */
import { execFileSync, execSync, spawnSync } from 'node:child_process'; import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs'; import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
realpathSync,
rmSync,
} from 'node:fs';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
@@ -428,25 +436,74 @@ function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
// ─── Pi skill/extension discovery ──────────────────────────────────────────── // ─── Pi skill/extension discovery ────────────────────────────────────────────
function discoverPiSkills(): string[] { /** Resolve a skill dir to its canonical real path so symlinked duplicates
* (e.g. ~/.pi/agent/skills/X -> ~/.config/mosaic/skills/X) collapse to one key.
* Falls back to the literal path if it can't be resolved (e.g. broken link). */
function skillRealPath(dir: string): string {
try {
return realpathSync(dir);
} catch {
return dir;
}
}
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
* skills dir and the project-local one relative to the launch cwd. */
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
}
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
* counts as a skill when it (or its symlink target) contains a SKILL.md.
* Exported for tests (real-FS coverage of symlink acceptance + realpath dedup). */
export function enumerateSkillDirs(roots: string[]): string[] {
const seen = new Set<string>();
const args: string[] = []; const args: string[] = [];
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) { for (const skillsRoot of roots) {
if (!existsSync(skillsRoot)) continue; if (!existsSync(skillsRoot)) continue;
try { try {
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) { for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue; // Synced fleet skills land as symlinks, so accept both dirs and links.
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const skillDir = join(skillsRoot, entry.name); const skillDir = join(skillsRoot, entry.name);
if (existsSync(join(skillDir, 'SKILL.md'))) { if (!existsSync(join(skillDir, 'SKILL.md'))) continue;
args.push('--skill', skillDir); const key = skillRealPath(skillDir);
} if (seen.has(key)) continue;
seen.add(key);
args.push('--skill', skillDir);
} }
} catch { } catch {
// skip // skip unreadable roots
} }
} }
return args; return args;
} }
/** Every skill dir Pi would link under `MOSAIC_PI_SKILL_MODE=all`: the Mosaic
* global/local catalog plus Pi's own native roots. `--no-skills` suppresses
* native auto-discovery, so 'all' must re-add the native roots explicitly or
* they would be silently dropped. Deduped by real path. */
function discoverPiSkills(cwd: string = process.cwd()): string[] {
return enumerateSkillDirs([
join(MOSAIC_HOME, 'skills'),
join(MOSAIC_HOME, 'skills-local'),
...piNativeSkillRoots(cwd),
]);
}
/** Real paths of skills Pi will auto-discover from its native roots. Used to
* drop redundant force-loads in 'discover' mode (which keeps native discovery
* on) so the same skill is not registered twice. */
function piNativeSkillRealPaths(cwd: string = process.cwd()): Set<string> {
const args = enumerateSkillDirs(piNativeSkillRoots(cwd));
const set = new Set<string>();
for (let i = 1; i < args.length; i += 2) {
const dir = args[i];
if (dir !== undefined) set.add(skillRealPath(dir));
}
return set;
}
type PiSkillMode = 'none' | 'all' | 'discover'; type PiSkillMode = 'none' | 'all' | 'discover';
function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode { function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
@@ -492,15 +549,19 @@ function forcedPiSkillArgs(env: NodeJS.ProcessEnv = process.env): string[] {
return args; return args;
} }
/** Concatenate `--skill <dir>` arg groups, dropping any directory already seen. */ /** Concatenate `--skill <dir>` arg groups, dropping any skill already seen.
* Dedup is by real path, so a forced skill and the same skill reached via a
* different (e.g. symlinked) directory collapse to a single `--skill`. */
function mergeSkillArgs(...groups: string[][]): string[] { function mergeSkillArgs(...groups: string[][]): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
const out: string[] = []; const out: string[] = [];
for (const group of groups) { for (const group of groups) {
for (let i = 0; i < group.length; i += 2) { for (let i = 0; i < group.length; i += 2) {
const dir = group[i + 1]; const dir = group[i + 1];
if (group[i] !== '--skill' || dir === undefined || seen.has(dir)) continue; if (group[i] !== '--skill' || dir === undefined) continue;
seen.add(dir); const key = skillRealPath(dir);
if (seen.has(key)) continue;
seen.add(key);
out.push('--skill', dir); out.push('--skill', dir);
} }
} }
@@ -512,17 +573,31 @@ export function buildPiSkillArgs(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
discoveredSkillArgs: string[] = discoverPiSkills(), discoveredSkillArgs: string[] = discoverPiSkills(),
forcedSkillArgs: string[] = forcedPiSkillArgs(env), forcedSkillArgs: string[] = forcedPiSkillArgs(env),
nativeSkillRealPaths: Set<string> = piNativeSkillRealPaths(),
): string[] { ): string[] {
const mode = normalizePiSkillMode(env); const mode = normalizePiSkillMode(env);
if (mode === 'discover') { if (mode === 'discover') {
// Native Pi discovery handles the rest; still force-load the fleet skills. // Native Pi discovery stays on, so only force-load fleet skills it will NOT
return [...forcedSkillArgs]; // already find under its native roots — otherwise the same skill is
// registered twice (once natively, once via --skill). mergeSkillArgs first
// collapses any intra-forced-set realpath duplicates, mirroring 'all' mode.
const deduped = mergeSkillArgs(forcedSkillArgs);
const out: string[] = [];
for (let i = 0; i < deduped.length; i += 2) {
const dir = deduped[i + 1];
if (deduped[i] !== '--skill' || dir === undefined) continue;
if (nativeSkillRealPaths.has(skillRealPath(dir))) continue;
out.push('--skill', dir);
}
return out;
} }
if (mode === 'all') { if (mode === 'all') {
// 'all' links the full catalog; merge in the forced set so fleet-critical // 'all' links the full catalog; merge in the forced set so fleet-critical
// skills are guaranteed present even if they live only under skills-local/. // skills are guaranteed present even if they live only under skills-local/.
// discoverPiSkills already covers Pi's native roots, which `--no-skills`
// would otherwise suppress.
return ['--no-skills', ...mergeSkillArgs(discoveredSkillArgs, forcedSkillArgs)]; return ['--no-skills', ...mergeSkillArgs(discoveredSkillArgs, forcedSkillArgs)];
} }

View File

@@ -0,0 +1,755 @@
# Durable tmux Fleet Installation Plan
> **For Mosaic/Hermes:** This is an implementation plan for making the tmux-backed Mosaic software-factory fleet durable on this server and reusable in generic Mosaic Stack installs. Keep local USC/Mosaic defaults in profiles; keep framework behavior customizable.
**Goal:** Add a supported Mosaic tmux-fleet installation path: holder-owned tmux server, per-agent reusable sessions, reliable send/reset/status tools, local roster customization, and a documented cutover for this server.
**Architecture:** Mosaic should ship generic tmux fleet primitives in the framework, then layer local rosters through configuration. The holder service owns the tmux socket; each agent service joins the holder-owned server and runs `mosaic yolo <runtime>`. The orchestrator addresses agents through `mosaic agent ...` abstractions so tmux can later be replaced by Matrix-backed agent comms without changing mission flow.
**Reference:** AI Guide `playbooks/tmux-fleet.md` at commit `2a0b0b5` documents the organization-neutral holder-service pattern, exact-match `=<name>` stop targets, and coupled-server cutover/verification sequence. The Stack implementation should treat that as the lifecycle model and keep concrete Mosaic unit/tooling details here.
**Tech Stack:** Bash, tmux, user systemd units, Mosaic CLI/framework installer, JSON/YAML roster config, existing `packages/mosaic/framework/tools/tmux/{agent-send.sh,send-message.sh}`.
---
## Current evidence from this server
Checked 2026-06-19:
- Host: `W-jarvis`
- User: `jarvis`
- tmux: `/usr/bin/tmux`, version `3.4`
- user systemd: active
- existing tmux sessions: `ai-bma-0`, `dyor-1`, `melaniewoltje-3`, `sage-2`
- existing Mosaic runtime: `/home/jarvis/.npm-global/bin/mosaic`, version `0.0.31`
- installed `~/.config/mosaic/tools/tmux` was not present even though the stack repo contains `packages/mosaic/framework/tools/tmux/`
Implication: do not kill the current tmux server casually. This server has active ad-hoc/service sessions. The durable fleet cutover must be planned, with either a separate socket first or a scheduled fleet recycle.
## Design decisions
### 1. Generic framework, local profile
The Mosaic framework should ship:
- systemd unit templates;
- tmux fleet CLI wrappers;
- roster schema and examples;
- install/enable/status/reset commands;
- docs and verification scripts.
Local environments should provide:
- agent names;
- runtime per slot (`claude`, `pi`, `codex`, etc.);
- default role class;
- launch directory;
- optional kickstart prompt;
- model/provider hints;
- transport selection (`tmux` now, `matrix` later).
Do not bake the USC roster into generic install code. Ship it as an example profile.
### 2. Durable sessions, disposable task context
Session names are durable operational addresses. Task persona is disposable. Reusable worker slots should be reset with `/clear` or `/new` and then receive a fresh task kickstart.
Persistent/semi-persistent personas:
- lead orchestrator;
- final/adversarial reviewer;
- architecture/enhancement lane.
Disposable slots:
- implementers;
- ordinary reviewers;
- security reviewers unless actively holding a security mission.
### 3. Transport abstraction now
Add commands around tmux instead of calling tmux directly from orchestration:
```bash
mosaic agent send <agent> --message "..."
mosaic agent status [--json]
mosaic agent reset <agent> [--clear|--new]
mosaic agent roster [--json]
mosaic fleet install|start|stop|restart|status|verify
```
Today these call tmux/systemd. Later the same command surface can target Matrix or per-agent gateways.
### 4. Avoid shared-server ownership bug
Use the AI Guide holder pattern:
```text
mosaic-tmux-holder.service owns the tmux server/socket
mosaic-agent@<name>.service joins the existing holder-owned socket
ExecStop kills only session =<name>
```
Use exact tmux targets: `=<session>`.
### 5. Prefer separate named socket for Mosaic factory
To avoid disturbing existing tmux work, the default fleet should use a named socket such as:
```text
$XDG_RUNTIME_DIR/mosaic-factory.tmux
```
or tmux socket name:
```bash
tmux -L mosaic-factory ...
```
This avoids collision with ordinary `tmux ls` sessions. The send tools need socket support.
---
## Target USC-style roster example
Ship as example only, not default:
```yaml
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
working_directory: ~/src
agents:
- name: mos-claude
runtime: claude
class: orchestrator
model_hint: Claude Opus
persistent_persona: true
- name: coder0
runtime: claude
class: implementer
model_hint: Claude Opus
reset_between_tasks: true
- name: coder1
runtime: claude
class: implementer
model_hint: Claude Opus
reset_between_tasks: true
- name: coder2
runtime: pi
class: implementer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: coder3
runtime: pi
class: implementer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: coder4
runtime: claude
class: implementer
model_hint: Claude Opus
reset_between_tasks: true
- name: coder5
runtime: claude
class: implementer
model_hint: Claude Opus
reset_between_tasks: true
- name: enhance
runtime: claude
class: enhancer
model_hint: Claude Opus
persistent_persona: semi
- name: rev0
runtime: pi
class: reviewer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: rev1
runtime: pi
class: reviewer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: secrev0
runtime: pi
class: security_reviewer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: secrev1
runtime: pi
class: security_reviewer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: ultron
runtime: pi
class: final_reviewer
model_hint: Pi GPT-5.5
persistent_persona: semi
```
---
## Phase 0 — Confirm install surfaces
### Task 0.1: Inspect installer copy behavior
**Objective:** Confirm how framework files under `packages/mosaic/framework/` become installed under `~/.config/mosaic/`.
**Files:**
- Read: `tools/install.sh`
- Read: `packages/mosaic/framework/install.sh`
- Read: `packages/mosaic/src/runtime/install-manifest.ts`
**Steps:**
1. Verify `packages/mosaic/framework/install.sh` rsyncs `tools/tmux`.
2. Verify whether npm-packaged installs include `framework/tools/tmux`.
3. Confirm whether installed hosts should run `mosaic update`, `bash tools/install.sh`, or `packages/mosaic/framework/install.sh` to receive new tmux tools.
4. Record exact propagation command in docs.
**Verification:**
```bash
bash packages/mosaic/framework/install.sh --help || true
npm pack --dry-run --json | jq '.[0].files[].path' | grep 'framework/tools/tmux'
```
Expected: tmux tools are included in installable package or packaging fix is identified.
### Task 0.2: Inspect current yolo launch semantics
**Objective:** Confirm `mosaic yolo claude` and `mosaic yolo pi` accept optional initial prompt text and behave well under systemd/tmux.
**Files:**
- Read: `packages/mosaic/src/**`
- Read: `packages/mosaic/framework/runtime/claude/RUNTIME.md`
- Read: `packages/mosaic/framework/runtime/pi/RUNTIME.md`
**Verification commands:**
```bash
mosaic yolo claude --help
mosaic yolo pi --help
```
Expected: a systemd `ExecStart` can launch the runtime either with no prompt or with a kickstart prompt file/string.
---
## Phase 1 — Framework tmux primitives
### Task 1.1: Add socket support to send tools
**Objective:** Allow `agent-send.sh` and `send-message.sh` to target a named Mosaic tmux socket without affecting default tmux sessions.
**Files:**
- Modify: `packages/mosaic/framework/tools/tmux/send-message.sh`
- Modify: `packages/mosaic/framework/tools/tmux/agent-send.sh`
- Modify: `packages/mosaic/framework/tools/tmux/README.md`
- Test: `packages/mosaic/framework/tools/tmux/test-send-message.sh` (new)
**Design:**
Add optional flags:
```bash
-L SOCKET_NAME # tmux -L socket name
-SOCKET PATH # optional later if needed; avoid conflict with existing -S source label in agent-send
```
Because `agent-send.sh` already uses `-S` for source label, prefer `-L` for socket name and `-T` or `--socket-path` only if long-option parsing is added.
**Implementation notes:**
- Build a tmux command array:
```bash
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then tmux_cmd+=( -L "$SOCKET_NAME" ); fi
```
- Replace raw `tmux ...` calls with `"${tmux_cmd[@]}" ...`.
- Pass `-L` through remote ssh invocation.
- Include socket name in verbose output.
**Verification:**
```bash
tmux -L mosaic-test new-session -d -s target 'cat'
packages/mosaic/framework/tools/tmux/send-message.sh -L mosaic-test -t target -m 'hello'
tmux -L mosaic-test capture-pane -t target -p | grep hello
tmux -L mosaic-test kill-server
```
Expected: message lands in the named socket session; default `tmux ls` is untouched.
### Task 1.2: Add exact target validation helper
**Objective:** Prevent accidental prefix targeting in all tmux fleet operations.
**Files:**
- Create: `packages/mosaic/framework/tools/tmux/_lib.sh`
- Modify: `send-message.sh`
- Modify: `agent-send.sh`
**Behavior:**
- For session-only agent names, normalize target to `=<name>` before kill/status/reset operations.
- For explicit pane targets like `session:window.pane`, allow as advanced path but document the risk.
**Verification:**
Create sessions `agent` and `agent0`; verify killing/resetting `agent` does not affect `agent0`.
---
## Phase 2 — systemd unit templates
### Task 2.1: Add holder service template
**Objective:** Ship a user systemd unit template that owns the Mosaic factory tmux server.
**Files:**
- Create: `packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service`
- Create: `packages/mosaic/framework/tools/fleet/install-user-units.sh`
**Unit shape:**
```ini
[Unit]
Description=Mosaic tmux fleet holder
Documentation=https://git.mosaicstack.dev/mosaicstack/aiguide
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
ExecStart=/usr/bin/tmux -L ${MOSAIC_TMUX_SOCKET} new-session -d -s _holder 'while true; do sleep 3600; done'
ExecStop=-/usr/bin/tmux -L ${MOSAIC_TMUX_SOCKET} kill-server
[Install]
WantedBy=default.target
```
**Important:** systemd environment expansion in `ExecStart` is limited. Verify syntax; if `%E`/environment expansion is awkward, generate concrete units from config instead of relying on dynamic expansion.
**Verification:**
```bash
systemd-analyze --user verify ~/.config/systemd/user/mosaic-tmux-holder.service
systemctl --user daemon-reload
systemctl --user start mosaic-tmux-holder.service
tmux -L mosaic-factory ls | grep _holder
```
### Task 2.2: Add agent service template
**Objective:** Ship a user systemd template that starts one configured agent slot.
**Files:**
- Create: `packages/mosaic/framework/systemd/user/mosaic-agent@.service`
- Modify: `packages/mosaic/framework/tools/fleet/install-user-units.sh`
**Unit shape:**
```ini
[Unit]
Description=Mosaic agent session %i
Requires=mosaic-tmux-holder.service
After=mosaic-tmux-holder.service
PartOf=mosaic-tmux-holder.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=%h/src
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "%i" "mosaic yolo $(mosaic fleet runtime %i)"'
ExecStop=-/usr/bin/tmux -L mosaic-factory kill-session -t '=%i'
[Install]
WantedBy=default.target
```
**Design warning:** command substitution in unit files can become brittle. Prefer a generated per-agent EnvironmentFile:
```text
~/.config/mosaic/fleet/agents/coder0.env
```
with:
```bash
MOSAIC_AGENT_NAME=coder0
MOSAIC_AGENT_RUNTIME=claude
MOSAIC_AGENT_WORKDIR=/home/jarvis/src
MOSAIC_TMUX_SOCKET=mosaic-factory
```
Then `ExecStart` calls a wrapper:
```bash
~/.config/mosaic/tools/fleet/start-agent-session.sh
```
**Verification:**
```bash
systemd-analyze --user verify ~/.config/systemd/user/mosaic-agent@.service
systemctl --user start mosaic-agent@coder0.service
tmux -L mosaic-factory has-session -t '=coder0'
systemctl --user restart mosaic-agent@coder0.service
```
Expected: holder server PID remains unchanged; only `coder0` session recycles.
### Task 2.3: Add start-agent wrapper
**Objective:** Keep systemd units simple by moving config lookup and launch command construction into a script.
**Files:**
- Create: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
**Behavior:**
Inputs:
```bash
start-agent-session.sh <agent-name>
```
Reads:
```text
$MOSAIC_HOME/fleet/agents/<agent-name>.env
```
Starts:
```bash
tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" "mosaic yolo $MOSAIC_AGENT_RUNTIME"
```
Guardrails:
- fail if runtime is empty;
- fail if workdir does not exist;
- no duplicate sessions unless `--replace` is passed;
- exact session names only.
---
## Phase 3 — roster config and CLI wrappers
### Task 3.1: Add fleet config schema and examples
**Objective:** Define customizable install-time roster without hardcoding USC.
**Files:**
- Create: `packages/mosaic/framework/fleet/roster.schema.json`
- Create: `packages/mosaic/framework/fleet/examples/minimal.yaml`
- Create: `packages/mosaic/framework/fleet/examples/usc-software-factory.yaml`
- Create: `packages/mosaic/framework/fleet/README.md`
**Schema concepts:**
- `transport`: `tmux` now; `matrix` later.
- `tmux.socket_name`
- `tmux.holder_session`
- `defaults.working_directory`
- `agents[].name`
- `agents[].runtime`
- `agents[].class`
- `agents[].model_hint`
- `agents[].persistent_persona`
- `agents[].reset_between_tasks`
- `agents[].kickstart_template`
**Verification:**
Use `jq` for JSON examples or add a small Python/YAML validator if YAML is chosen. If no YAML parser is guaranteed, store examples as JSON or support both with Python stdlib JSON first.
### Task 3.2: Add `mosaic fleet` commands
**Objective:** Provide operator-safe commands for install/status/start/stop/restart/verify.
**Files:**
- Modify: `packages/mosaic/src/cli.ts` or the current commander entrypoint.
- Create scripts under: `packages/mosaic/framework/tools/fleet/`
**Commands:**
```bash
mosaic fleet init --profile minimal|usc --write
mosaic fleet install-systemd
mosaic fleet start [agent]
mosaic fleet stop [agent]
mosaic fleet restart [agent]
mosaic fleet status --json
mosaic fleet verify
```
**Implementation path:**
Start by wrapping framework shell scripts from the TypeScript CLI. Do not overbuild a TypeScript service manager in the first pass.
### Task 3.3: Add `mosaic agent` commands
**Objective:** Provide transport-stable per-agent operations.
**Files:**
- Modify: Mosaic CLI entrypoint.
- Create: `packages/mosaic/framework/tools/agent/` or reuse `tools/tmux` + `tools/fleet`.
**Commands:**
```bash
mosaic agent roster [--json]
mosaic agent status [agent] [--json]
mosaic agent send <agent> --message "..."
mosaic agent reset <agent> --clear|--new
mosaic agent tail <agent> [-n 80]
```
**Reset behavior:**
For tmux transport, `reset --clear` sends `/clear` then Enter through `send-message.sh`.
For Claude/Pi differences, keep reset command configurable per runtime:
```yaml
runtimes:
claude:
reset_command: /clear
pi:
reset_command: /new
```
If a runtime does not support a known reset command, restart the service and send a fresh kickstart.
---
## Phase 4 — this-server rollout strategy
### Task 4.1: Install on separate socket first
**Objective:** Prove the holder pattern without disturbing existing sessions.
**Commands after implementation lands locally:**
```bash
mosaic fleet init --profile minimal --write
mosaic fleet install-systemd
systemctl --user daemon-reload
systemctl --user start mosaic-tmux-holder.service
mosaic fleet verify
```
Expected:
- `tmux -L mosaic-factory ls` shows `_holder`.
- normal `tmux ls` still shows existing sessions unchanged.
### Task 4.2: Start one canary agent
**Objective:** Validate single-agent start/restart isolation.
Use a harmless canary first, not the full fleet.
Example roster addition:
```yaml
- name: canary-pi
runtime: pi
class: canary
working_directory: /home/jarvis/src
```
Commands:
```bash
systemctl --user start mosaic-agent@canary-pi.service
SRV=$(tmux -L mosaic-factory display-message -p '#{pid}')
systemctl --user restart mosaic-agent@canary-pi.service
test "$SRV" = "$(tmux -L mosaic-factory display-message -p '#{pid}')"
tmux -L mosaic-factory ls
```
Expected: holder PID unchanged; `_holder` remains; `canary-pi` recreated.
### Task 4.3: Configure local Mosaic factory roster
**Objective:** Create the actual local roster for this server after canary passes.
Do not assume USC exact roster is desired here. Create a local profile such as:
```text
~/.config/mosaic/fleet/roster.yaml
```
Initial local recommendation:
- `mos-claude` orchestrator
- `coder0` / `coder1` implementers
- `rev0` reviewer
- `secrev0` security reviewer
- `ultron` final/adversarial reviewer
Scale to full USC-style pool only after resource/budget behavior is understood.
### Task 4.4: Cut over existing ad-hoc tmux sessions only if desired
**Objective:** Avoid data loss.
Existing sessions on this server are not on the proposed `mosaic-factory` socket. They can remain untouched. If we later want them under Mosaic fleet control:
1. list sessions;
2. capture logs/handoffs;
3. stop old processes intentionally;
4. recreate as configured `mosaic-agent@...` services;
5. verify comms and state.
Do not run `tmux kill-server` on the default socket unless Jason explicitly approves that outage.
---
## Phase 5 — docs and AI Guide backfill
### Task 5.1: Stack docs
**Objective:** Document install and customization for Mosaic Stack users.
**Files:**
- Create: `docs/fleet/tmux-fleet.md` or `packages/mosaic/framework/tools/fleet/README.md`
- Modify: top-level `README.md` if appropriate.
Must cover:
- what problem holder service solves;
- install commands;
- customization file;
- example rosters;
- reset/reuse lifecycle;
- exact-target safety;
- separate socket default;
- Matrix migration path.
### Task 5.2: AI Guide docs
**Objective:** Keep generic guidance in AI Guide and implementation details in Stack.
**Files in `mosaicstack/aiguide`:**
- Update: `playbooks/tmux-fleet.md` with named socket, roster/profile, and resettable-slot pattern.
- Add or update: `reference/agent-role-matrix.md` if PR #5 lands.
Do not put Mosaic install commands as the only path in AI Guide. Present them as one implementation profile.
---
## Phase 6 — Matrix migration seam
### Task 6.1: Add transport enum but implement tmux only
**Objective:** Avoid hardcoding tmux into orchestration semantics.
Roster:
```yaml
transport: tmux
```
Future:
```yaml
transport: matrix
matrix:
homeserver: https://matrix.example
room_prefix: mosaic-factory
```
### Task 6.2: Define transport interface docs
**Objective:** Make Matrix plugin work a transport swap, not a rewrite.
Minimum operations:
```text
send(agent, message)
reset(agent, mode)
status(agent)
tail(agent)
listAgents()
```
Any tmux-specific concept must stay below this line.
---
## Acceptance criteria
The implementation is complete when:
- `mosaic fleet init` can write a minimal roster.
- `mosaic fleet install-systemd` installs holder and agent units without hand editing.
- `mosaic fleet start` starts the holder and configured agents on a named tmux socket.
- Restarting one `mosaic-agent@name.service` does not change holder server PID or kill sibling sessions.
- `mosaic agent send` can deliver a message to a named agent with a self-identifying preamble.
- `mosaic agent reset` can clear/new a reusable slot and send a fresh kickstart.
- `mosaic fleet verify` proves holder ownership, exact-target safety, and per-agent restart isolation.
- Existing default tmux sessions on this server are not disturbed by default install.
- Docs explain generic customization and include USC-style roster only as an example.
- AI Guide remains generic; Mosaic Stack docs carry the concrete install path.
## Risks and mitigations
| Risk | Mitigation |
| --------------------------------------------------- | --------------------------------------------------------------------------------- |
| Killing existing tmux sessions | Use named `mosaic-factory` socket; no default `tmux kill-server`. |
| systemd unit quoting/env expansion bugs | Move logic into shell wrappers; verify with `systemd-analyze --user verify`. |
| Runtime reset command mismatch | Make reset command runtime-configurable; fallback to service restart + kickstart. |
| Tool install drift | Ensure npm package includes framework tmux/fleet tools; add packaging test. |
| Mosaic-specific assumptions leak into generic guide | Keep USC roster as example profile; AI Guide documents pattern/options. |
| Matrix migration blocked by tmux coupling | Add `mosaic agent` abstraction now; keep tmux details below transport layer. |
## Suggested first PR split
1. **PR A — tmux tool hardening**
- socket support;
- exact target helpers;
- tests/docs.
2. **PR B — fleet systemd primitives**
- holder unit;
- agent unit;
- start-agent wrapper;
- install-user-units script;
- verify script.
3. **PR C — roster and CLI**
- roster schema/examples;
- `mosaic fleet ...` commands;
- `mosaic agent ...` commands.
4. **PR D — local rollout and docs**
- local roster for this server;
- run canary;
- document verification evidence;
- update AI Guide with generic lessons.
## Immediate next action
Implement PR A first. It is low-risk, improves existing tools, and is required for a safe named-socket rollout on this server.