Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5a8d2cec | ||
|
|
e14ad9ab52 | ||
|
|
9fd16b9739 | ||
|
|
9051ad179b | ||
|
|
2f649ed930 | ||
|
|
db330c12c7 | ||
|
|
0a17d29bce | ||
|
|
3458f6a7ad | ||
|
|
b033952cd5 | ||
|
|
c102980ad4 | ||
|
|
58b96cb715 | ||
|
|
dd3ff944a1 | ||
|
|
8eb81ebec1 | ||
|
|
530597cc84 | ||
|
|
9a0d44f96a | ||
|
|
121b331c6c | ||
|
|
34e06e7de7 | ||
|
|
9bd4f1c405 | ||
|
|
a7b612435b |
@@ -91,6 +91,7 @@ Full reference — usage, fields, exit codes, safety notes:
|
||||
## Pointers (depth lives here)
|
||||
|
||||
- `docs/plans/CURRENT.md` — THE next action (single source of "what now")
|
||||
- `docs/plans/ROADMAP.md` — agreed milestone path (M16+)
|
||||
- `docs/plans/CONDUCTOR.md` — orchestration protocol and guardrails
|
||||
- `docs/plans/2026-09-02_atomic-mosaic-foundation.md` — architecture, invariants
|
||||
- `docs/plans/2026-09-03_autonomous-run.md` — batch-run tracker
|
||||
|
||||
+100
@@ -337,4 +337,104 @@ Conductor loop proven end-to-end on the stack itself. `main` merged with M8; rel
|
||||
|
||||
Session forking verified. `main` merged with M11, tagged `session-fork-v1`; release 0.0.7 active.
|
||||
|
||||
---
|
||||
|
||||
## Phase 15: Interactive TUI agent + TOOLS.md (M13)
|
||||
|
||||
### Entry 15.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: Add scripts/agent.sh — an interactive TUI launcher (contracts + optional mission + agent identity + named session + optional workspace/tools) — and the pi-adapter interactive branch; remove the fixed compose command; add docs/TOOLS.md as the on-demand reference AGENTS.md routes to; RELEASE -> 0.0.8 (Gitea #35).
|
||||
- Reason: The owner's bootstrap model is vanilla pi sessions directed by AGENTS.md, graduating to governed TUI agents — the first the system itself launches.
|
||||
- Expected result: TUI agent launches with contracts+identity context; headless paths unchanged; TOOLS.md consolidates the reference.
|
||||
|
||||
### Entry 15.2 — after
|
||||
|
||||
- Observed: mock plumbing asserts agent name/session/workspace/mission delivery; identity section asserted in generated prompt; headless hello + suites green (24/58/17/14 + verify); 0.0.8 packaged and health-gated activated.
|
||||
- Failure or correction:
|
||||
1. Regression: pi adapter rewrite made MOSAIC_AGENT_NAME unconditionally required, breaking headless paths — caught by task suite (empty-stderr exit-nonzero), fixed (optional in headless; identity section simply omitted).
|
||||
2. Regression: unquoted $REQUEST_ARG word-split the request into positional args — fixed with positional-argument building (set -- ... "$@").
|
||||
3. Mission fixture wording (objective named the agent) invited the model to append its name after the marker, tripping the strict gate — fixture tightened; strict gate kept by design.
|
||||
- Conductor session env hygiene: sandbox config exports now scoped per-command after a leak broke cross-suite runs.
|
||||
|
||||
## Result (M13)
|
||||
|
||||
Interactive TUI agent launched and verified; TOOLS.md reference shipped. `main` merged with M13, tagged `interactive-agent-v1`; release 0.0.8 active.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 16: Run-record retention (M10) — recorded retroactively
|
||||
|
||||
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled entry; the original phase entry was lost to editor races - see correction note below)
|
||||
- Summary: `mosaic-task.mjs prune [--keep=N] [--yes]` - keep newest N run records, dry-run by default, append-only `.pruned.log` receipt, sessions/workspaces/state untouched. 8 suite cases.
|
||||
- Observed: dry-run deletes nothing; keep-N honored; newest kept; receipt written; isolation asserted.
|
||||
- Correction (recorded): suite-hardening edits (prune section config scoping, duplicate helper removal) were applied in the same phase.
|
||||
|
||||
## Phase 17: Conductor auto-apply policy (M12) — recorded retroactively
|
||||
|
||||
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
|
||||
- Summary: `conductor-policy.json` (tracked, strictly validated: enabled switch, path allowlist, gating suites) + `scripts/conductor-apply.sh <runId>` - succeeded-run check, clean target tree, allowlist, syntax gates, apply, suites, attribution commit; any failure reverts; push never automatic. 17 sandbox suite cases.
|
||||
- Observed: all gates green; suite-failure auto-revert verified; disabled policy refuses with exit 2.
|
||||
- Decision recorded: review moves to after-the-fact (history revertible) for worker patches under the policy; push remains explicit.
|
||||
|
||||
## Phase 18: Live user context (M14) — recorded retroactively
|
||||
|
||||
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
|
||||
- Summary: USER.md removed from immutable contracts (wrong owner - user info is user-owned live context); `<dataRoot>/user/*.md` dispatched (sorted) into every agent launch's generated prompt; bootstrap seeds `user/USER.md` once; loader layers now governance -> persona -> identity -> mission -> user.
|
||||
- Observed: user edit propagates to next launch (TUI or headless) without rebuilds; contract-only prompts unchanged when no user dir present.
|
||||
- Correction (recorded): first implementation pass did not regenerate the container - caught by owner test (edits to repo/mirror copies of USER.md did not propagate; the design, not the test, was the defect).
|
||||
|
||||
## Phase 19: Agent seats + roles/ convention (M15) — recorded retroactively
|
||||
|
||||
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
|
||||
- Summary: `agents/<name>/` holds `agent.json` (strict validation: version, name, role?, capabilities?, workspace?, session?) + `SOUL.md` persona; `agent.sh` validates, copies runtime SOUL to `dataRoot/agents/<name>/`, sets `MOSAIC_AGENT_SOUL_FILE`; loader fills the SOUL slot from the seat persona (contract SOUL = default); identity section gains role; per-agent default workspace; compose passthroughs.
|
||||
- Observed: launch with seat definition replaces the contract persona in the generated prompt; role in identity; seat record written once; suites green; RELEASE 0.0.10 packaged, health-gated activated.
|
||||
- Correction (recorded): RELEASE was not bumped when container content changed - tag r0.0.9 rebuilt with different content (invariant lapse; r0.0.9 was never active). Restored: RELEASE 0.0.10 packaged beside, health-gated, activated.
|
||||
- Conventions recorded per owner direction: repository root holds bootstrap-required configuration only; role contracts live in `roles/`.
|
||||
|
||||
## Backfill note
|
||||
|
||||
Phases 16-19 were recorded retroactively on 2026-09-03 after editor-session races
|
||||
left them unwritten at the time of work. Ground truth for each entry: the git
|
||||
history (commit subjects/bodies), the Gitea milestone/issue records (#32, #34,
|
||||
#35, #36), and the suite files themselves. No facts were reconstructed from
|
||||
memory alone.
|
||||
|
||||
---
|
||||
|
||||
## Phase 20: Release self-determination (M16)
|
||||
|
||||
### Entry 20.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: The system determines what is installed and aligns itself; the user never manually runs release.sh. `release.sh ensure` (align-or-noop) invoked automatically by human-facing launchers (hello, verify, agent); run-task reports drift without auto-aligning (workers/suites must not trigger builds or model gates mid-automation).
|
||||
- Reason: Owner direction — intelligence in operation; post-reset pointer loss required a manual release command, which contradicts the self-healing design.
|
||||
- Expected result: post-reset pointer loss auto-restores via health-gated ensure; drift warns on run-task; aligned state is a no-op; M20 packages/* decision recorded.
|
||||
|
||||
### Entry 20.2 — after
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Commands run: ensure with pointer removed (live drift); ensure idempotence; drift-warning demo via RELEASE bump without packaging; full suites.
|
||||
- Observed result: drift detected (active: none, desired: 0.0.11) -> health-gated activate -> aligned; second ensure no-op; run-task drift warning fired on desired-version bump without packaging; all suites green (24/68/17/14 + verify).
|
||||
- Failure or correction:
|
||||
1. First run-task edit anchor missed (inline comment mismatch) — reapplied with exact text.
|
||||
2. RELEASE was temporarily bumped to 0.0.12 without packaging for the drift demo — restored to 0.0.11; state pointer remained aligned.
|
||||
- M20 decision recorded in ROADMAP.md: v2 adopts packages/* monorepo structure at usurpation (owner, continuity-first).
|
||||
- Process note (tool races): mechanism confirmed — batching a file write/edit and a dependent bash command in one parallel block runs the bash before the write flushes. Consequences: lost doc updates, stale anchors, one premature grep. Operating rule: dependent calls sequential; every write verified before claimed complete.
|
||||
|
||||
## Result (M16)
|
||||
|
||||
Release self-determination live: the system aligns itself to RELEASE without manual commands. Suites 24/68/17/14 + verify green.
|
||||
|
||||
## Phase 21: M16 hardening + M20 decision
|
||||
|
||||
- Recursion guard: release.sh's health-gate task run sets MOSAIC_ENSURE_SKIP
|
||||
so the gate's run-task cannot re-enter release self-determination.
|
||||
- run-task.sh: drift warning on pointer/RELEASE mismatch (workers and suites
|
||||
never trigger builds or model gates mid-automation).
|
||||
- ROADMAP M20 decision recorded: v2 adopts packages/* monorepo structure at
|
||||
usurpation (owner, continuity-first); restructure sequenced as M20 phase 1.
|
||||
- Live drill: drift 0.0.11 -> 0.0.12 detected, health-gated activate, no
|
||||
recursion, verify green.
|
||||
|
||||
@@ -92,10 +92,15 @@ scripts/release.sh package # build + tag the release image
|
||||
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
|
||||
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
|
||||
scripts/release.sh rollback # health-gated return to the previous release
|
||||
scripts/release.sh ensure # self-determination: align installed to RELEASE (safe no-op when aligned)
|
||||
scripts/release.sh status # release, tag, active pointer, recent log
|
||||
scripts/test-release.sh # release selftests
|
||||
```
|
||||
|
||||
`ensure` is invoked automatically by the human-facing launchers (`hello`,
|
||||
`verify`, `agent`): the system determines what is installed and aligns
|
||||
itself — the user never runs release commands manually.
|
||||
|
||||
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
|
||||
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
|
||||
|
||||
|
||||
+14
-1
@@ -47,6 +47,19 @@ fi
|
||||
TOOLS_FLAG="--no-tools"
|
||||
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
|
||||
|
||||
# Skills (M17): explicitly provided skill dirs replace discovery. When none
|
||||
# are provided the agent runs with --no-skills (nothing ambient to find).
|
||||
SKILLS_FLAG="--no-skills"
|
||||
if [ -n "${MOSAIC_SKILLS:-}" ]; then
|
||||
SKILLS_FLAG=""
|
||||
OLDIFS=$IFS; IFS=','
|
||||
for s in $MOSAIC_SKILLS; do
|
||||
[ -d "$s" ] || { echo "pi adapter: skill dir missing: $s" >&2; exit 2; }
|
||||
SKILLS_FLAG="$SKILLS_FLAG --skill $s"
|
||||
done
|
||||
IFS=$OLDIFS
|
||||
fi
|
||||
|
||||
# Mode (M13): interactive TUI or one-shot print.
|
||||
PRINT_MODE="-p"
|
||||
REQUEST_ARG=""
|
||||
@@ -68,7 +81,7 @@ PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
||||
set -- \
|
||||
--offline \
|
||||
--no-extensions \
|
||||
--no-skills \
|
||||
$SKILLS_FLAG \
|
||||
--no-prompt-templates \
|
||||
--no-themes \
|
||||
--no-context-files \
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# SOUL - researcher
|
||||
|
||||
You are the researcher seat of the Mosaic fleet. You are curious, methodical,
|
||||
and precise. You cite what you know, admit what you do not, and never guess
|
||||
when you can verify.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"agentVersion": 1,
|
||||
"name": "researcher",
|
||||
"role": "researcher",
|
||||
"capabilities": { "tools": ["read", "bash"] }
|
||||
}
|
||||
@@ -24,6 +24,10 @@ services:
|
||||
# Interactive TUI mode + agent identity (M13, set by scripts/agent.sh)
|
||||
MOSAIC_INTERACTIVE: ${MOSAIC_INTERACTIVE:-}
|
||||
MOSAIC_AGENT_NAME: ${MOSAIC_AGENT_NAME:-}
|
||||
MOSAIC_AGENT_ROLE: ${MOSAIC_AGENT_ROLE:-}
|
||||
MOSAIC_AGENT_SOUL_FILE: ${MOSAIC_AGENT_SOUL_FILE:-}
|
||||
# Skill dirs explicitly provided to the seat (M17)
|
||||
MOSAIC_SKILLS: ${MOSAIC_SKILLS:-}
|
||||
# mock adapter only: verbatim response for deterministic seam tests
|
||||
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
|
||||
# Documented container auth alternative: provider API key via
|
||||
|
||||
@@ -7,3 +7,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
| Date (UTC) | Actor | Scope | Outcome / artifacts |
|
||||
|---|---|---|---|
|
||||
| 2026-09-03 | assistant (conductor + worker) | POC through M12: containerized pi proof, config layer, missions/tasks, release model, adapter seam, workspaces/capabilities, named sessions, retention, session forking, conductor auto-apply, roles/ convention | 13 tags; suites 24/58/14 + 17 conductor + verify green; releases 0.0.1–0.0.7; issues #1–#34 closed |
|
||||
| 2026-09-03 | assistant (conductor) | User layer: profile updates (pets, family), ms-user skill review/revision (confirmation rules merged, propose-not-apply, missing-file flow, privacy scope, dispatch = all of user/, rule 9 scratch-file constraint), USER.md.bak removed | skills/ms-user/SKILL.md rewritten; ~/.mosaic-dev/user/USER.md updated (Family, Pets); USER.md.bak deleted |
|
||||
|
||||
+26
-36
@@ -7,20 +7,21 @@ update this file to the next action). No ambiguity, no re-planning.
|
||||
|
||||
## Next action
|
||||
|
||||
Owner review of M12 (conductor auto-apply policy) — then name the next target.
|
||||
M16 — release self-determination (`release.sh ensure` invoked at launch; post-reset auto-restore; no manual release commands). Owner approved 2026-09-03 — say "next" to execute.
|
||||
|
||||
## Queue (ordered, not started)
|
||||
## Queue (ordered per docs/plans/ROADMAP.md)
|
||||
|
||||
1. Second real adapter (parked — owner focused on Pi)
|
||||
2. Session forking from a common ancestor — SHIPPED in M11; exercise via session demos
|
||||
3. UX/DX backlog #31 (grep highlight vs status colors — likely closed by owner's unalias)
|
||||
4. Push policy decision: auto-apply commits locally; push remains explicit (documented in CONDUCTOR.md)
|
||||
1. M17 — ms-tools skill (central tooling documentation; AGENTS.md references it; workers excluded via --no-skills)
|
||||
2. M18 — seat-role progressive capability restriction (role ceilings ∩ mission ∩ task)
|
||||
3. M19 — harness auth tooling (pi checkpoint: auth status, provider stacking, multi-account investigation)
|
||||
4. Deferred by owner: CI runners (Gitea hardware slow); second real adapter; push automation
|
||||
|
||||
## Rules
|
||||
|
||||
- One action in flight. Update this file at the END of every action.
|
||||
- Blocked? Move the item to "Blocked" below with the reason and stop.
|
||||
- Completed actions move to the log at the bottom (date + issue + result).
|
||||
- Corrected entries are marked, never silently rewritten (see 2026-09-03 dedup note).
|
||||
|
||||
## Blocked
|
||||
|
||||
@@ -28,34 +29,23 @@ Owner review of M12 (conductor auto-apply policy) — then name the next target.
|
||||
|
||||
## Completed log
|
||||
|
||||
- 2026-09-03 — M9 mission capability policy (#30) — merged, least-privilege intersection
|
||||
- 2026-09-03 — test UX: green OK/red FAIL status colors (#31 adjacent) — terminal-only, pipe-safe
|
||||
- 2026-09-03 — M10 run-record retention (#32) — merged, prune keep-N/dry-run/receipt, 49/24/14 + verify green
|
||||
- 2026-09-03 — M11 session forking (#33) — merged, child recalls ancestor context, ancestor untouched, 58/24/14 + verify green; release 0.0.7 activated
|
||||
- 2026-09-03 — M12 conductor auto-apply policy (#34) — committed on main, 17/24/58/14 + verify green; push stays explicit
|
||||
- 2026-09-03 — repository convention: role contracts move to roles/ (root = bootstrap-only, per owner direction)
|
||||
Note (2026-09-03): this log was deduplicated after editor-session races
|
||||
appended duplicate blocks. The dedup removed repeated lines only; every
|
||||
distinct action appears exactly once, in completion order. Ground truth:
|
||||
git history + Gitea issues.
|
||||
|
||||
- 2026-09-03 — M9 mission capability policy (#30) — merged, least-privilege intersection
|
||||
- 2026-09-03 — test UX: green OK/red FAIL status colors (#31 adjacent) — terminal-only, pipe-safe
|
||||
- 2026-09-03 — M10 run-record retention (#32) — merged, prune keep-N/dry-run/receipt, 49/24/14 + verify green
|
||||
- 2026-09-03 — M11 session forking (#33) — merged, child recalls ancestor context, ancestor untouched, 58/24/14 + verify green; release 0.0.7 activated
|
||||
- 2026-09-03 — M12 conductor auto-apply policy (#34) — committed on main, 17/24/58/14 + verify green; push stays explicit
|
||||
|
||||
- 2026-09-03 — M9 mission capability policy (#30) — merged, least-privilege intersection
|
||||
- 2026-09-03 — test UX: green OK/red FAIL status colors (#31 adjacent) — terminal-only, pipe-safe
|
||||
- 2026-09-03 — M10 run-record retention (#32) — merged, prune keep-N/dry-run/receipt, 49/24/14 + verify green
|
||||
- 2026-09-03 — M11 session forking (#33) — merged, child recalls ancestor context, ancestor untouched, 58/24/14 + verify green; release 0.0.7 activated
|
||||
|
||||
- 2026-09-03 — M9 mission capability policy (#30) — merged, least-privilege intersection
|
||||
- 2026-09-03 — test UX: green OK/red FAIL status colors (#31 adjacent) — terminal-only, pipe-safe
|
||||
- 2026-09-03 — M10 run-record retention (#32) — merged, prune keep-N/dry-run/receipt, 49/24/14 + verify green
|
||||
|
||||
- 2026-09-03 — M5 workspaces + capability envelope (#20, #21) — merged, tests green
|
||||
- 2026-09-03 — M6 named sessions + resume (#22, #23) — merged, teach/recall verified
|
||||
- 2026-09-03 — M7 run inspection + release 0.0.6 (#24) — merged, activated
|
||||
- 2026-09-03 — M8 conductor loop + worker-built retry (#25, #26, #27) — merged; worker authored retry in 2 refinement rounds, conductor fixed a 3-line interpolation rename; live retry verified
|
||||
- 2026-09-03 — retry lineage + relative mission resolution (#28) — merged, 36/24/14 suites + verify green
|
||||
- 2026-09-03 — M9 mission capability policy (#30) — merged, least-privilege intersection, 41/36/14 + verify green
|
||||
- 2026-09-03 — M8 conductor loop + worker-built retry (#25, #26, #27) — merged; worker authored retry in 2 refinement rounds, conductor fixed a 3-line interpolation rename; live retry verified
|
||||
- 2026-09-03 — retry lineage + relative mission resolution (#28) — merged, 36/24/14 suites + verify green
|
||||
- 2026-09-03 — M8 conductor loop + worker-built retry (#25, #26, #27) — merged; worker authored retry in 2 refinement rounds, conductor fixed a 3-line interpolation rename; live retry verified
|
||||
- 2026-09-03 — POC: containerized pi hello-world (poc-container-hello-v0)
|
||||
- 2026-09-03 — M1 configuration-driven hello world (#1–#4; config-hello-v1); hotfix #5 stdin detach
|
||||
- 2026-09-03 — M2 mission/task abstraction (#6–#9; mission-task-v1); hotfix #14 release identity in task path
|
||||
- 2026-09-03 — M3 release model + safe updates (#10–#13; release-model-v1); drills: update/refusal/rollback
|
||||
- 2026-09-03 — M14 live user context layer (user/ dispatched to all launches; 0.0.9 built)
|
||||
- 2026-09-03 — M15 agent seats: per-agent SOUL + role contracts (#36; agent-seats-v1); roles/ convention (root = bootstrap-only)
|
||||
- 2026-09-03 — M13 interactive TUI agent + TOOLS.md (#35; interactive-agent-v1); release 0.0.8 activated
|
||||
- 2026-09-03 — M12 conductor auto-apply policy (#34; auto-apply-v1); 17 conductor selftests
|
||||
- 2026-09-03 — M11 session forking (#33; session-fork-v1); child recalls ancestor, base untouched
|
||||
- 2026-09-03 — M10 run-record retention (#32; retention-v1); prune keep-N, dry-run default, receipt
|
||||
- 2026-09-03 — M9 mission capability policy (#30; mission-policy-v1); least-privilege intersection
|
||||
- 2026-09-03 — test UX: green OK/red FAIL status colors; NO_COLOR-aware
|
||||
- 2026-09-03 — M10-era hotfix: retry lineage (#28) + AGENTS.md/SESSIONS.md recovery shim
|
||||
- 2026-09-03 — release 0.0.10 packaged and health-gated activated (user context + agent seats live)
|
||||
- 2026-09-03 — release 0.0.11 shipped (onboarding + live user context); ROADMAP.md agreed (M16–M19); CI deferred by owner
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# HARVEST — patterns from mosaicstack/stack + the live fleet runtime
|
||||
|
||||
Sources surveyed 2026-09-03 (owner-authorized):
|
||||
- `~/src/mosaic-stack` (drifted) and fresh clone at
|
||||
`~/src/mosaic-stack-worktrees/v2-harvest` (branch `next` @ 0db2d19a,
|
||||
"mosaic doctor structure-anchor provisioning check (T51 WP0b)")
|
||||
- `~/.mosaic/fleet/` — live fleet runtime: `bin/`, `roles/` (+`-ng`),
|
||||
`agents/<seat>/`, `auth/`, `roster.yaml`, `SEAT-CUTOVER.md`
|
||||
|
||||
Rule honored: nothing pulled whole-cloth. This file is the pattern ledger;
|
||||
each adoption lands through its own milestone with tests.
|
||||
|
||||
## Pattern ledger
|
||||
|
||||
| # | Pattern (source) | Verdict | Destination |
|
||||
|---|---|---|---|
|
||||
| 1 | **Channel dispatcher** — `fleet/bin/mosaic`: shipped npm CLI is default; dev worktree opt-in via `MOSAIC_CLI_WORKTREE` health-checked with `--version`; `MOSAIC_FLEET_CLI_OFF` forces pass-through; launcher never writes to user dirs | **steal** | M20 unified `mosaic` CLI |
|
||||
| 2 | **Single canonical launcher + per-seat env** — `bin/launch-seat.sh` is the only launcher; per-seat variation lives in `launch.env` ("per-seat copies drift") | **steal** | M20 CLI + agent.sh stays thin |
|
||||
| 3 | **Refuse-rather-than-guess provisioning** — `bin/new-seat.sh`: seat classes, `--adopt` never overwrites real identity files, `--mint` fails closed (unminted = git writes fail closed), `--dry-run` | **steal** | seat instantiation (M15 evolution) |
|
||||
| 4 | **Role manifest schema v3** — `roles/code-ng/mosaic-core.manifest.json`: capability bindings (id/effect/binding/status), `tools[]`, `forbiddenTools[]`, `shell.mode: denied`, `credentials` policy, `evidence` journaling, `digestRules` postcondition (`active-set-exact`, sha256 over manifest) | **adapt progressively** | M18 seat-role restriction |
|
||||
| 5 | **Per-seat git identity** — `MOSAIC_GIT_IDENTITY=<seat>` + `GIT_AUTHOR_NAME/EMAIL`; incident 2026-08-19: seven seats committed as the wrong identity via directory git config | **steal** | M18/M20 (we have no per-seat git writes yet — adopt before we do) |
|
||||
| 6 | **Auth slots + symlink shares** — `fleet/auth/<provider>/<account>/` credential dirs; seat `.pi/agent/` symlinks `auth.json settings.json models-store.json bin skills` | **adapt** | M19 auth tooling |
|
||||
| 7 | **Launch-time pre-flight probes** — every consumed path checked before start; pi's `existsSync` silently appends literal path strings as prompt text (documented trap) | **steal** | M19 launch check + our existing refusal style |
|
||||
| 8 | **Prompt ordering doctrine** — estate context first, seat SOUL/AGENTS last ("closest to the task wins") | **matches ours** | document in adapters/README |
|
||||
| 9 | **`--check` mode** on launchers (verify provisioning without launching) | **steal** | agent.sh / release ensure |
|
||||
| 10 | **ENTITY.md per seat** — identity record incl. git identity, explicitly secret-free | **steal** | seat records (extend seat.json or add ENTITY.md) |
|
||||
| 11 | **`mosaic doctor`** — structure-anchor provisioning check (T51) | **adapt** | M20 `mosaic --check` |
|
||||
| 12 | packages/ anatomy on `next`: `mosaic` (CLI), `auth`, `agent`, `config`, `forge`, `quality-rails`, `comms`, `macp` | reference map | M18/M19/M20 deep reads |
|
||||
|
||||
## Skips (for now)
|
||||
|
||||
- tmux comms watchers, lanes/, board/, memory/, journal/ — L5/L6 fleet
|
||||
territory; our conductor loop is deliberately not a resident service.
|
||||
- openbao/swarm/coolify compose stacks — deployment, far future.
|
||||
- roster.yaml + roster.schema.json — superseded by our agents/ definitions
|
||||
for now; revisit when fleet registration returns.
|
||||
|
||||
## Owner-corrected design points (2026-09-03)
|
||||
|
||||
- M17 revised: skills must reach **non-conductor seats as role-scoped
|
||||
subsets** — agents invent tooling when the right tool isn't supplied.
|
||||
Role contracts will declare skill sets; the runner loads them explicitly
|
||||
(mock-verified `--skill` + `--no-skills` coexistence pending).
|
||||
- M19 revised (owner's own diagnosis): auth selection was symlinks from
|
||||
`agents/<seat>/.pi` into `fleet/auth`, and a missing symlink silently
|
||||
fell back to defaults. Our design: the launcher reads the seat's declared
|
||||
account, **ensures** the symlink/auth selection before TUI launch, and
|
||||
refuses loudly on mismatch — check + fix, never silent default.
|
||||
- Unified `mosaic` CLI is directional (`mosaic tui`, `mosaic yolo
|
||||
<harness>`, `mosaic --check`): tracked as M20, harvested from pattern #1
|
||||
and stack `packages/mosaic`.
|
||||
@@ -0,0 +1,185 @@
|
||||
# ROADMAP — agreed path forward
|
||||
|
||||
Status: planning aligned with owner 2026-09-03. Feature work follows this
|
||||
order unless the owner re-orders. Design notes below are commitments about
|
||||
intent, not implementation — each milestone still gets its own issue, tests,
|
||||
and BUILD-LOG phase.
|
||||
|
||||
## Standing decisions (owner, 2026-09-03)
|
||||
|
||||
- **Pi is the main TUI harness** and the reference checkpoint. Multi-harness
|
||||
(Codex, Claude, OpenCode) comes after pi is stable as the primary seat.
|
||||
- **CI is deferred.** Gitea runs on slow hardware; local suites + verify are
|
||||
the quality gate. Revisit when code volume justifies runners.
|
||||
- **The user never manually runs release.sh.** The system determines what is
|
||||
installed and self-aligns (M16).
|
||||
- **Tooling documentation is agent-accessible** via the ms-tools skill (M17),
|
||||
referenced from AGENTS.md.
|
||||
- **Capability restriction becomes progressive and role-based** (M18),
|
||||
porting prior Mosaic Stack concepts where they fit the file-based runtime.
|
||||
|
||||
## M16 — Release self-determination
|
||||
|
||||
Problem: after `reset.sh` the activation pointer is gone and the owner had
|
||||
to know about `scripts/release.sh activate`. The system must determine what
|
||||
is actually installed.
|
||||
|
||||
Design:
|
||||
- New `release.sh ensure` (also invoked automatically by the launchers via
|
||||
`common.sh`): compares desired (`RELEASE`) vs packaged image vs active
|
||||
pointer.
|
||||
- Fast path (drift = pointer missing/mismatched, image already packaged):
|
||||
restore the pointer without a health gate — the release was previously
|
||||
health-gated; reset is data loss, not a new release.
|
||||
- Slow path (new version in RELEASE, or image missing): package beside →
|
||||
full health gate (exact-marker model run) → atomic pointer swap. This is
|
||||
the M3 gate-then-flip, now automatic.
|
||||
- Acceptance: post-`reset.sh`, the next `hello.sh`/`agent.sh` self-restores
|
||||
activation with zero manual release commands; a bumped `RELEASE` auto-
|
||||
activates after its health gate; a failing health gate leaves the previous
|
||||
release active and records the refusal.
|
||||
|
||||
## M17 — ms-tools skill + role-scoped skill lifecycle (DESIGN REVISED per owner)
|
||||
|
||||
Problem: agents without supplied tooling invent their own, even when a
|
||||
functional method exists. Skills must reach non-conductor seats as
|
||||
role-scoped subsets, with an explicit lifecycle.
|
||||
|
||||
Design (owner-specified):
|
||||
- Two machine-scoped directories under the data root:
|
||||
- `<dataRoot>/skills-enabled/` — loadable by agents
|
||||
- `<dataRoot>/skills-available/` — installed but disabled
|
||||
- Lifecycle: **install** → available · **activate** → enabled ·
|
||||
**deactivate** → back to available · **uninstall** → removed.
|
||||
A skill not in skills-enabled is not enabled or available for use.
|
||||
- Launch resolution: role contracts declare skill NAMES; the runner resolves
|
||||
each against skills-enabled and passes `--skill <path>` explicitly.
|
||||
`--skill` necessarily negates `--no-skills` (explicit provision wins).
|
||||
Contracted-but-not-enabled → **refuse the launch loudly** (a silently
|
||||
under-equipped seat is the exact failure mode this prevents).
|
||||
- Containerization removes ambiguity: agents get only what is explicitly
|
||||
provided; no discovery, no ambient skill leakage.
|
||||
- Interfaces: CLI first (`mosaic skill install|activate|deactivate|uninstall|list`),
|
||||
TUI and WebUI frontends call the same tooling later.
|
||||
- The ms-tools skill is the first shipped skill (tooling reference for
|
||||
conductor/vanilla sessions); role contracts then grant subsets per seat.
|
||||
|
||||
## M18 — Seat-role progressive capability restriction
|
||||
|
||||
Problem: capability restriction exists but is per-task/per-mission only.
|
||||
Seats (agent identities) need governing ceilings that tasks cannot escalate
|
||||
past — the M9 intersection chain extended: **role ceiling ∩ mission grant ∩
|
||||
task grant = effective**.
|
||||
|
||||
Design:
|
||||
- `roles/<role>.json` (role contracts, tracked): `roleVersion`, `name`,
|
||||
`tools` ceiling (subset of pi built-ins), `network: "none"|"api-only"|`
|
||||
`"open"` (declared now, enforced when network policy lands), `paths`
|
||||
(future).
|
||||
- Resolution chain in the runner: seat role contract ∩ mission capabilities
|
||||
∩ task capabilities → effective tools (existing intersection logic, one
|
||||
more link). Empty at any stage = tool-free run, loudly recorded.
|
||||
- agent.json keeps `role` (already shipped in M15) — it now binds to
|
||||
`roles/<role>.json`.
|
||||
- Porting note: prior Mosaic Stack role/policy concepts inform this design,
|
||||
but implementation is fresh on the file-based runtime (the standing
|
||||
repository exclusion applies until the owner points at specific prior
|
||||
files to port).
|
||||
- Adapters unchanged: `MOSAIC_TOOLS` already carries the effective set.
|
||||
|
||||
## M19 — Harness auth tooling (pi checkpoint)
|
||||
|
||||
Problem: auth is manual (`/login`, `auth.json`); multi-account and
|
||||
multi-harness stacking have no tooling.
|
||||
|
||||
Current facts (observed): one `auth.json` holds multiple providers
|
||||
(anthropic, openai-codex, zai observed on the host) — provider stacking
|
||||
already works. Multiple accounts per provider is the open question (the
|
||||
owner runs two Claude accounts; openai-codex multi-account desired).
|
||||
|
||||
Design direction (pi-first checkpoint):
|
||||
- `scripts/auth.sh status` — per-provider auth report (which providers have
|
||||
credentials, source: auth.json vs env), no secrets printed.
|
||||
- Multi-account: investigate pi's auth schema for per-provider account
|
||||
entries; if unsupported natively, model accounts as named auth files
|
||||
(`auth.<account>.json`) with a conductor-level selector — runtime-injected
|
||||
per launch, same pattern as the read-only credential mount.
|
||||
- Claude/Codex/OpenCode adapters stay parked; the auth tooling is
|
||||
harness-neutral so it serves them when they arrive.
|
||||
|
||||
## M20+ — Unified `mosaic` CLI and the stack succession path
|
||||
|
||||
Direction (owner): a unified `mosaic` CLI (`mosaic --check`, `mosaic tui`,
|
||||
`mosaic yolo <harness>`, `mosaic skill ...`, `mosaic release ensure`, ...).
|
||||
Much larger task; channel-dispatcher doctrine harvested from
|
||||
`fleet/bin/mosaic` (HARVEST.md #1).
|
||||
|
||||
Succession trajectory (owner): the v2 line is actively building the
|
||||
REPLACEMENT for the mosaicstack/stack repo. At the appropriate level of
|
||||
usurpation, v2 content becomes THE next-branch content on
|
||||
mosaicstack/stack — a full refactor-and-replace with a functional system.
|
||||
Not ready yet.
|
||||
|
||||
DECIDED (owner, 2026-09-03): v2 reorganizes to the `packages/*` monorepo
|
||||
structure for continuity with the existing stack tooling and expectations.
|
||||
Reference: harvest worktree `~/src/mosaic-stack-worktrees/v2-harvest`
|
||||
(branch `next` @ 0db2d19a) and `packages/mosaic` (CLI), `packages/auth`.
|
||||
Usurpation criteria to be defined before any replacement (proposed:
|
||||
functional parity of P0 operations on the v2 runtime + owner sign-off).
|
||||
|
||||
### Restructure sequencing (decided 2026-09-03, owner question)
|
||||
|
||||
The monorepo restructure is NOT done first. Reason: the bash entry points'
|
||||
final home is *replacement by* `packages/mosaic` commands, not containment -
|
||||
moving them into packages/ now, then replacing them at M20, migrates
|
||||
everything twice and churns the suites (the safety net) during peak feature
|
||||
work. Instead:
|
||||
|
||||
1. **Layout doctrine frozen now** (this file + AGENTS.md): nothing new lands
|
||||
at root; features build in their durable homes (`skills/`, `roles/`,
|
||||
`docs/`, `adapters/`, `src/`).
|
||||
2. **M20 phase 1 = restructure**: `packages/` monorepo skeleton absorbs the
|
||||
script entry points as CLI commands (`packages/mosaic`), with
|
||||
`packages/agent`, `packages/auth` following the harvested anatomy. The
|
||||
bash scripts retire as they are absorbed.
|
||||
3. **Succession**: at usurpation, v2 content (already in `packages/*` shape)
|
||||
becomes the next-branch content of mosaicstack/stack.
|
||||
|
||||
### Target tree (post-M20-phase-1)
|
||||
|
||||
/ bootstrap + entry docs (unchanged)
|
||||
+-- packages/
|
||||
| +-- mosaic/ the CLI: tui, yolo, skill, release, onboard, check
|
||||
| +-- agent/ seat/session runtime (absorbs src/ + adapters/)
|
||||
| +-- auth/ provider slots, account ensure (absorbs M19)
|
||||
| +-- config/ schema + policy validation (absorbs config tooling)
|
||||
+-- contracts/ roles/ missions/ tasks/ templates/ declarative inputs
|
||||
+-- skills/ shipped skills (installed to dataRoot on activate)
|
||||
+-- docs/ plans, harvest, SESSIONS, TOOLS
|
||||
+-- src/ container runtime (loader, dispatcher)
|
||||
|
||||
### Skills as discipline encoding (owner insight, 2026-09-03)
|
||||
|
||||
Skills are not tool menus - they encode operational discipline: order of
|
||||
operations, gotchas, and failure history. The tool-race lesson and every
|
||||
catalogued defect become skill content per role (e.g. a conductor skill
|
||||
carries: sequential dependent calls; verify writes before claiming;
|
||||
symlink-ensure before TUI; path pre-checks before pi flags). Role
|
||||
contracts (M17) declare which skill subsets each seat receives.
|
||||
|
||||
## Explicitly deferred
|
||||
|
||||
- CI runners (owner: Gitea hardware is slow; local suites are the gate) —
|
||||
revisit as code volume grows.
|
||||
- Network egress policy enforcement (declared in role contracts at M18;
|
||||
enforcement is its own milestone).
|
||||
- Push automation (push stays an explicit act).
|
||||
- Second real adapter; fleet/multi-agent; knowledge storage; portal.
|
||||
|
||||
## Completed reference (see docs/plans/CURRENT.md log)
|
||||
|
||||
M1 config-driven hello · M2 missions/tasks · M3 release model · M4 adapter
|
||||
seam · M5 workspaces/capabilities · M6 sessions · M7 ergonomics · M8
|
||||
conductor loop · M9 mission capability policy · M10 retention · M11 session
|
||||
forking · M12 auto-apply policy · M13 interactive TUI agent · M14 live user
|
||||
context · M15 agent seats + roles/ convention.
|
||||
+87
-5
@@ -22,6 +22,7 @@ MISSION=""
|
||||
WORKSPACE=""
|
||||
SESSION=""
|
||||
TOOLS=""
|
||||
SKILLS=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -29,6 +30,7 @@ while [ $# -gt 0 ]; do
|
||||
--workspace) WORKSPACE="${2:?}"; shift 2 ;;
|
||||
--session) SESSION="${2:?}"; shift 2 ;;
|
||||
--tools) TOOLS="${2:?}"; shift 2 ;;
|
||||
--skills) SKILLS="${2:?}"; shift 2 ;;
|
||||
--help|-h) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
*) NAME="$1"; shift ;;
|
||||
esac
|
||||
@@ -40,14 +42,92 @@ case "$NAME" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid agent name" >&2; exit
|
||||
load_config
|
||||
load_release
|
||||
bootstrap_runtime_dir
|
||||
ensure_release_aligned
|
||||
|
||||
# Onboarding gate (M16): a TUI agent cannot launch without a user profile.
|
||||
# The onboarding wizard runs automatically here - the TTY is already yours.
|
||||
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
|
||||
echo "agent: no user profile found - starting onboarding"
|
||||
scripts/onboard.sh
|
||||
[ -f "$MOSAIC_DEV_DIR/user/USER.md" ] || { echo "agent: onboarding did not complete; aborting launch" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# Agent seat definition (M15): when agents/<name>/agent.json exists it is
|
||||
# strictly validated and its values become defaults (CLI flags override).
|
||||
# The seat's SOUL.md overrides the contract persona; governance contracts
|
||||
# are never overridden.
|
||||
AGENTS_DIR="${MOSAIC_AGENTS_DIR:-agents}"
|
||||
ROLE=""
|
||||
DEFCAPS=""
|
||||
AGENT_DEF_SKILLS=""
|
||||
AGENT_DEF_SKILLS=""
|
||||
if [ -f "$AGENTS_DIR/$NAME/agent.json" ]; then
|
||||
DEFAULTS_FILE="$(mktemp)"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
if (p.agentVersion !== 1) process.exit(2);
|
||||
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
if (typeof p.name !== "string" || !ID.test(p.name)) process.exit(2);
|
||||
if (p.role !== undefined && (typeof p.role !== "string" || !ID.test(p.role))) process.exit(2);
|
||||
let tools = "";
|
||||
if (p.capabilities !== undefined) {
|
||||
if (typeof p.capabilities !== "object" || p.capabilities === null || Array.isArray(p.capabilities)) process.exit(2);
|
||||
for (const k of Object.keys(p.capabilities)) if (k !== "tools") process.exit(2);
|
||||
if (!Array.isArray(p.capabilities.tools) || p.capabilities.tools.some(t => !/^[a-z]+$/.test(t))) process.exit(2);
|
||||
tools = p.capabilities.tools.join(",");
|
||||
}
|
||||
fs.writeFileSync(process.argv[2], "AGENT_DEF_ROLE=" + (p.role || "") + "\nAGENT_DEF_CAPS=" + tools + "\nAGENT_DEF_SKILLS=" + ((p.skills && Array.isArray(p.skills)) ? p.skills.join(",") : "") + "\n");
|
||||
' "$AGENTS_DIR/$NAME/agent.json" "$DEFAULTS_FILE" || { rm -f "$DEFAULTS_FILE"; echo "agent: invalid agent definition" >&2; exit 2; }
|
||||
AGENT_DEF_ROLE=""; AGENT_DEF_CAPS=""; AGENT_DEF_SKILLS=""
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
AGENT_DEF_ROLE=*) AGENT_DEF_ROLE="${line#AGENT_DEF_ROLE=}" ;;
|
||||
AGENT_DEF_CAPS=*) AGENT_DEF_CAPS="${line#AGENT_DEF_CAPS=}" ;;
|
||||
AGENT_DEF_SKILLS=*) AGENT_DEF_SKILLS="${line#AGENT_DEF_SKILLS=}" ;;
|
||||
esac
|
||||
done < "$DEFAULTS_FILE"
|
||||
rm -f "$DEFAULTS_FILE"
|
||||
ROLE="$AGENT_DEF_ROLE"
|
||||
DEFCAPS="$AGENT_DEF_CAPS"
|
||||
[ -r "$AGENTS_DIR/$NAME/SOUL.md" ] || { echo "agent: definition dir missing SOUL.md: $AGENTS_DIR/$NAME" >&2; exit 4; }
|
||||
mkdir -p "$MOSAIC_DEV_DIR/agents/$NAME"
|
||||
cp "$AGENTS_DIR/$NAME/SOUL.md" "$MOSAIC_DEV_DIR/agents/$NAME/SOUL.md"
|
||||
export MOSAIC_AGENT_SOUL_FILE="/var/lib/mosaic/agents/$NAME/SOUL.md"
|
||||
# Seat record: written once at instantiation.
|
||||
SEAT="$MOSAIC_DEV_DIR/agents/$NAME/seat.json"
|
||||
if [ ! -f "$SEAT" ]; then
|
||||
printf '{"seatVersion":1,"name":"%s","role":"%s","instantiatedAt":"%s"}\n' \
|
||||
"$NAME" "$ROLE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SEAT"
|
||||
fi
|
||||
fi
|
||||
|
||||
SESSION="${SESSION:-agent-$NAME}"
|
||||
mkdir -p "$MOSAIC_DEV_DIR/sessions/$SESSION"
|
||||
export MOSAIC_SESSION_DIR="/var/lib/mosaic/sessions/$SESSION"
|
||||
export MOSAIC_AGENT_NAME="$NAME"
|
||||
[ -n "$ROLE" ] && export MOSAIC_AGENT_ROLE="$ROLE"
|
||||
export MOSAIC_INTERACTIVE=1
|
||||
if [ -z "$TOOLS" ] && [ -n "$DEFCAPS" ]; then TOOLS="$DEFCAPS"; fi
|
||||
export MOSAIC_TOOLS="${TOOLS:+$TOOLS}"
|
||||
|
||||
# Skills (M17): seat definition may declare skill names; each must be
|
||||
# enabled in <dataRoot>/skills-enabled or the launch refuses - a silently
|
||||
# under-equipped seat is the failure mode this prevents.
|
||||
SKILLS_LIST="${SKILLS:-$AGENT_DEF_SKILLS}"
|
||||
if [ -n "$SKILLS_LIST" ]; then
|
||||
mkdir -p "$MOSAIC_DEV_DIR/skills-enabled"
|
||||
RESOLVED=""
|
||||
OLDIFS=$IFS; IFS=','
|
||||
for s in $SKILLS_LIST; do
|
||||
case "$s" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid skill name: '$s'" >&2; exit 2;; esac
|
||||
[ -d "$MOSAIC_DEV_DIR/skills-enabled/$s" ] || { echo "agent: skill '$s' is declared but not enabled (scripts/skill.sh activate $s)" >&2; exit 1; }
|
||||
RESOLVED="${RESOLVED:+$RESOLVED,}/var/lib/mosaic/skills-enabled/$s"
|
||||
done
|
||||
IFS=$OLDIFS
|
||||
export MOSAIC_SKILLS="$RESOLVED"
|
||||
fi
|
||||
|
||||
if [ -n "$MISSION" ]; then
|
||||
[ -r "$MISSION" ] || { echo "agent: mission file not readable: $MISSION" >&2; exit 4; }
|
||||
mkdir -p "$MOSAIC_DEV_DIR/agent-missions"
|
||||
@@ -55,11 +135,13 @@ if [ -n "$MISSION" ]; then
|
||||
export MOSAIC_MISSION_FILE="/var/lib/mosaic/agent-missions/$NAME.json"
|
||||
fi
|
||||
|
||||
if [ -n "$WORKSPACE" ]; then
|
||||
case "$WORKSPACE" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid workspace name" >&2; exit 4;; esac
|
||||
mkdir -p "$MOSAIC_DEV_DIR/workspaces/$WORKSPACE"
|
||||
export MOSAIC_WORKSPACE="/var/lib/mosaic/workspaces/$WORKSPACE"
|
||||
fi
|
||||
# Workspace (M13): defaults to a persistent per-agent workspace
|
||||
# (workspaces/<agent>) so the agent has a real, host-visible home instead
|
||||
# of the container's neutral /workspace. Override with --workspace <ws>.
|
||||
[ -n "$WORKSPACE" ] || WORKSPACE="$NAME"
|
||||
case "$WORKSPACE" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid workspace name" >&2; exit 4;; esac
|
||||
mkdir -p "$MOSAIC_DEV_DIR/workspaces/$WORKSPACE"
|
||||
export MOSAIC_WORKSPACE="/var/lib/mosaic/workspaces/$WORKSPACE"
|
||||
|
||||
echo "agent: launching TUI agent '$NAME' (session: $SESSION, adapter: $MOSAIC_ADAPTER, model: $MOSAIC_MODEL)"
|
||||
echo "agent: contracts + $([ -n "$MISSION" ] && echo 'mission' || echo 'no mission') loaded; exit the TUI with /quit"
|
||||
|
||||
@@ -50,4 +50,24 @@ bootstrap_runtime_dir() {
|
||||
echo "bootstrap: created $MOSAIC_DEV_DIR"
|
||||
fi
|
||||
touch "$MOSAIC_DEV_DIR/$POC_ROOT_MARKER"
|
||||
# Live user context layer (M14/M16): the directory is system-managed,
|
||||
# but USER.md is NEVER auto-created. Onboarding (scripts/onboard.sh,
|
||||
# auto-invoked by agent.sh) creates it from the template.
|
||||
mkdir -p "$MOSAIC_DEV_DIR/user"
|
||||
}
|
||||
|
||||
# M16 release self-determination: launchers call this after load_release.
|
||||
# Cheap no-op when the active pointer matches the desired RELEASE; on drift
|
||||
# it delegates to `release.sh ensure` (package if needed, health-gated
|
||||
# activate). The health gate's own task run sets MOSAIC_ENSURE_SKIP so the
|
||||
# ensure never re-enters itself.
|
||||
ensure_release_aligned() {
|
||||
[ -n "${MOSAIC_ENSURE_SKIP:-}" ] && return 0
|
||||
local pointer="$MOSAIC_DEV_DIR/state/active.json"
|
||||
local active=""
|
||||
if [ -f "$pointer" ]; then
|
||||
active="$(node -e 'try{const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(p.release||"")}catch{}' "$pointer" 2>/dev/null)"
|
||||
fi
|
||||
[ "$active" = "$MOSAIC_RELEASE" ] && return 0
|
||||
MOSAIC_ENSURE_SKIP=1 scripts/release.sh ensure
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ load_config
|
||||
load_release
|
||||
|
||||
bootstrap_runtime_dir
|
||||
ensure_release_aligned
|
||||
|
||||
# -T: no pseudo-TTY, so stdout is clean model output.
|
||||
# </dev/null: detach stdin. Pi's print mode reads piped stdin until EOF;
|
||||
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# User onboarding: renders the user profile (user/USER.md) from the canon
|
||||
# template. User name is REQUIRED; everything else is optional at this time
|
||||
# (skillset is intended to be filled ongoing by agents in later layers).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/onboard.sh # interactive prompts (TTY)
|
||||
# scripts/onboard.sh --name "Jason" [--timezone "Europe/Berlin"] [...]
|
||||
#
|
||||
# Fields: name (required), profession, marital-status, age, gender,
|
||||
# education, location, timezone, skillset, interests, hobbies, pets
|
||||
#
|
||||
# The rendered profile is dispatched to every agent launch. Re-running
|
||||
# onboarding replaces the profile (the previous file is kept as .bak).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
# shellcheck source=common.sh
|
||||
source scripts/common.sh
|
||||
|
||||
load_config
|
||||
load_release
|
||||
bootstrap_runtime_dir
|
||||
|
||||
TEMPLATE="templates/USER.md"
|
||||
OUT_DIR="$MOSAIC_DEV_DIR/user"
|
||||
OUT="$OUT_DIR/USER.md"
|
||||
[ -f "$TEMPLATE" ] || { echo "onboard: template missing: $TEMPLATE" >&2; exit 4; }
|
||||
|
||||
NAME="" PROFESSION="" MARITAL_STATUS="" AGE="" GENDER="" EDUCATION=""
|
||||
LOCATION="" TIMEZONE="" SKILLSET="" INTERESTS="" HOBBIES="" PETS=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name) NAME="${2:-}"; shift 2 ;;
|
||||
--profession) PROFESSION="${2:-}"; shift 2 ;;
|
||||
--marital-status) MARITAL_STATUS="${2:-}"; shift 2 ;;
|
||||
--age) AGE="${2:-}"; shift 2 ;;
|
||||
--gender) GENDER="${2:-}"; shift 2 ;;
|
||||
--education) EDUCATION="${2:-}"; shift 2 ;;
|
||||
--location) LOCATION="${2:-}"; shift 2 ;;
|
||||
--timezone) TIMEZONE="${2:-}"; shift 2 ;;
|
||||
--skillset) SKILLSET="${2:-}"; shift 2 ;;
|
||||
--interests) INTERESTS="${2:-}"; shift 2 ;;
|
||||
--hobbies) HOBBIES="${2:-}"; shift 2 ;;
|
||||
--pets) PETS="${2:-}"; shift 2 ;;
|
||||
*) echo "onboard: unknown option: $1" >&2; exit 4 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
INTERACTIVE="no"
|
||||
if [ -t 0 ] && [ -t 1 ]; then INTERACTIVE="yes"; fi
|
||||
|
||||
trim() { local v="$1"; v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"; printf '%s' "$v"; }
|
||||
valid_value() { local v="$1"; [ -n "$v" ] && [ "${#v}" -le 500 ] && [ "${v//$'\n'/}" = "$v" ] && [ "${v//$'\r'/}" = "$v" ]; }
|
||||
|
||||
# Required: name
|
||||
if [ -z "$(trim "$NAME")" ]; then
|
||||
if [ "$INTERACTIVE" = "yes" ]; then
|
||||
while [ -z "$(trim "$NAME")" ]; do
|
||||
read -r -p "Your name (required): " NAME || NAME=""
|
||||
NAME="$(trim "$NAME")"
|
||||
done
|
||||
else
|
||||
echo "onboard: user name is required (use --name \"...\", or run from a terminal)" >&2
|
||||
exit 4
|
||||
fi
|
||||
fi
|
||||
NAME="$(trim "$NAME")"
|
||||
valid_value "$NAME" || { echo "onboard: invalid name" >&2; exit 2; }
|
||||
|
||||
# Optional fields: prompt interactively when not supplied by flag
|
||||
ask_optional() { # varname label current
|
||||
local __label="$2" __val=""
|
||||
if [ -z "$(trim "$3")" ] && [ "$INTERACTIVE" = "yes" ]; then
|
||||
read -r -p "$__label (optional, Enter to skip): " __val || __val=""
|
||||
__val="$(trim "$__val")"
|
||||
if valid_value "$__val"; then printf '%s' "$__val"; return; fi
|
||||
printf '%s' "$3"
|
||||
else
|
||||
printf '%s' "$3"
|
||||
fi
|
||||
}
|
||||
PROFESSION="$(ask_optional PROFESSION "Profession" "$PROFESSION")"
|
||||
MARITAL_STATUS="$(ask_optional MARITAL_STATUS "Marital status" "$MARITAL_STATUS")"
|
||||
AGE="$(ask_optional AGE "Age" "$AGE")"
|
||||
GENDER="$(ask_optional GENDER "Gender" "$GENDER")"
|
||||
EDUCATION="$(ask_optional EDUCATION "Education level" "$EDUCATION")"
|
||||
LOCATION="$(ask_optional LOCATION "Location" "$LOCATION")"
|
||||
TIMEZONE="$(ask_optional TIMEZONE "Timezone" "$TIMEZONE")"
|
||||
SKILLSET="$(ask_optional SKILLSET "Skillset" "$SKILLSET")"
|
||||
INTERESTS="$(ask_optional INTERESTS "Interests" "$INTERESTS")"
|
||||
HOBBIES="$(ask_optional HOBBIES "Hobbies" "$HOBBIES")"
|
||||
PETS="$(ask_optional PETS "Pets" "$PETS")"
|
||||
|
||||
for pair in "profession:$PROFESSION" "marital:$MARITAL_STATUS" "age:$AGE" "gender:$GENDER" "education:$EDUCATION" "location:$LOCATION" "timezone:$TIMEZONE" "skillset:$SKILLSET" "interests:$INTERESTS" "hobbies:$HOBBIES" "pets:$PETS"; do
|
||||
val="${pair#*:}"
|
||||
[ -n "$(trim "$val")" ] || continue # empty optional fields are valid
|
||||
valid_value "$(trim "$val")" || { echo "onboard: invalid value for ${pair%%:*}" >&2; exit 2; }
|
||||
done
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
# Preserve the previous profile before replacing it (onboard re-runs are
|
||||
# deliberate, but the old copy costs nothing to keep).
|
||||
[ -f "$OUT" ] && cp -f "$OUT" "$OUT_DIR/USER.md.bak"
|
||||
|
||||
# Render: template placeholders -> values; unfilled -> "(not provided)".
|
||||
# Node keeps the rendering exact regardless of slashes/& in values.
|
||||
NAME="$NAME" PROFESSION="$PROFESSION" MARITAL_STATUS="$MARITAL_STATUS" AGE="$AGE" \
|
||||
GENDER="$GENDER" EDUCATION="$EDUCATION" LOCATION="$LOCATION" TIMEZONE="$TIMEZONE" \
|
||||
SKILLSET="$SKILLSET" INTERESTS="$INTERESTS" HOBBIES="$HOBBIES" PETS="$PETS" \
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const t = fs.readFileSync(process.argv[1], "utf8");
|
||||
const v = process.env;
|
||||
const out = t.replace(/\{\{(\w+)\}\}/g, (m, k) => {
|
||||
const val = v[k];
|
||||
return val === undefined || val === "" ? "(not provided)" : val;
|
||||
});
|
||||
fs.writeFileSync(process.argv[2], out);
|
||||
' "$TEMPLATE" "$OUT.new"
|
||||
|
||||
mv -f "$OUT.new" "$OUT"
|
||||
echo "onboard: user profile written: $OUT"
|
||||
echo "onboard: dispatched to every agent launch; edit freely or re-run onboard"
|
||||
+23
-2
@@ -49,7 +49,9 @@ health_check() { # returns 0 only when the marker path passes; $1 = fault inject
|
||||
task="$tmp/fault-task.json"
|
||||
fi
|
||||
local rc=0
|
||||
scripts/run-task.sh run "$task" >/dev/null 2>&1 || rc=$?
|
||||
# MOSAIC_ENSURE_SKIP: the gate's run must not re-enter release
|
||||
# self-determination (recursion guard).
|
||||
MOSAIC_ENSURE_SKIP=1 scripts/run-task.sh run "$task" >/dev/null 2>&1 || rc=$?
|
||||
[ -n "$tmp" ] && rm -rf "$tmp"
|
||||
return "$rc"
|
||||
}
|
||||
@@ -140,10 +142,29 @@ cmd_rollback() {
|
||||
activate "$prev_release" "$prev" "rollback"
|
||||
}
|
||||
|
||||
cmd_ensure() {
|
||||
# M16 release self-determination: the system determines what is installed
|
||||
# and aligns itself. Aligned (pointer == RELEASE, image present) -> no-op.
|
||||
# Drifted -> package if needed, then health-gated activate.
|
||||
local pointer="$MOSAIC_DEV_DIR/state/active.json"
|
||||
local active=""
|
||||
if [ -f "$pointer" ]; then
|
||||
active="$(node -e 'try{const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(p.release||"")}catch{}' "$pointer")"
|
||||
fi
|
||||
if [ "$active" = "$MOSAIC_RELEASE" ] && image_exists "$MOSAIC_IMAGE_TAG"; then
|
||||
echo "release: aligned at $MOSAIC_RELEASE ($MOSAIC_IMAGE_TAG)"
|
||||
return 0
|
||||
fi
|
||||
echo "release: drift (active: ${active:-none}, desired: $MOSAIC_RELEASE) - aligning"
|
||||
image_exists "$MOSAIC_IMAGE_TAG" || cmd_package
|
||||
activate "$MOSAIC_RELEASE" "$MOSAIC_IMAGE_TAG" "activate"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
package) cmd_package ;;
|
||||
activate) shift; cmd_activate "$@" ;;
|
||||
rollback) cmd_rollback ;;
|
||||
status) cmd_status ;;
|
||||
*) echo "usage: scripts/release.sh package | activate [--fault-injection] | rollback | status" >&2; exit 4 ;;
|
||||
ensure) shift; cmd_ensure "$@" ;;
|
||||
*) echo "usage: scripts/release.sh package | activate [--fault-injection] | rollback | status | ensure" >&2; exit 4 ;;
|
||||
esac
|
||||
|
||||
@@ -14,4 +14,15 @@ load_config
|
||||
load_release # compose requires MOSAIC_IMAGE_TAG; task runs are release-scoped too
|
||||
bootstrap_runtime_dir
|
||||
|
||||
# Workers never auto-align releases (no builds or model gates mid-automation);
|
||||
# drift is reported so the operator or a human-facing launcher can align.
|
||||
if [ -f "$MOSAIC_DEV_DIR/state/active.json" ]; then
|
||||
ACTIVE_REL="$(node -e 'try{const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(p.release||"")}catch{}' "$MOSAIC_DEV_DIR/state/active.json")"
|
||||
[ "$ACTIVE_REL" = "$MOSAIC_RELEASE" ] || echo "run-task: note: release drift (active: ${ACTIVE_REL:-none}, desired: $MOSAIC_RELEASE) - run scripts/release.sh ensure to align" >&2
|
||||
fi
|
||||
|
||||
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
|
||||
echo "run-task: note: user profile not onboarded - continuing without user context (scripts/onboard.sh)" >&2
|
||||
fi
|
||||
|
||||
exec node scripts/mosaic-task.mjs "$@"
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Skill lifecycle: install / activate / deactivate / uninstall / list.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/skill.sh install <source-dir | bundled-name>
|
||||
# scripts/skill.sh activate <name>
|
||||
# scripts/skill.sh deactivate <name>
|
||||
# scripts/skill.sh uninstall <name>
|
||||
# scripts/skill.sh list
|
||||
#
|
||||
# Layout (machine-scoped, under the data root):
|
||||
# <dataRoot>/skills-available/<name> installed, not loadable
|
||||
# <dataRoot>/skills-enabled/<name> loadable by agent launches
|
||||
#
|
||||
# A skill not in skills-enabled is not enabled or available for use.
|
||||
# Lifecycle: install -> available; activate -> enabled; deactivate ->
|
||||
# available; uninstall -> removed (only from available).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
# shellcheck source=common.sh
|
||||
source scripts/common.sh
|
||||
|
||||
load_config
|
||||
load_release
|
||||
bootstrap_runtime_dir
|
||||
|
||||
ENABLED="$MOSAIC_DEV_DIR/skills-enabled"
|
||||
AVAILABLE="$MOSAIC_DEV_DIR/skills-available"
|
||||
mkdir -p "$ENABLED" "$AVAILABLE"
|
||||
|
||||
die() { local code="$1"; shift; echo "skill: $*" >&2; exit "$code"; }
|
||||
|
||||
is_skill_dir() { [ -f "$1/SKILL.md" ]; }
|
||||
|
||||
resolve_source() { # bundled name (skills/<name> in repo) or explicit path
|
||||
if [ -d "skills/$1" ]; then printf 'skills/%s' "$1"; return; fi
|
||||
if [ -d "$1" ]; then printf '%s' "$1"; return; fi
|
||||
die 4 "source skill dir not found: $1"
|
||||
}
|
||||
|
||||
frontmatter_name() { # extract name: from SKILL.md frontmatter
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const t = fs.readFileSync(process.argv[1], "utf8");
|
||||
const m = t.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!m) process.exit(1);
|
||||
const name = (m[1].match(/^name:\s*(.+)$/m) || [])[1];
|
||||
if (!name) process.exit(1);
|
||||
process.stdout.write(name.trim());
|
||||
' "$1/SKILL.md"
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
install)
|
||||
src="${2:?usage: skill.sh install <source-dir|bundled-name>}"
|
||||
resolve_source "$src" >/dev/null || die 4 "source skill dir not found: $src"
|
||||
SRC_DIR="$(resolve_source "$src")"
|
||||
is_skill_dir "$SRC_DIR" || die 4 "not a skill (missing SKILL.md): $SRC_DIR"
|
||||
NAME="$(basename "$SRC_DIR")"
|
||||
[ -d "$AVAILABLE/$NAME" ] && die 1 "already installed: $NAME"
|
||||
mkdir -p "$AVAILABLE"
|
||||
cp -r "$SRC_DIR" "$AVAILABLE/$NAME"
|
||||
echo "skill: installed $NAME -> skills-available"
|
||||
echo "skill: activate with scripts/skill.sh activate $NAME"
|
||||
;;
|
||||
activate)
|
||||
name="${2:?usage: skill.sh activate <name>}"
|
||||
[ -d "$AVAILABLE/$name" ] || die 4 "not installed: $name"
|
||||
[ -d "$ENABLED/$name" ] && die 1 "already enabled: $name"
|
||||
mv "$AVAILABLE/$name" "$ENABLED/$name"
|
||||
echo "skill: enabled $name"
|
||||
;;
|
||||
deactivate)
|
||||
name="${2:?usage: skill.sh deactivate <name>}"
|
||||
[ -d "$ENABLED/$name" ] || die 4 "not enabled: $name"
|
||||
mv "$ENABLED/$name" "$AVAILABLE/$name"
|
||||
echo "skill: deactivated $name -> skills-available"
|
||||
;;
|
||||
uninstall)
|
||||
name="${2:?usage: skill.sh uninstall <name>}"
|
||||
if [ -d "$ENABLED/$name" ]; then
|
||||
die 1 "refusing: $name is enabled - deactivate first"
|
||||
fi
|
||||
[ -d "$AVAILABLE/$name" ] || die 4 "not installed: $name"
|
||||
rm -rf "$AVAILABLE/$name"
|
||||
echo "skill: uninstalled $name"
|
||||
;;
|
||||
list)
|
||||
echo "enabled:"
|
||||
for d in "$ENABLED"/*/; do
|
||||
[ -d "$d" ] && echo " $(basename "$d")"
|
||||
done
|
||||
echo "available:"
|
||||
for d in "$AVAILABLE"/*/; do
|
||||
[ -d "$d" ] && echo " $(basename "$d")"
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "usage: scripts/skill.sh install <src> | activate <name> | deactivate <name> | uninstall <name> | list" >&2
|
||||
exit 4
|
||||
;;
|
||||
esac
|
||||
+91
-16
@@ -191,22 +191,50 @@ EOF
|
||||
expect_exit "retry of missing run exits 4" 4 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" node scripts/mosaic-task.mjs retry r-missing
|
||||
|
||||
# session fork plumbing (M11): fork source + target dir delivered
|
||||
printf '{"taskVersion":1,"id":"t-forkplumb","prompt":"x","session":"fork-child","sessionForkFrom":"base"}' > "$SANDBOX/forkplumb.json"
|
||||
mkdir -p "$SANDBOX/data/sessions/base"
|
||||
printf '{}' > "$SANDBOX/data/sessions/base/20260903T000000-plumb.jsonl"
|
||||
expect_exit "fork task runs via mock" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \
|
||||
scripts/run-task.sh run "$SANDBOX/forkplumb.json"
|
||||
FL="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)"
|
||||
grep -q '^MOSAIC_SESSION_FORK=/var/lib/mosaic/sessions/base/20260903T000000-plumb.jsonl$' "$FL/stderr.txt" 2>/dev/null \
|
||||
&& grep -q '^MOSAIC_SESSION_DIR=/var/lib/mosaic/sessions/fork-child$' "$FL/stderr.txt" 2>/dev/null \
|
||||
&& check "fork source + target delivered to adapter" 0 \
|
||||
|| check "fork source + target delivered to adapter" 1
|
||||
printf '{"taskVersion":1,"id":"t-f2","prompt":"x","sessionForkFrom":"base"}' > "$SANDBOX/noforktarget.json"
|
||||
expect_exit "fork without session target exits 2" 2 -- $TASK validate "$SANDBOX/noforktarget.json"
|
||||
printf '{"taskVersion":1,"id":"t-f3","prompt":"x","session":"base","sessionForkFrom":"base"}' > "$SANDBOX/selfork.json"
|
||||
expect_exit "self-fork exits 2" 2 -- $TASK validate "$SANDBOX/selfork.json"
|
||||
# skills lifecycle (M17)
|
||||
expect_exit "skill install bundled ms-tools" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh install ms-tools
|
||||
[ -d "$SANDBOX/data/skills-available/ms-tools" ] \
|
||||
&& check "installed to skills-available" 0 || check "installed to skills-available" 1
|
||||
expect_exit "double install refuses" 1 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh install ms-tools
|
||||
expect_exit "activate enables skill" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh activate ms-tools
|
||||
[ -d "$SANDBOX/data/skills-enabled/ms-tools" ] \
|
||||
&& check "enabled dir populated" 0 || check "enabled dir populated" 1
|
||||
expect_exit "uninstall while enabled refuses" 1 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh uninstall ms-tools
|
||||
expect_exit "deactivate moves back to available" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh deactivate ms-tools
|
||||
expect_exit "uninstall removes from available" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh uninstall ms-tools
|
||||
|
||||
# seat skills dispatch (mock evidence)
|
||||
mkdir -p "$SANDBOX/agents/skillseat" "$SANDBOX/data/user"
|
||||
printf '# User\n\nname: Jason\n' > "$SANDBOX/data/user/USER.md"
|
||||
printf '# SOUL - skillseat\n\nMethodical. Verifies before claiming.\n' > "$SANDBOX/agents/skillseat/SOUL.md"
|
||||
printf '{"agentVersion":1,"name":"skillseat","skills":["ms-tools"]}' > "$SANDBOX/agents/skillseat/agent.json"
|
||||
printf '# SOUL - skillseat\n\nMethodical. Verifies before claiming.\n' > "$SANDBOX/agents/skillseat/SOUL.md"
|
||||
cat > "$SANDBOX/mock-config.json" <<EOF
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}
|
||||
EOF
|
||||
REL="$(tr -d '[:space:]' < RELEASE)"
|
||||
mkdir -p "$SANDBOX/data/state"
|
||||
printf '{"pointerVersion":1,"release":"%s","imageTag":"mosaic-poc-agent:0.84.4-r%s","activatedAt":"2026-01-01T00:00:00Z"}\n' "$REL" "$REL" > "$SANDBOX/data/state/active.json"
|
||||
scripts/skill.sh install ms-tools >/dev/null 2>&1
|
||||
scripts/skill.sh activate ms-tools >/dev/null 2>&1
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-config.json" MOSAIC_MOCK_RESPONSE=MOCKED \
|
||||
env MOSAIC_AGENTS_DIR="$SANDBOX/agents" \
|
||||
scripts/agent.sh skillseat </dev/null >"$SANDBOX/seat-stdout.txt" 2>"$SANDBOX/seat-stderr.txt"
|
||||
RC=$?
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} seat with enabled skill launches"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} seat with enabled skill launches (exit $RC)" >&2
|
||||
echo "SEATCASE stderr:" >&2; cat "$SANDBOX/seat-stderr.txt" >&2
|
||||
fi
|
||||
grep -q '^MOSAIC_SKILLS=/var/lib/mosaic/skills-enabled/ms-tools$' "$SANDBOX/seat-stderr.txt" 2>/dev/null \
|
||||
&& check "skill path delivered to adapter" 0 || check "skill path delivered to adapter" 1
|
||||
|
||||
# capability policy (M9): least-privilege intersection
|
||||
POL="$SANDBOX/data/workspaces"; mkdir -p "$POL"
|
||||
@@ -236,6 +264,18 @@ EOF
|
||||
printf '{"missionVersion":1,"id":"m-pol","objective":"o","capabilities":{"tools":["sudo"]}}' > "$SANDBOX/pol-m.json"
|
||||
expect_exit "invalid mission capabilities rejected" 2 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" $TASK validate "$SANDBOX/pol-t.json"
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" $TASK validate "$SANDBOX/pol-t.json"
|
||||
|
||||
# live user context (M14): dispatched to every launch without rebuild
|
||||
mkdir -p "$SANDBOX/data/user"
|
||||
printf '\nUSER-CANON-MARKER\n' >> "$SANDBOX/data/user/USER.md"
|
||||
expect_exit "task run with user layer present" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
|
||||
scripts/run-task.sh run "$SANDBOX/ok.json"
|
||||
grep -q 'USER CONTEXT: USER.md' "$SANDBOX/data/system-prompt.md" \
|
||||
&& grep -q 'USER-CANON-MARKER' "$SANDBOX/data/system-prompt.md" \
|
||||
&& check "user context dispatched into generated prompt" 0 \
|
||||
|| check "user context dispatched into generated prompt" 1
|
||||
else
|
||||
echo "skip adapter seam cases (docker daemon unavailable)"
|
||||
fi
|
||||
@@ -341,6 +381,41 @@ else
|
||||
echo "skip live task cases (docker unavailable)"
|
||||
fi
|
||||
|
||||
# ---------- onboarding (M16): deterministic, no Docker ----------
|
||||
ONB="$SANDBOX/data"
|
||||
cat > "$SANDBOX/onb-config.json" <<EOF
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$ONB","execution":{"backend":"docker","provider":"zai","model":"m"}}
|
||||
EOF
|
||||
|
||||
expect_exit "onboard without name exits 4 (non-interactive)" 4 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/onb-config.json" scripts/onboard.sh --profession x
|
||||
expect_exit "onboard --name renders profile" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/onb-config.json" scripts/onboard.sh --name "Jason" --timezone "Europe/Berlin"
|
||||
[ -f "$ONB/user/USER.md" ] \
|
||||
&& check "profile written" 0 || check "profile written" 1
|
||||
grep -q "name: Jason" "$ONB/user/USER.md" \
|
||||
&& grep -q "(not provided)" "$ONB/user/USER.md" \
|
||||
&& check "canon structure: required filled, optional placeholdered" 0 \
|
||||
|| check "canon structure: required filled, optional placeholdered" 1
|
||||
grep -q "## Skillset" "$ONB/user/USER.md" && grep -q "## Pets" "$ONB/user/USER.md" \
|
||||
&& check "canon sections present" 0 || check "canon sections present" 1
|
||||
|
||||
# Live recall: real pi, real phrasing - assert containment, not exactness
|
||||
printf '{"taskVersion":1,"id":"t-user","prompt":"What is the user name? Reply with only the name.","session":"onb-check","timeoutSeconds":180}' > "$SANDBOX/recall-task.json"
|
||||
RC=0
|
||||
scripts/run-task.sh run "$SANDBOX/recall-task.json" >/dev/null 2>&1 || RC=$?
|
||||
[ "$RC" -eq 0 ] && check "user recall run succeeds" 0 || check "user recall run succeeds (exit $RC)" 1
|
||||
FR="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)"
|
||||
RESP="$(node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.response||"")}catch{console.log("")}' "$FR/result.json" 2>/dev/null)"
|
||||
echo "$RESP" | grep -qi "jason" \
|
||||
&& check "recalled user name (response: $RESP)" 0 || check "recalled user name (response: $RESP)" 1
|
||||
# headless run must NOT carry an agent identity section
|
||||
! grep -q 'AGENT IDENTITY' "$SANDBOX/data/system-prompt.md" 2>/dev/null \
|
||||
&& check "no agent identity on headless run" 0 || check "no agent identity on headless run" 1
|
||||
|
||||
git -C "$SANDBOX/repo" rev-parse >/dev/null 2>&1 || true
|
||||
unset MOSAIC_CONFIG
|
||||
|
||||
echo
|
||||
echo "selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
|
||||
@@ -30,6 +30,7 @@ IMAGE="$MOSAIC_IMAGE_TAG"
|
||||
# Ensure the configured data root exists (host-owned) before the mount,
|
||||
# otherwise Docker would auto-create a root-owned directory.
|
||||
bootstrap_runtime_dir
|
||||
ensure_release_aligned
|
||||
|
||||
# 1. Build the image only if it is not already present.
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: ms-agent-watch
|
||||
description: Use for all instances where a watch, wait, or agentic status check is needed. This skill avoid the need for wait cycles and other methods used to wait for an action outcome.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-agent-watch — self-armed condition watches
|
||||
|
||||
One CLI call arms an isolated `systemd --user` transient timer per watch. Each
|
||||
tick is a fresh, cgroup-isolated process; no loop, no script, no orphan. You
|
||||
never `sleep`, never write a watch script, never background anything.
|
||||
|
||||
## Tool
|
||||
|
||||
```bash
|
||||
tools/agent-watch/agent-watch.sh start \
|
||||
--name <lowercase-id> --session <your-tmux-session> \
|
||||
[--socket <tmux-socket>] # REQUIRED for mosaic-fleet seats using tmux
|
||||
--when '<shell command>' # exit 0 = met; quote it
|
||||
--message "text delivered to you" \
|
||||
[--class actionable|reaction|human|digest|terminal-log] \
|
||||
[--interval 30] [--timeout 3600] [--repeat] [--quiet-timeout]
|
||||
|
||||
agent-watch.sh list # your host's watches
|
||||
agent-watch.sh log <name> # what it did and why
|
||||
agent-watch.sh stop <name> # retire + clean state
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Name it for the thing watched** (`ci-pr1350-green`, `peer-orch-01-reply`),
|
||||
not for yourself — names appear in `[watch:<name>]` message prefixes and
|
||||
in `systemctl list-timers 'agent-watch-*'`.
|
||||
2. **Interval floor is 10s.** A watcher is a fallback cadence. If you find
|
||||
yourself wanting 1s polls, the real fix is an event, not a faster watch.
|
||||
3. **Timeout is real** (default 1h): on expiry you get one terminal-log note
|
||||
(unless `--quiet-timeout`) and the watch retires. A watch is never forever;
|
||||
re-arm deliberately if the condition is still pending.
|
||||
4. **Conditions are cron-style**: clean-ish environment, `cwd=$HOME`. Use
|
||||
absolute paths. Do NOT rely on ambient credentials — resolve tokens through
|
||||
the git credential helper or absolute service-credential paths.
|
||||
5. **rc=2 delivery is DELIVERED** (text reached your pane as a draft — FLEET-COMMS
|
||||
E7); the watcher never retries it. Real failures retry twice then retire loudly.
|
||||
6. **A broken condition (exit ≠ 0/1) retires the watch** with a terminal-log
|
||||
note. Check `log <name>` before re-arming — re-arming a broken condition
|
||||
without fixing it just burns another timeout.
|
||||
7. **`--repeat` re-arms after every delivery.** Default is one-shot on purpose:
|
||||
each watch should correspond to one pending fact.
|
||||
8. **Retire your watches** when the mission closes (`stop`). `list` shows
|
||||
stale-state entries whose timer is gone; stop removes those too.
|
||||
9. Fleet seats MUST pass `--socket mosaic-fleet` — the default is the default
|
||||
socket and the delivery will not reach you.
|
||||
|
||||
## When NOT to watch
|
||||
|
||||
- Waiting on another SEAT: send them a message (agent-send / comms) instead —
|
||||
a watch polling their output is a loop wearing a hat.
|
||||
- Waiting on yourself: just do the next thing.
|
||||
- Something that already has a wake path (fleet watcher injects comms
|
||||
automatically): those arrive without any watch.
|
||||
|
||||
## Failure model (what you'll see)
|
||||
|
||||
| Symptom | Meaning |
|
||||
|---|---|
|
||||
| `started watcher ...` then nothing, timer inactive | condition broke (exit ≠ 0/1) or timeout hit — `log <name>` says which |
|
||||
| `[watch:x] ... timeout after Ns` | retired; re-arm only if still relevant |
|
||||
| delivered (rc=2) in log | delivered as draft into your pane — go read the pane |
|
||||
| delivery failed rc=1 after 3 attempts | your session was gone; restart it, then re-arm |
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: ms-agent
|
||||
description: Use for ALL agent operation cycles.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-agent
|
||||
|
||||
Agent operation cycles: launch, work, verify, persist, hand off.
|
||||
|
||||
## Cycle
|
||||
|
||||
1. Launch: `scripts/agent.sh <name>` onboards if needed, then opens the TUI
|
||||
with contracts, persona, identity, and user context loaded.
|
||||
2. Work inside your workspace. Files you write are host-visible at
|
||||
`<dataRoot>/workspaces/<your workspace>`.
|
||||
3. Your session persists in `sessions/<name>`. Relaunching the same seat
|
||||
resumes where you left off.
|
||||
4. Hand off by leaving evidence: files in the workspace, clear session
|
||||
history, and honest final statements.
|
||||
|
||||
## Rules
|
||||
|
||||
- One seat, one identity. Never share sessions or workspaces across seats.
|
||||
- Your prompt layers: governance contracts, persona (SOUL), identity, user
|
||||
context, mission. Later layers refine earlier ones. Governance is never
|
||||
overridden.
|
||||
- If a capability you need is missing, say so. Do not improvise a capability
|
||||
you were not granted.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: ms-conductor
|
||||
description: Use this skill when performing Conducting tasks.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-conductor
|
||||
|
||||
Conducting discipline: direct workers without being one.
|
||||
|
||||
## Order of operations
|
||||
|
||||
1. Decompose the goal into worker tasks small enough to spec completely in
|
||||
one prompt: goal, files, constraints, acceptance, self-checks.
|
||||
2. Dispatch through the task runner. Never raw pi; never a shell one-liner.
|
||||
3. Extract the worker's diff. Review it line by line before integration.
|
||||
4. Verify with the suites. A failure reverts; the refusal is recorded.
|
||||
5. Integrate with attribution. Update the plan and registry.
|
||||
|
||||
## Gotcha ledger
|
||||
|
||||
- Sequential dependent calls. Verify a write before claiming it done.
|
||||
- Pre-check every path before passing it to a tool. Missing paths fail
|
||||
silently in some consumers.
|
||||
- Auth and symlink ensure before TUI launch. Missing auth falls back to
|
||||
defaults silently.
|
||||
- Post-reset: the release pointer is gone and onboarding reruns. Both are
|
||||
expected; align releases with `release.sh ensure`.
|
||||
- A worker that passes for the wrong reason is a masking failure. Assert
|
||||
reasons, not just exit codes.
|
||||
|
||||
## Refusals
|
||||
|
||||
Refuse rather than guess. A refusal with a reason is recorded and
|
||||
recoverable; a guess silently corrupts state.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: ms-file-read
|
||||
description: Use this skill when reading any file.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-file-read
|
||||
|
||||
Read files before you act on them. Never act on a filename alone.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Read the file before editing, summarizing, or deciding anything about it.
|
||||
2. Large files: read in chunks with offset and limit instead of dumping.
|
||||
3. Verify what you read matches what you expected before building on it.
|
||||
4. Never read credential material (auth files, tokens, keys). The name is
|
||||
warning enough.
|
||||
|
||||
## When
|
||||
|
||||
- Before any edit: the edit must match what is actually on disk.
|
||||
- Before answering questions about file contents.
|
||||
- When a run record, log, or receipt is cited as evidence.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: ms-file-write
|
||||
description: Use this skill when writing any file.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-file-write
|
||||
|
||||
Write files so the write is provable and reversible.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Write to a temp file and rename for atomic replacement of an existing file.
|
||||
2. Verify the write: re-read or checksum before claiming success.
|
||||
3. Never overwrite a file that carries identity or history (seat records,
|
||||
run records, logs). They are append-only or write-once for a reason.
|
||||
4. No secrets in written files. Ever.
|
||||
5. Match the file's existing style. Do not reformat regions you did not touch.
|
||||
@@ -1,14 +1,46 @@
|
||||
---
|
||||
name: ms-tools
|
||||
description: Contains a reference to all available tools for Mosaic Stack.
|
||||
disable-model-invocation: true
|
||||
description: Reference for all available tools in Mosaic Stack.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-tools
|
||||
|
||||
You are a Mosaic fleet agent. A maintained toolkit lives at `<dir>`.
|
||||
Use it FIRST for the tasks below — improvising with raw CLIs causes the recurring failures this
|
||||
skill exists to prevent. This is the high-frequency fast path; the full reference is the
|
||||
`# Machine Tools` section already in your system prompt.
|
||||
You are a Mosaic seat agent. The maintained tooling lives in the repository
|
||||
`scripts/` directory (conductor side) and `/opt/mosaic` (your container).
|
||||
Use it FIRST for the tasks below. Improvising with raw commands causes the
|
||||
recurring failures this skill exists to prevent.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- scripts/bootstrap.sh creates the system config. Idempotent, never overwrites.
|
||||
- scripts/build.sh builds the release image. Tag comes from RELEASE.
|
||||
- scripts/verify.sh runs the gated startup check. Exit 0 means MOSAIC_HELLO_OK.
|
||||
- scripts/release.sh ensure aligns what is installed with RELEASE. No manual
|
||||
release commands; drift is detected and corrected at launch.
|
||||
|
||||
## Tasks and runs
|
||||
|
||||
- scripts/run-task.sh run <task.json> executes a governed task. Every run
|
||||
leaves a write-once record under <dataRoot>/runs/.
|
||||
- node scripts/mosaic-task.mjs list shows run history.
|
||||
- node scripts/mosaic-task.mjs show <runId> inspects one run: receipts,
|
||||
snapshots, stderr.
|
||||
- node scripts/mosaic-task.mjs retry <runId> re-executes a recorded task as
|
||||
a new run. The old record stays.
|
||||
|
||||
## Inter-agent communications
|
||||
|
||||
- tools/tmux/agent-send.sh # Send a tmux message to an agent launched in tmux
|
||||
|
||||
## Seats
|
||||
|
||||
- scripts/agent.sh <name> launches an interactive seat. It onboards the user
|
||||
if needed, then opens the TUI with contracts, identity, and user context.
|
||||
- agents/<name>/ holds a seat definition: agent.json plus SOUL.md.
|
||||
|
||||
## Rules
|
||||
|
||||
- Exit codes: 0 ok, 1 failed, 2 invalid input, 3 config missing, 4 usage.
|
||||
- A refusal is evidence. Diagnose it; do not route around it.
|
||||
- Full reference: docs/TOOLS.md.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: ms-user
|
||||
description: Always use to update recorded information about the user
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-user
|
||||
|
||||
The user context layer holds user-owned information dispatched to every
|
||||
agent launch: everything under `<dataRoot>/user/`, with `USER.md` as the
|
||||
profile.
|
||||
|
||||
## Rules
|
||||
|
||||
1. The user owns the content. It is injected into your system prompt when
|
||||
present; verify it on disk before editing.
|
||||
2. Always check for the `<dataRoot>/user/USER.md` file before attempting
|
||||
to update info.
|
||||
3. If the USER.md file is missing, guide the user through onboarding
|
||||
(`scripts/onboard.sh`) or, with their consent, create a minimal
|
||||
template (name only) and let them fill in the rest.
|
||||
4. Initial configuration uses `scripts/onboard.sh`
|
||||
(guided, name required) or the user's own edits.
|
||||
Agents propose; the user decides and authorizes; once authorized,
|
||||
the agent performs the edit.
|
||||
5. NEVER place secrets in the user layer. Everything under
|
||||
`<dataRoot>/user/` is dispatched to every agent and worker, so keep
|
||||
near-secrets out too: home address, finances, anything you would not
|
||||
hand a stranger with shell access.
|
||||
6. Propose every change to the user layer, new content or new section
|
||||
alike, and apply it only after an explicit yes.
|
||||
7. Propose updates proactively as information about the user is learned.
|
||||
8. The USER.md file is not limited to the provided template fields.
|
||||
Add sections as needed.
|
||||
9. Never block work on proposed updates to the user information files.
|
||||
Note the proposed additions in a scratch file outside
|
||||
`<dataRoot>/user/` (unconfirmed content must not be dispatched),
|
||||
then confirm with the user at a better time.
|
||||
+39
-4
@@ -1,14 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Load the four immutable contract files in fixed order and write the
|
||||
# generated system prompt to /var/lib/mosaic/system-prompt.md.
|
||||
# Load agent context and write the generated system prompt to
|
||||
# /var/lib/mosaic/system-prompt.md.
|
||||
#
|
||||
# Order is normative: CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md.
|
||||
# Layers, in normative order:
|
||||
# 1. Immutable contracts (image): CONSTITUTION, STANDARDS, SOUL
|
||||
# 2. Agent identity (when the launcher names the agent)
|
||||
# 3. Mission (when the task/launcher provides one)
|
||||
# 4. Live user context (M14): <dataRoot>/user/*.md - user-owned,
|
||||
# dispatched to every launch without rebuilds
|
||||
set -eu
|
||||
|
||||
CONTRACT_DIR="${1:-/opt/mosaic/contracts}"
|
||||
OUT="${2:-/var/lib/mosaic/system-prompt.md}"
|
||||
|
||||
FILES="CONSTITUTION.md STANDARDS.md SOUL.md USER.md"
|
||||
FILES="CONSTITUTION.md STANDARDS.md"
|
||||
|
||||
# SOUL slot (M15): the contract SOUL.md is the DEFAULT persona; a launched
|
||||
# agent seat overrides it with its own runtime SOUL (governance contracts
|
||||
# are never overridden).
|
||||
SOUL_SRC="$CONTRACT_DIR/SOUL.md"
|
||||
SOUL_HEADER="SOUL.md"
|
||||
if [ -n "${MOSAIC_AGENT_SOUL_FILE:-}" ]; then
|
||||
if [ ! -r "$MOSAIC_AGENT_SOUL_FILE" ]; then
|
||||
echo "load-contracts: agent SOUL not readable: $MOSAIC_AGENT_SOUL_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
SOUL_SRC="$MOSAIC_AGENT_SOUL_FILE"
|
||||
SOUL_HEADER="SOUL.md (agent seat override)"
|
||||
fi
|
||||
|
||||
if [ ! -d "$CONTRACT_DIR" ]; then
|
||||
echo "load-contracts: contract directory not found: $CONTRACT_DIR" >&2
|
||||
@@ -33,14 +52,30 @@ for f in $FILES; do
|
||||
printf '\n' >> "$TEMP"
|
||||
done
|
||||
|
||||
printf '===== CONTRACT: %s =====\n' "$SOUL_HEADER" >> "$TEMP"
|
||||
cat "$SOUL_SRC" >> "$TEMP"
|
||||
printf '\n' >> "$TEMP"
|
||||
|
||||
# Agent identity (M13): when the launcher names the agent, the generated
|
||||
# prompt states it - SOUL.md provides the persona, this provides the name.
|
||||
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
|
||||
printf '===== AGENT IDENTITY =====\n' >> "$TEMP"
|
||||
printf 'agent name: %s\n' "$MOSAIC_AGENT_NAME" >> "$TEMP"
|
||||
[ -n "${MOSAIC_AGENT_ROLE:-}" ] && printf 'agent role: %s\n' "$MOSAIC_AGENT_ROLE" >> "$TEMP"
|
||||
printf '\n' >> "$TEMP"
|
||||
fi
|
||||
|
||||
# Live user context (M14): every *.md in /var/lib/mosaic/user (sorted) is
|
||||
# appended - the user owns this layer and edits it without rebuilds.
|
||||
USER_DIR="/var/lib/mosaic/user"
|
||||
if [ -d "$USER_DIR" ]; then
|
||||
for f in $(ls "$USER_DIR"/*.md 2>/dev/null | sort); do
|
||||
printf '===== USER CONTEXT: %s =====\n' "$(basename "$f")" >> "$TEMP"
|
||||
cat "$f" >> "$TEMP"
|
||||
printf '\n' >> "$TEMP"
|
||||
done
|
||||
fi
|
||||
|
||||
# Sanctioned mission injection point (M4): when the task runner provides a
|
||||
# mission snapshot, its objective and directives are appended AFTER the
|
||||
# immutable contracts. Runtime data; never part of the contract fixtures.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# User
|
||||
|
||||
name: {{NAME}}
|
||||
profession: {{PROFESSION}}
|
||||
location: {{LOCATION}}
|
||||
timezone: {{TIMEZONE}}
|
||||
|
||||
## Demographics
|
||||
|
||||
marital status: {{MARITAL_STATUS}}
|
||||
age: {{AGE}}
|
||||
gender: {{GENDER}}
|
||||
education: {{EDUCATION}}
|
||||
|
||||
## Skillset
|
||||
|
||||
{{SKILLSET}}
|
||||
|
||||
## Interests
|
||||
|
||||
{{INTERESTS}}
|
||||
|
||||
## Hobbies
|
||||
|
||||
{{HOBBIES}}
|
||||
|
||||
## Pets
|
||||
|
||||
{{PETS}}
|
||||
Executable
+730
@@ -0,0 +1,730 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-watch.sh — isolated condition watcher for fleet agents (v2: systemd timers).
|
||||
#
|
||||
# Each watch is its own transient systemd --user timer + service (cron-style):
|
||||
# no loop process, no orphan risk, no host-reboot fragility (v1's nohup model
|
||||
# died at reboot), cgroup-isolated, journald-logged. Agents never hand-write
|
||||
# watch scripts or sleep loops — a watch is one CLI call.
|
||||
#
|
||||
# Usage:
|
||||
# agent-watch.sh start --name <id> --session <tmux-session> \
|
||||
# --when '<shell command; true = met>' --message "text to send" \
|
||||
# [--class actionable|reaction|human|digest|terminal-log] \
|
||||
# [--interval 30] [--timeout 3600] [--repeat] [--quiet-timeout] \
|
||||
# [--socket <tmux-socket>] # e.g. mosaic-fleet for fleet seats
|
||||
# agent-watch.sh list
|
||||
# agent-watch.sh status [--json] # exit 0 = clean, 3 = any stale watch or dead meta-watch, 6 = user bus unreachable
|
||||
# agent-watch.sh stop <name>
|
||||
# agent-watch.sh log <name>
|
||||
# agent-watch.sh meta-install [--interval 300] [--unit-name agent-watch-meta]
|
||||
# agent-watch.sh meta-remove [--unit-name agent-watch-meta]
|
||||
#
|
||||
# Rules encoded (guides/FLEET-COMMS.md, WAKE-DOCTRINE.md):
|
||||
# - Interval floor 10s: a watcher is a fallback cadence, never a tight poll.
|
||||
# - Delivery via agent-send.sh only; rc=2 = reached the pane as draft =
|
||||
# DELIVERED, never retried. Real failures retry twice, then give up loudly.
|
||||
# - Conditions run CRON-STYLE: clean-ish env (HOME/PATH/MOSAIC_* pass
|
||||
# through), cwd=$HOME. Do not rely on ambient credentials; use absolute
|
||||
# paths and the credential helper.
|
||||
# - Messages carry [watch:<name>] so the recipient can trace or stop them.
|
||||
# - One-shot by default; --repeat re-arms after each delivery.
|
||||
# - Timeout (default 3600s): terminal-log note unless --quiet-timeout,
|
||||
# then the timer is cancelled. A watch is never forever; re-arm deliberately.
|
||||
# - State: $STATE_ROOT/<name>/ (config, log). Units: agent-watch-<name>.{timer,service}
|
||||
# - Expected deaths are marked: completion, timeout, and broken-condition
|
||||
# paths write a `terminated` marker BEFORE stopping the timer, so those
|
||||
# watches show as retired (owed nothing), never as stale.
|
||||
# - Stale = config present, timer gone, no terminated marker: unexpected
|
||||
# loss. list/status deliver AT MOST ONE notice per staleness episode: a
|
||||
# noclobber claim on stale-noticed admits exactly one of N concurrent
|
||||
# callers; a crash between claim and send can drop that episode's notice
|
||||
# (staleness stays visible in status output / exit 3 regardless). The
|
||||
# stale claim clears on re-arm (start) and observed recovery; the
|
||||
# terminated marker clears on re-arm only — activity during a stop
|
||||
# window must not erase it (T16W2 race). D62 CLOSED by the meta-watch
|
||||
# (T19): a persistent, ENABLED systemd user timer runs the hidden
|
||||
# `_scan` path on a fixed cadence — the SAME classification and
|
||||
# claim-first notice logic list/status use — so a lost watch is noticed
|
||||
# without anyone querying. Each scan stamps a heartbeat file; list and
|
||||
# status report meta health (timer active + heartbeat age vs cadence),
|
||||
# so a dead meta-watch is visible on the existing query surface instead
|
||||
# of silently recreating D62 one level up. status is the machine-readable
|
||||
# liveness answer (JSON or human; retired watches are listed separately,
|
||||
# exit 3 when any watch is stale OR an installed meta-watch is dead).
|
||||
# - Ambiguous unpinned socket resolution refuses with rc 4; pass --socket
|
||||
# or set MOSAIC_TMUX_SOCKET to choose deliberately.
|
||||
# - No user bus (XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS unset or bus
|
||||
# unreachable): systemctl --user fails with EMPTY output, which a naive
|
||||
# classifier reads as "timer gone". Every classifying query (list /
|
||||
# status / _scan / start / meta-install) refuses loudly instead — rc 6,
|
||||
# diagnostic naming the bus, zero claims, zero notices (T24).
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="2.1.2"
|
||||
SEND="${MOSAIC_AGENT_SEND:-$HOME/.config/mosaic/tools/tmux/agent-send.sh}"
|
||||
STATE_ROOT="${MOSAIC_WATCH_STATE:-$HOME/.cache/mosaic-agent-watch}"
|
||||
FLOOR_INTERVAL=10
|
||||
CLASSES="actionable|reaction|human|digest|terminal-log"
|
||||
UNIT_PREFIX="agent-watch"
|
||||
META_UNIT_DEFAULT="agent-watch-meta"
|
||||
META_FLOOR_INTERVAL=60 # meta cadence floor: a detection net, never a poll
|
||||
RC_NO_BUS=6 # T24: user bus unreachable — classify nothing, notify nothing
|
||||
|
||||
die() { echo "agent-watch: $*" >&2; exit 2; }
|
||||
usage() { sed -n '2,/^set -euo pipefail/{/^set -euo pipefail/d;p}' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
|
||||
# T24 (measured live 2026-08-23 03:33): a caller without the user bus gets a
|
||||
# FAILING systemctl --user whose empty output the classifier read as "timer
|
||||
# gone" — every live watch classified LOST, false claims written, false
|
||||
# notices delivered (self-healed only via T16W2 alive-release, masking real
|
||||
# losses in the window). An unreachable bus is a broken instrument, not a
|
||||
# fleet of dead watches: systemctl's own failure (rc!=0, EMPTY stdout) is
|
||||
# distinguishable from an empty-but-successful query (rc!=0 with TEXT like
|
||||
# "inactive" on stdout). Every path that classifies probes first and
|
||||
# refuses to classify at all on failure.
|
||||
require_user_bus() {
|
||||
local err rc=0
|
||||
err="$(systemctl --user show-environment 2>&1 1>/dev/null)" || rc=$?
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
{
|
||||
echo "agent-watch: systemd user bus UNREACHABLE — refusing to classify watch liveness (rc $RC_NO_BUS)"
|
||||
echo "agent-watch: systemctl --user show-environment failed rc=$rc: ${err}"
|
||||
echo "agent-watch: no claims written, no notices sent; a failed query is NOT 'no timers'."
|
||||
echo "agent-watch: fix the caller: export XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus"
|
||||
} >&2
|
||||
exit "$RC_NO_BUS"
|
||||
fi
|
||||
}
|
||||
state_dir() { echo "$STATE_ROOT/$1"; }
|
||||
unit_for() { echo "$UNIT_PREFIX-$1"; }
|
||||
|
||||
say_log() { # per-watch durable log (journal also carries it, but `log` reads this)
|
||||
local dir; dir="$(state_dir "$1")"; mkdir -p "$dir"
|
||||
echo "[$(date -u +%FT%TZ)] ${2:-}" >> "$dir/watch.log"
|
||||
}
|
||||
|
||||
# Terminal marker (review B1): every intentional stop path records WHY the
|
||||
# timer is about to vanish, BEFORE the stop. Stale detection (config present +
|
||||
# timer gone) ignores markered watches: expected deaths are owed nothing.
|
||||
# Writing pre-stop closes the race: a detector can never observe timer-gone
|
||||
# with the marker still absent.
|
||||
mark_terminal() { # $1=name $2=reason
|
||||
printf '%s\n' "$2" > "$(state_dir "$1")/terminated"
|
||||
say_log "$1" "terminal: $2"
|
||||
}
|
||||
|
||||
# ── start ─────────────────────────────────────────────────────────────────────
|
||||
cmd_start() {
|
||||
local name="" session="" when="" message="" cls="actionable" socket=""
|
||||
local interval=30 timeout=3600 repeat=0 quiet=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--session) session="$2"; shift 2 ;;
|
||||
--socket) socket="$2"; shift 2 ;;
|
||||
--when) when="$2"; shift 2 ;;
|
||||
--message) message="$2"; shift 2 ;;
|
||||
--class) cls="$2"; shift 2 ;;
|
||||
--interval) interval="$2"; shift 2 ;;
|
||||
--timeout) timeout="$2"; shift 2 ;;
|
||||
--repeat) repeat=1; shift ;;
|
||||
--quiet-timeout) quiet=1; shift ;;
|
||||
*) die "start: unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$name" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "start: --name must be lowercase-hyphens (got: '$name')"
|
||||
[[ "$name" != *.* ]] || die "start: --name must not contain dots (systemd unit naming)"
|
||||
[[ -n "$session" ]] || die "start: --session is required"
|
||||
|
||||
# Pin the target to an EXACT session name. Without the leading `=`, tmux
|
||||
# PREFIX-matches and returns rc=0 on the wrong session. Measured 2026-08-24
|
||||
# on this host: `jarvis` and `jarvis-enhance` are a prefix pair split across
|
||||
# two servers, so on the default server `-t jarvis` resolves to
|
||||
# `jarvis-enhance` at rc=0 today -- no dead seat required. Normalising here
|
||||
# rather than at each use covers the guard below AND all four fire-time
|
||||
# `$SEND -s "$SESSION"` sites, because send-message.sh already expands
|
||||
# `=sess` to a pane-qualified target. Preserve an already-pinned session,
|
||||
# and pin only its session component for compound `session:window.pane`
|
||||
# targets. This keeps discovery from constructing `==session:window.pane`.
|
||||
case "$session" in
|
||||
=*) ;;
|
||||
*:*) session="=${session%%:*}:${session#*:}" ;;
|
||||
*) session="=$session" ;;
|
||||
esac
|
||||
[[ -n "$when" ]] || die "start: --when is required (quoted shell command; exit 0 = met)"
|
||||
[[ -n "$message" ]] || die "start: --message is required"
|
||||
[[ "$cls" =~ ^($CLASSES)$ ]] || die "start: --class must be one of: $CLASSES"
|
||||
[[ "$interval" =~ ^[0-9]+$ ]] || die "start: --interval must be a number"
|
||||
[[ "$interval" -ge "$FLOOR_INTERVAL" ]] || die "start: --interval floor is ${FLOOR_INTERVAL}s (got ${interval}s) — no tight polls"
|
||||
[[ "$timeout" =~ ^[0-9]+$ ]] || die "start: --timeout must be a number (seconds)"
|
||||
[[ -x "$SEND" ]] || die "sender not found/executable: $SEND"
|
||||
command -v systemctl >/dev/null 2>&1 || die "systemctl not on PATH (v2 requires systemd --user)"
|
||||
require_user_bus # T24: fail naming the bus, not as a cryptic systemd-run error
|
||||
# B1 (2026-08-29): socket default resolution. Precedence: explicit
|
||||
# --socket > MOSAIC_TMUX_SOCKET (launcher-exported) > unique socket hit
|
||||
# > refusal on ambiguity. Socket discovery scans tmux's own socket dir,
|
||||
# ${TMUX_TMPDIR:-/tmp}/tmux-UID (codex PR #1466: TMPDIR is wrong here).
|
||||
# Validate an environment-selected socket just like an explicit socket so a
|
||||
# stale launcher pin cannot create a watcher that can never deliver.
|
||||
if [[ -z "$socket" && -n "${MOSAIC_TMUX_SOCKET:-}" ]]; then
|
||||
socket="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [[ -n "$socket" ]]; then
|
||||
tmux -L "$socket" has-session -t "$session" 2>/dev/null || die "no tmux session '$session' on socket '$socket'"
|
||||
else
|
||||
local hits="" sname sf hit_count
|
||||
local socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
tmux -L "$sname" has-session -t "$session" 2>/dev/null && hits="$hits $sname"
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | wc -w)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-watch: session '$session' exists on multiple sockets:$hits — pass --socket explicitly" >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
socket="${hits# }"
|
||||
else
|
||||
die "no tmux session '$session' (default socket)"
|
||||
fi
|
||||
fi
|
||||
|
||||
local unit; unit="$(unit_for "$name")"
|
||||
if systemctl --user is-active "$unit.timer" >/dev/null 2>&1; then
|
||||
die "a watcher named '$name' is already running (unit $unit.timer); stop it first or pick another name"
|
||||
fi
|
||||
|
||||
local dir; dir="$(state_dir "$name")"
|
||||
mkdir -p "$dir"
|
||||
# printf %q, not bare quotes (D43): the config is SOURCED by each tick; a
|
||||
# single quote in message/condition used to close the string early and kill
|
||||
# the watcher silently after start reported success. Measured 2026-08-21.
|
||||
{
|
||||
printf 'NAME=%q\n' "$name"
|
||||
printf 'SESSION=%q\n' "$session"
|
||||
printf 'SOCKET=%q\n' "$socket"
|
||||
printf 'CLASS=%q\n' "$cls"
|
||||
printf 'MESSAGE=%q\n' "$message"
|
||||
printf 'CONDITION=%q\n' "$when"
|
||||
printf 'INTERVAL=%s\n' "$interval"
|
||||
printf 'TIMEOUT=%s\n' "$timeout"
|
||||
printf 'STARTED=%s\n' "$(date +%s)"
|
||||
printf 'REPEAT=%s\n' "$repeat"
|
||||
printf 'QUIET_TIMEOUT=%s\n' "$quiet"
|
||||
} > "$dir/config"
|
||||
# Fail loudly here, not in a detached tick nobody is reading.
|
||||
if ! ( set -e; source "$dir/config" ) 2>/dev/null; then
|
||||
rm -f "$dir/config"
|
||||
die "could not write a sourceable config for '$name'; watcher NOT started"
|
||||
fi
|
||||
# Re-arm clears episode/terminal markers: a fresh staleness episode must be
|
||||
# notifiable again, and a re-armed watch is no longer retired.
|
||||
rm -f "$dir/stale-noticed" "$dir/terminated" 2>/dev/null || true
|
||||
|
||||
# One transient timer per watch = the isolation. Each tick is a fresh
|
||||
# process in its own cgroup: a crash kills that tick only; the journal
|
||||
# carries the history; reboot cancels cleanly (a watch is re-armed by
|
||||
# whoever still wants it — that is deliberate, WAKE-DOCTRINE's "a watcher
|
||||
# is a fallback cadence, not a steady-state mechanism").
|
||||
# --on-active fires the first tick in ~1s; --on-unit-active-sec re-arms
|
||||
# after every tick. systemd composes the two as OR.
|
||||
: > "$dir/watch.log"
|
||||
if ! systemctl --user start "$unit.timer" 2>/dev/null; then
|
||||
# transient timer does not exist yet — create it
|
||||
# ENV PASS-THROUGH (measured 2026-08-23, D62): a tick runs in the unit's
|
||||
# environment, NOT the arming shell's. MOSAIC_WATCH_STATE or
|
||||
# MOSAIC_AGENT_SEND set at arm time but not passed here made _tick resolve
|
||||
# a DIFFERENT state root, miss its config, and fire the poison pill two
|
||||
# seconds after start — the watch silently died while `start` had already
|
||||
# reported success. Any override the arming shell used must travel with
|
||||
# the unit, or start and tick disagree about which watch they serve.
|
||||
local extra_env=()
|
||||
[[ -n "${MOSAIC_WATCH_STATE:-}" ]] && extra_env+=(--setenv=MOSAIC_WATCH_STATE="$MOSAIC_WATCH_STATE")
|
||||
[[ -n "${MOSAIC_AGENT_SEND:-}" ]] && extra_env+=(--setenv=MOSAIC_AGENT_SEND="$MOSAIC_AGENT_SEND")
|
||||
if ! systemd-run --user --collect \
|
||||
--unit="$unit" \
|
||||
--description="agent-watch: $name (-> $session${socket:+ on $socket})" \
|
||||
--on-active=1s \
|
||||
--on-unit-active="${interval}s" \
|
||||
--setenv=HOME="$HOME" \
|
||||
--setenv=PATH="$PATH" \
|
||||
--setenv=MOSAIC_BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" \
|
||||
${extra_env[@]+"${extra_env[@]}"} \
|
||||
bash "$(readlink -f "$0")" _tick "$name" >> "$dir/watch.log" 2>&1; then
|
||||
rm -f "$dir/config"
|
||||
die "systemd-run failed for $unit (see $dir/watch.log); watcher NOT started"
|
||||
fi
|
||||
fi
|
||||
say_log "$name" "watcher started: every ${interval}s, timeout ${timeout}s, -> $session${socket:+ on $socket} ($cls)"
|
||||
echo "started watcher '$name' (unit $unit.timer): every ${interval}s, timeout ${timeout}s, -> ${socket:+$socket/}$session ($cls)"
|
||||
echo "condition: $when"
|
||||
}
|
||||
|
||||
# ── _tick (hidden; run BY the transient service each interval) ────────────────
|
||||
cmd_tick() {
|
||||
local name="$1"
|
||||
local dir; dir="$(state_dir "$name")"
|
||||
local unit; unit="$(unit_for "$name")"
|
||||
# Poison pill (v1.1.0 semantics): no config = stopped/cleaned; cancel timer.
|
||||
if [[ ! -r "$dir/config" ]]; then
|
||||
systemctl --user stop "$unit.timer" "$unit.service" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$dir/config"
|
||||
|
||||
# Timeout: a watch is never forever.
|
||||
if (( $(date +%s) - STARTED >= TIMEOUT )); then
|
||||
if [[ "$QUIET_TIMEOUT" -eq 0 ]]; then
|
||||
"$SEND" ${SOCKET:+-L "$SOCKET"} -s "$SESSION" -C terminal-log \
|
||||
-m "[watch:$NAME] timeout after ${TIMEOUT}s — watcher retired" >/dev/null 2>&1 || true
|
||||
fi
|
||||
say_log "$NAME" "timeout after ${TIMEOUT}s"
|
||||
mark_terminal "$NAME" "timeout after ${TIMEOUT}s"
|
||||
systemctl --user stop "$unit.timer" >/dev/null 2>&1 || true
|
||||
rm -f "$dir/pid" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set +e
|
||||
( cd "$HOME" && bash -c "$CONDITION" ) >/dev/null 2>&1
|
||||
local rc=$?
|
||||
set -e
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
say_log "$NAME" "condition met (rc=0)"
|
||||
local tries=0 drc
|
||||
while :; do
|
||||
set +e
|
||||
"$SEND" ${SOCKET:+-L "$SOCKET"} -s "$SESSION" -C "$CLASS" -m "[watch:$NAME] $MESSAGE"
|
||||
drc=$?
|
||||
set -e
|
||||
case "$drc" in
|
||||
0) say_log "$NAME" "delivered (rc=0)"; break ;;
|
||||
2) say_log "$NAME" "rc=2: reached pane as draft — delivered, NOT retried"; break ;;
|
||||
*) tries=$((tries + 1))
|
||||
if [[ "$tries" -ge 3 ]]; then
|
||||
say_log "$NAME" "delivery failed rc=$drc after 3 attempts — giving up"
|
||||
# NO terminal marker here, deliberately: the recipient got
|
||||
# nothing, so the watch is still owed. It stays stale-detectable
|
||||
# and the LOST notice is truthful (B1 covers only paths that
|
||||
# already told the recipient something).
|
||||
systemctl --user stop "$unit.timer" >/dev/null 2>&1 || true
|
||||
exit 4
|
||||
fi
|
||||
sleep 5 ;;
|
||||
esac
|
||||
done
|
||||
if [[ "$REPEAT" -eq 1 ]]; then
|
||||
say_log "$NAME" "--repeat: re-arming"
|
||||
return 0
|
||||
fi
|
||||
say_log "$NAME" "watcher complete"
|
||||
mark_terminal "$NAME" "complete: condition met, notice delivered"
|
||||
systemctl --user stop "$unit.timer" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
elif [[ "$rc" -ne 1 ]]; then
|
||||
# 1 = ordinary false; anything else = broken condition. Say so, retire.
|
||||
say_log "$NAME" "condition exited rc=$rc (not 0/1) — broken; stopping"
|
||||
"$SEND" ${SOCKET:+-L "$SOCKET"} -s "$SESSION" -C terminal-log \
|
||||
-m "[watch:$NAME] condition broken (rc=$rc), watcher stopped: $CONDITION" >/dev/null 2>&1 || true
|
||||
mark_terminal "$NAME" "condition broken (rc=$rc)"
|
||||
systemctl --user stop "$unit.timer" >/dev/null 2>&1 || true
|
||||
exit 5
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── stale detection (D62) ─────────────────────────────────────────────────
|
||||
# Config present + timer gone + no terminal marker = the watch will never
|
||||
# fire and nobody was told. Claim-first (review B2): the stale-noticed marker
|
||||
# is created atomically (noclobber) BEFORE any send, so exactly one of N
|
||||
# concurrent callers sends and the others return immediately. Crash semantics
|
||||
# are AT-MOST-ONCE per episode: a crash between claim and successful send
|
||||
# drops that episode's notice (the claim survives, preventing a later
|
||||
# duplicate); staleness itself stays visible in list/status output and exit
|
||||
# code 3 regardless. Delivery follows fired-watch semantics: rc=0/2 counts as
|
||||
# delivered and the claim is held; exhausted retries release the claim so the
|
||||
# next detection tries again.
|
||||
notice_stale_once() {
|
||||
local name="$1"
|
||||
local dir; dir="$(state_dir "$name")"
|
||||
[[ -r "$dir/config" ]] || return 0
|
||||
[[ -e "$dir/terminated" ]] && return 0 # expected death: owed nothing
|
||||
if ! ( set -o noclobber; printf '%s\n' "$(date -u +%FT%TZ)" > "$dir/stale-noticed" ) 2>/dev/null; then
|
||||
return 0 # another caller holds the claim for this episode
|
||||
fi
|
||||
say_log "$name" "stale claim acquired (config present, timer gone, not terminal)"
|
||||
# shellcheck disable=SC1090
|
||||
source "$dir/config"
|
||||
# Post-claim re-check: if the timer recovered while we raced for the claim,
|
||||
# the episode ended. Release the claim without sending.
|
||||
# T24: the re-check itself can fail (bus dropped since entry) — an EMPTY
|
||||
# answer from systemctl must not read as "still gone": release the claim
|
||||
# (nothing was verified, nothing sent) and fail loud rather than fabricate
|
||||
# a LOST notice on an unreadable system.
|
||||
local rerc=0 reout
|
||||
reout="$(systemctl --user is-active "$(unit_for "$name").timer" 2>/dev/null)" || rerc=$?
|
||||
if [[ "$rerc" -ne 0 && -z "$reout" ]]; then
|
||||
rm -f "$dir/stale-noticed"
|
||||
say_log "$name" "user bus unreachable at post-claim re-check; claim released, NO notice sent"
|
||||
require_user_bus # prints the diagnostic, exits rc $RC_NO_BUS
|
||||
fi
|
||||
if [[ "$rerc" -eq 0 ]]; then
|
||||
rm -f "$dir/stale-noticed"
|
||||
say_log "$name" "timer recovered after claim; notice suppressed, claim released"
|
||||
return 0
|
||||
fi
|
||||
local tries=0 drc=1
|
||||
while :; do
|
||||
set +e
|
||||
"$SEND" ${SOCKET:+-L "$SOCKET"} -s "$SESSION" -C actionable \
|
||||
-m "[watch:$NAME] LOST: its timer is gone but the watch state remains; it will never fire like this. Re-arm (agent-watch.sh start) or stop it (agent-watch.sh stop $NAME)."
|
||||
drc=$?
|
||||
set -e
|
||||
[[ "$drc" -eq 0 || "$drc" -eq 2 ]] && break
|
||||
tries=$((tries + 1)); [[ "$tries" -ge 3 ]] && break
|
||||
sleep 5
|
||||
done
|
||||
if [[ "$drc" -eq 0 || "$drc" -eq 2 ]]; then
|
||||
say_log "$NAME" "stale notice delivered (rc=$drc); claim held"
|
||||
else
|
||||
rm -f "$dir/stale-noticed" # claim released: nothing was delivered
|
||||
say_log "$NAME" "stale notice delivery FAILED rc=$drc after 3 attempts — claim released, will retry on next detection"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── shared classification (T19) ──────────────────────────────────────────────
|
||||
# One classification source for list/status/_scan. T16W2 semantics preserved
|
||||
# exactly: an observed-alive timer releases the episode claim ONLY — a timer
|
||||
# mid-stop can still report active while mark_terminal's stop is in flight, so
|
||||
# activity must never clear `terminated` (only start/re-arm does).
|
||||
classify_watch() { # $1=name -> echoes alive|retired|stale
|
||||
local dir; dir="$(state_dir "$1")"
|
||||
if systemctl --user is-active "$(unit_for "$1").timer" >/dev/null 2>&1; then
|
||||
rm -f "$dir/stale-noticed" 2>/dev/null || true
|
||||
echo alive
|
||||
elif [[ -e "$dir/terminated" ]]; then
|
||||
echo retired
|
||||
else
|
||||
echo stale
|
||||
fi
|
||||
}
|
||||
|
||||
# ── meta-watch (T19/D62): detection without a query ──────────────────────────
|
||||
# The scan path is nothing new: it is the SAME classification + claim-first
|
||||
# notices list/status run, invoked on a cadence by a persistent, ENABLED
|
||||
# systemd user timer instead of a human/orchestrator query. A tick is just
|
||||
# another concurrent caller of the T16 semantics, so tick-vs-query races still
|
||||
# yield exactly one LOST per episode.
|
||||
#
|
||||
# Why this does not recreate D62 one level up:
|
||||
# - watches are TRANSIENT units: reboot wipes them while their state dirs
|
||||
# still promise delivery (the measured loss mode). The meta-watch is a
|
||||
# persistent, enabled unit — reboot cannot strand it; it returns with
|
||||
# timers.target.
|
||||
# - it holds no per-obligation state. Its only artifact is a heartbeat that
|
||||
# AGES: a dead meta-watch leaves an absence signal, not a silent promise.
|
||||
# - list/status surface meta health (timer active + heartbeat age vs
|
||||
# cadence) on the query surface that already exists, with a JSON field
|
||||
# for machine consumption. Pre-T19 a dead watch was invisible even when
|
||||
# queried; post-T19 a dead meta-watch is visible whenever queried.
|
||||
# - a same-host meta-meta-watch would share fate with the meta (one systemd
|
||||
# user instance) and add nothing. The honest terminal for liveness is
|
||||
# off-host dead-man supervision (WAKE-DOCTRINE); out of scope here.
|
||||
meta_conf() { echo "$STATE_ROOT/meta-watch.conf"; } # flat files on purpose:
|
||||
meta_heartbeat() { echo "$STATE_ROOT/meta-watch.heartbeat"; } # scan loop reads dirs only
|
||||
|
||||
meta_state() { # rc 0 = installed (globals below set) · rc 2 = not installed
|
||||
MU_UNIT=""; MU_INTERVAL=0; MU_TIMER="inactive"; MU_AGE="none"; MU_HEALTHY=0
|
||||
local conf; conf="$(meta_conf)"
|
||||
[[ -r "$conf" ]] || return 2
|
||||
# shellcheck disable=SC1090
|
||||
source "$conf"
|
||||
MU_UNIT="${META_UNIT:-$META_UNIT_DEFAULT}"
|
||||
MU_INTERVAL="${META_INTERVAL:-300}"
|
||||
if systemctl --user is-active "$MU_UNIT.timer" >/dev/null 2>&1; then MU_TIMER="active"; fi
|
||||
local hb; hb="$(meta_heartbeat)"
|
||||
if [[ -r "$hb" ]]; then
|
||||
MU_AGE=$(( $(date +%s) - $(stat -c %Y "$hb") ))
|
||||
if (( MU_AGE < 0 )); then MU_AGE=0; fi
|
||||
fi
|
||||
local limit=$(( MU_INTERVAL * 2 + 60 ))
|
||||
if [[ "$MU_TIMER" == "active" && "$MU_AGE" != "none" && "$MU_AGE" -le "$limit" ]]; then
|
||||
MU_HEALTHY=1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
meta_line() { # human one-liner for list/status output
|
||||
if meta_state; then
|
||||
if [[ "$MU_HEALTHY" -eq 1 ]]; then
|
||||
echo "meta-watch: healthy (unit $MU_UNIT timer active, heartbeat ${MU_AGE}s old, cadence ${MU_INTERVAL}s)"
|
||||
else
|
||||
local age="$MU_AGE"
|
||||
if [[ "$MU_AGE" == "none" ]]; then age="never"; fi
|
||||
echo "meta-watch: DEAD (unit $MU_UNIT timer=$MU_TIMER, heartbeat $age, cadence ${MU_INTERVAL}s) — autonomous detection DOWN; queries still work"
|
||||
fi
|
||||
else
|
||||
echo "meta-watch: not installed (no autonomous detection; agent-watch.sh meta-install)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── list / status / stop / log ───────────────────────────────────────────────
|
||||
cmd_list() {
|
||||
require_user_bus # T24: an unreachable bus reads as "all timers gone" — refuse before classifying
|
||||
echo "active watches (transient timers):"
|
||||
systemctl --user list-timers --all --no-legend "${UNIT_PREFIX}-*.timer" 2>/dev/null || true
|
||||
local stale=() retired=() d n
|
||||
for d in "$STATE_ROOT"/*/; do
|
||||
[[ -d "$d" ]] || continue
|
||||
n="$(basename "$d")"
|
||||
[[ -r "$d/config" ]] || continue
|
||||
case "$(classify_watch "$n")" in
|
||||
retired) retired+=("$n ($(cat "$d/terminated" 2>/dev/null || echo '?'))") ;;
|
||||
stale) stale+=("$n") ;;
|
||||
esac
|
||||
done
|
||||
# Print the collected sections first, then do notification work: a slow or
|
||||
# failing sender must not bury the inventory (mirrors cmd_status, S2).
|
||||
local x
|
||||
if [[ ${#retired[@]} -gt 0 ]]; then
|
||||
echo "retired (expected stop; see log <name>):"
|
||||
for x in ${retired[@]+"${retired[@]}"}; do echo " $x"; done
|
||||
fi
|
||||
if [[ ${#stale[@]} -gt 0 ]]; then
|
||||
echo "stale state (config present, timer gone):"
|
||||
for x in ${stale[@]+"${stale[@]}"}; do echo " $x"; done
|
||||
fi
|
||||
echo "$(meta_line)" # T19: meta-watch health rides the same surface
|
||||
for x in ${stale[@]+"${stale[@]}"}; do
|
||||
notice_stale_once "$x"
|
||||
done
|
||||
}
|
||||
|
||||
# Machine-readable liveness: one call, JSON or human, exit 0 = none stale,
|
||||
# exit 3 = one or more stale. Never a substitute for arming real watches; it
|
||||
# answers "did my watches survive" (WAKE-DOCTRINE: check the instrument).
|
||||
cmd_status() {
|
||||
# S1: status takes at most one option, --json, and nothing else.
|
||||
[[ $# -le 1 ]] || die "status: takes at most one option (--json), got: $*"
|
||||
local json=0
|
||||
case "${1:-}" in
|
||||
"") ;;
|
||||
--json) json=1 ;;
|
||||
*) die "status: unknown argument: $1 (usage: status [--json])" ;;
|
||||
esac
|
||||
require_user_bus # T24: an unreachable bus reads as "all timers gone" — refuse before classifying
|
||||
local alive=() stale=() retired=() d n
|
||||
for d in "$STATE_ROOT"/*/; do
|
||||
[[ -d "$d" ]] || continue
|
||||
[[ -r "$d/config" ]] || continue
|
||||
n="$(basename "$d")"
|
||||
case "$(classify_watch "$n")" in
|
||||
alive) alive+=("$n") ;;
|
||||
retired) retired+=("$n") ;;
|
||||
stale) stale+=("$n") ;;
|
||||
esac
|
||||
done
|
||||
# Print the collected answer FIRST (S2), then do notification work: a slow
|
||||
# or failing sender must not delay the JSON/human liveness answer.
|
||||
# T19: meta-watch health rides the same machine answer (additive schema-1
|
||||
# field; consumers ignoring unknown fields are unaffected).
|
||||
local meta_installed=0
|
||||
if meta_state; then meta_installed=1; fi
|
||||
if [[ "$json" -eq 1 ]]; then
|
||||
local ja="" js="" jr=""
|
||||
[[ "${#alive[@]}" -gt 0 ]] && ja="$(printf '"%s",' "${alive[@]}" | sed 's/,$//')"
|
||||
[[ "${#stale[@]}" -gt 0 ]] && js="$(printf '"%s",' "${stale[@]}" | sed 's/,$//')"
|
||||
[[ "${#retired[@]}" -gt 0 ]] && jr="$(printf '"%s",' "${retired[@]}" | sed 's/,$//')"
|
||||
local mj
|
||||
if [[ "$meta_installed" -eq 1 ]]; then
|
||||
local m_h="false" m_t="false"
|
||||
if [[ "$MU_HEALTHY" -eq 1 ]]; then m_h="true"; fi
|
||||
if [[ "$MU_TIMER" == "active" ]]; then m_t="true"; fi
|
||||
local m_age="$MU_AGE"
|
||||
if [[ "$MU_AGE" == "none" ]]; then m_age="null"; fi
|
||||
mj=$(printf '"installed":true,"healthy":%s,"timer_active":%s,"heartbeat_age_s":%s,"interval_s":%s,"unit":"%s"' \
|
||||
"$m_h" "$m_t" "$m_age" "$MU_INTERVAL" "$MU_UNIT")
|
||||
else
|
||||
mj='"installed":false'
|
||||
fi
|
||||
printf '{"schema":1,"total":%d,"alive":[%s],"stale":[%s],"retired":[%s],"meta":{%s}}\n' \
|
||||
"$(( ${#alive[@]} + ${#stale[@]} + ${#retired[@]} ))" "$ja" "$js" "$jr" "$mj"
|
||||
else
|
||||
echo "alive: ${#alive[@]}${alive[@]:+ (${alive[*]})}"
|
||||
echo "stale: ${#stale[@]}${stale[@]:+ (${stale[*]})}"
|
||||
echo "retired: ${#retired[@]}${retired[@]:+ (${retired[*]})}"
|
||||
echo "$(meta_line)"
|
||||
fi
|
||||
local s
|
||||
for s in ${stale[@]+"${stale[@]}"}; do
|
||||
notice_stale_once "$s"
|
||||
done
|
||||
local rc=0
|
||||
if [[ "${#stale[@]}" -gt 0 ]]; then rc=3; fi
|
||||
# T19: an installed-but-dead meta-watch is itself a liveness failure —
|
||||
# fail loud on the instrument, not only on the watches it guards.
|
||||
if [[ "$meta_installed" -eq 1 && "$MU_HEALTHY" -ne 1 ]]; then rc=3; fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
local name="$1"
|
||||
local unit; unit="$(unit_for "$name")"
|
||||
systemctl --user stop "$unit.timer" "$unit.service" >/dev/null 2>&1 || true
|
||||
rm -rf "$(state_dir "$name")" 2>/dev/null || true
|
||||
echo "stopped watcher '$name' (state removed)"
|
||||
}
|
||||
|
||||
cmd_log() {
|
||||
local name="$1"
|
||||
local dir; dir="$(state_dir "$name")"
|
||||
[[ -r "$dir/watch.log" ]] && cat "$dir/watch.log"
|
||||
echo "--- journal (unit $(unit_for "$name").service) ---"
|
||||
journalctl --user -u "$(unit_for "$name").service" --no-pager -n 40 2>/dev/null | tail -n +2 || true
|
||||
}
|
||||
|
||||
# ── _scan (hidden; run BY the meta-watch service each cadence) ───────────────
|
||||
# Same classification + claim-first notices as list/status — a meta tick is
|
||||
# just another concurrent caller of the T16 semantics. Heartbeat is stamped
|
||||
# LAST: it proves a COMPLETED scan, not a scheduled one (a crashing scan
|
||||
# leaves the heartbeat aging, which is exactly the dead-meta signal).
|
||||
cmd_scan() { # $1 = meta unit name (informational, for the journal line)
|
||||
require_user_bus # T24: a bus-less scan classifies everything LOST — refuse, stamp no heartbeat
|
||||
local d n stale=()
|
||||
for d in "$STATE_ROOT"/*/; do
|
||||
[[ -d "$d" ]] || continue
|
||||
[[ -r "$d/config" ]] || continue
|
||||
n="$(basename "$d")"
|
||||
if [[ "$(classify_watch "$n")" == "stale" ]]; then stale+=("$n"); fi
|
||||
done
|
||||
local s
|
||||
for s in ${stale[@]+"${stale[@]}"}; do
|
||||
notice_stale_once "$s"
|
||||
done
|
||||
printf '%s\n' "$(date -u +%FT%TZ)" > "$(meta_heartbeat)"
|
||||
echo "agent-watch meta-watch (${1:-$META_UNIT_DEFAULT}) scan: ${#stale[@]} stale, notices attempted"
|
||||
}
|
||||
|
||||
# ── meta-install / meta-remove (explicit; nothing self-installs) ────────────
|
||||
cmd_meta_install() {
|
||||
local interval=300 unit="$META_UNIT_DEFAULT"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--interval) interval="$2"; shift 2 ;;
|
||||
--unit-name) unit="$2"; shift 2 ;;
|
||||
*) die "meta-install: unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
[[ "$unit" =~ ^agent-watch-[a-z0-9][a-z0-9-]*$ ]] || die "meta-install: --unit-name must be agent-watch-<lowercase-hyphens> so it stays visible under the agent-watch-* inventory (got: '$unit')"
|
||||
[[ "$interval" =~ ^[0-9]+$ ]] || die "meta-install: --interval must be a number"
|
||||
[[ "$interval" -ge "$META_FLOOR_INTERVAL" ]] || die "meta-install: --interval floor is ${META_FLOOR_INTERVAL}s for the meta-watch (got ${interval}s) — a detection net, not a poll"
|
||||
command -v systemctl >/dev/null 2>&1 || die "systemctl not on PATH (meta-watch requires systemd --user)"
|
||||
[[ -x "$SEND" ]] || die "sender not found/executable: $SEND"
|
||||
require_user_bus # T24: installing without the bus writes units the manager never loads
|
||||
# Collision guard: meta units share the watch-unit namespace; never shadow
|
||||
# an existing watch's units.
|
||||
local bare="${unit#agent-watch-}"
|
||||
[[ ! -r "$(state_dir "$bare")/config" ]] || die "a watch named '$bare' already exists; its units would collide with $unit.*"
|
||||
# T19R O1: a meta installed under a DIFFERENT unit name must not be
|
||||
# displaced silently — overwriting the conf strands the old timer (two
|
||||
# live metas, one heartbeat). Refuse; meta-remove first. A same-name
|
||||
# re-install is the idempotent repair path (unit files rewritten, timer
|
||||
# re-enabled and restarted) — preserved below.
|
||||
if meta_state; then
|
||||
[[ "$MU_UNIT" == "$unit" ]] || \
|
||||
die "meta-install: a meta-watch is already installed as '$MU_UNIT.timer' (conf: $(meta_conf)); run 'agent-watch.sh meta-remove' first — installing '$unit' would run two metas on one heartbeat"
|
||||
fi
|
||||
local was_active=0
|
||||
if systemctl --user is-active "$unit.timer" >/dev/null 2>&1; then was_active=1; fi
|
||||
local self; self="$(readlink -f "$0")"
|
||||
# Env travel (D62 lesson, same as start): the scan must resolve the SAME
|
||||
# state root and sender the installing shell used. systemd Environment=
|
||||
# carries these verbatim, so reject values it cannot (no spaces/quotes).
|
||||
local v
|
||||
for v in "$HOME" "${MOSAIC_WATCH_STATE:-}" "${MOSAIC_AGENT_SEND:-}" "$self"; do
|
||||
[[ -z "$v" || "$v" =~ ^[[:alnum:]_./:=+-]+$ ]] || die "meta-install: value has characters a systemd unit cannot carry verbatim: '$v'"
|
||||
done
|
||||
local ud="$HOME/.config/systemd/user"
|
||||
mkdir -p "$ud"
|
||||
{
|
||||
echo "# generated by agent-watch.sh meta-install $(date -u +%FT%TZ); change = re-install, uninstall = meta-remove"
|
||||
echo "[Unit]"
|
||||
echo "Description=agent-watch meta-watch: autonomous stale-watch detection (${interval}s cadence)"
|
||||
echo ""
|
||||
echo "[Timer]"
|
||||
echo "OnBootSec=1min" # post-reboot first scan, even though the
|
||||
echo "OnUnitActiveSec=${interval}s" # service has never run this boot
|
||||
echo "AccuracySec=5s"
|
||||
echo ""
|
||||
echo "[Install]"
|
||||
echo "WantedBy=timers.target" # enablement survives reboot (the whole point)
|
||||
} > "$ud/$unit.timer"
|
||||
{
|
||||
echo "# generated by agent-watch.sh meta-install $(date -u +%FT%TZ); change = re-install, uninstall = meta-remove"
|
||||
echo "[Unit]"
|
||||
echo "Description=agent-watch meta-watch scan (autonomous stale detection)"
|
||||
echo ""
|
||||
echo "[Service]"
|
||||
echo "Type=oneshot"
|
||||
echo "TimeoutStartSec=10min" # retries (3x5s sleeps) must not trip the default
|
||||
echo "Environment=HOME=$HOME"
|
||||
if [[ -n "${MOSAIC_WATCH_STATE:-}" ]]; then echo "Environment=MOSAIC_WATCH_STATE=$MOSAIC_WATCH_STATE"; fi
|
||||
if [[ -n "${MOSAIC_AGENT_SEND:-}" ]]; then echo "Environment=MOSAIC_AGENT_SEND=$MOSAIC_AGENT_SEND"; fi
|
||||
echo "ExecStart=$self _scan $unit"
|
||||
} > "$ud/$unit.service"
|
||||
mkdir -p "$STATE_ROOT"
|
||||
printf 'META_UNIT=%q\nMETA_INTERVAL=%s\n' "$unit" "$interval" > "$(meta_conf)"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "$unit.timer" >/dev/null
|
||||
# Repair path (same-name re-install): a literal restart re-arms the timer
|
||||
# on the freshly written unit files; enable --now alone would leave an
|
||||
# already-active timer on its old schedule.
|
||||
if [[ "$was_active" -eq 1 ]]; then
|
||||
systemctl --user restart "$unit.timer" >/dev/null
|
||||
fi
|
||||
# Explicit first scan NOW: fail fast at install time, not one cadence later;
|
||||
# it also anchors OnUnitActiveSec for steady cadence.
|
||||
systemctl --user start "$unit.service"
|
||||
echo "meta-watch installed and enabled: $unit.timer, every ${interval}s (persistent unit: reboot-safe)"
|
||||
echo "first scan complete; heartbeat: $(meta_heartbeat)"
|
||||
echo "state root: $STATE_ROOT"
|
||||
}
|
||||
|
||||
cmd_meta_remove() {
|
||||
local unit=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--unit-name) unit="$2"; shift 2 ;;
|
||||
*) die "meta-remove: unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$unit" ]]; then
|
||||
if [[ -r "$(meta_conf)" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
unit="$( . "$(meta_conf)" && echo "${META_UNIT:-}" )"
|
||||
fi
|
||||
[[ -n "$unit" ]] || unit="$META_UNIT_DEFAULT"
|
||||
fi
|
||||
systemctl --user disable --now "$unit.timer" >/dev/null 2>&1 || true
|
||||
systemctl --user reset-failed "$unit.service" "$unit.timer" >/dev/null 2>&1 || true
|
||||
rm -f "$HOME/.config/systemd/user/$unit.timer" "$HOME/.config/systemd/user/$unit.service"
|
||||
systemctl --user daemon-reload >/dev/null 2>&1 || true
|
||||
rm -f "$(meta_conf)" "$(meta_heartbeat)"
|
||||
echo "meta-watch removed: $unit.{timer,service} uninstalled, meta state cleared"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) shift; cmd_start "$@" ;;
|
||||
list) cmd_list ;;
|
||||
status) shift; cmd_status "$@" ;;
|
||||
stop) shift; cmd_stop "$1" ;;
|
||||
log) shift; cmd_log "$1" ;;
|
||||
meta-install) shift; cmd_meta_install "$@" ;;
|
||||
meta-remove) shift; cmd_meta_remove "$@" ;;
|
||||
_tick) shift; cmd_tick "$1" ;;
|
||||
_scan) shift; cmd_scan "$@" ;;
|
||||
-h|--help|*) usage ;;
|
||||
esac
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# jarvis-email-watch-liveness.sh — T40 timer-owner liveness check (code-be-01).
|
||||
#
|
||||
# Owner: code-be-01 (Jason ruling, board T40; jarvis remains author of the
|
||||
# watcher code). Detection is SYSTEMD-STATE-ONLY — no pane scraping, no fixed
|
||||
# interval assumptions beyond the unit's own 30-min cadence.
|
||||
#
|
||||
# DEAD conditions (any one pages the owner):
|
||||
# 1. timer unit not found or not active
|
||||
# 2. NextElapse is infinity (timer will never fire again)
|
||||
# 3. service last result failed
|
||||
# 4. last service run older than 90 min (3 missed 30-min slots)
|
||||
#
|
||||
# Exit: 0 = alive (prints state line) · 1 = DEAD (prints reason) · 2 = cannot
|
||||
# assert (systemd unreachable — surface, don't guess).
|
||||
set -uo pipefail
|
||||
|
||||
UNIT_TIMER="jarvis-email-watch.timer"
|
||||
UNIT_SERVICE="jarvis-email-watch.service"
|
||||
CADENCE_MIN=30
|
||||
MISSED_SLOTS=3
|
||||
STALE_SEC=$(( CADENCE_MIN * 60 * MISSED_SLOTS ))
|
||||
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
|
||||
active="$(systemctl --user show "$UNIT_TIMER" --property=ActiveState --value 2>/dev/null)"
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ] || [ -z "$active" ]; then
|
||||
echo "DEAD(reason=systemd-unreachable-or-unit-missing unit=$UNIT_TIMER rc=$rc)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
[ "$active" = "active" ] || { echo "DEAD(reason=timer-not-active state=$active)"; exit 1; }
|
||||
|
||||
# This timer is MONOTONIC-ONLY (OnBootSec/OnUnitActiveSec/OnUnitInactiveSec — no
|
||||
# realtime calendar), so NextElapseUSecRealtime is legitimately empty. The
|
||||
# unit file's own comment records the failure signature that killed brain-sync
|
||||
# for five days: monotonic timer + reboot => NextElapse becomes 'infinity'
|
||||
# while is-active/is-enabled stay green. Probe the monotonic property; then
|
||||
# take the AUTHORITATIVE next-fire from list-timers (it computes the min over
|
||||
# all timer bases; `show`'s aggregate includes the long-expired OnBootSec base
|
||||
# and reads as ~never on a healthy timer).
|
||||
mono="$(systemctl --user show "$UNIT_TIMER" --property=NextElapseUSecMonotonic --value 2>/dev/null)"
|
||||
[ "$mono" != "infinity" ] || { echo "DEAD(reason=next-elapse-infinity monotonic — reboot-killed-timer signature, see unit-file note)"; exit 1; }
|
||||
|
||||
row="$(systemctl --user list-timers "$UNIT_TIMER" --no-pager 2>/dev/null | grep "$UNIT_TIMER")"
|
||||
[ -n "$row" ] || { echo "DEAD(reason=no-list-timers-row unit=$UNIT_TIMER)"; exit 1; }
|
||||
case "$row" in
|
||||
*"n/a"*) echo "DEAD(reason=list-timers-next-na)"; exit 1 ;;
|
||||
esac
|
||||
next="$row"
|
||||
|
||||
result="$(systemctl --user show "$UNIT_SERVICE" --property=Result --value 2>/dev/null)"
|
||||
[ "$result" = "success" ] || [ "$result" = "" ] || { echo "DEAD(reason=service-result-failed result=$result)"; exit 1; }
|
||||
|
||||
execstamp="$(systemctl --user show "$UNIT_SERVICE" --property=ExecMainExitTimestamp --value 2>/dev/null)"
|
||||
if [ -n "$execstamp" ] && [ "$execstamp" != "0" ]; then
|
||||
last_ep="$(date -d "$execstamp" +%s 2>/dev/null || echo 0)"
|
||||
now_ep="$(date +%s)"
|
||||
age=$(( now_ep - last_ep ))
|
||||
if [ "$age" -gt "$STALE_SEC" ]; then
|
||||
echo "DEAD(reason=stale-last-run age_min=$((age/60)) threshold_min=$((STALE_SEC/60)))"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "ALIVE(active=$active next=$next result=$result last_exit=$execstamp)"
|
||||
exit 0
|
||||
Executable
+272
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-agent-watch-socket.sh — P5-C03 independent B1 contract validation.
|
||||
#
|
||||
# Uses real tmux servers on a private TMUX_TMPDIR. systemctl and systemd-run
|
||||
# are stubs because this suite validates start-time socket selection, not
|
||||
# systemd scheduling. HOME, watch state, and all test sockets are isolated.
|
||||
# Run with --no-red-controls only for the mutation child runs.
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
TOOL="${AGENT_WATCH_TOOL_OVERRIDE:-$HERE/agent-watch.sh}"
|
||||
REAL_TMUX="$(command -v tmux 2>/dev/null || true)"
|
||||
BASH_BIN="$(command -v bash)"
|
||||
ORIG_PATH="$PATH"
|
||||
|
||||
[ -x "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; }
|
||||
[ -x "$TOOL" ] || { echo "FAIL: agent-watch tool not found/executable: $TOOL" >&2; exit 1; }
|
||||
|
||||
ROOT="$(mktemp -d)"
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
HOME_DIR="$ROOT/home"
|
||||
TMUX_TMPDIR="$ROOT/tmux"
|
||||
BIN="$ROOT/bin"
|
||||
STATE="$ROOT/state"
|
||||
mkdir -p "$HOME_DIR" "$TMUX_TMPDIR" "$BIN" "$STATE"
|
||||
chmod 700 "$ROOT" "$HOME_DIR" "$TMUX_TMPDIR" "$BIN" "$STATE"
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
|
||||
# These stubs satisfy agent-watch's user-bus and transient-unit checks while
|
||||
# leaving real tmux semantics, including socket discovery, under test.
|
||||
cat > "$BIN/systemctl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
case "${*:--}" in
|
||||
"--user show-environment") exit 0 ;;
|
||||
"--user is-active"*) exit 1 ;;
|
||||
"--user start"*) exit 1 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
STUB
|
||||
cat > "$BIN/systemd-run" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "${AGENT_WATCH_TEST_SYSTEMD_RUN_LOG:?}"
|
||||
exit 0
|
||||
STUB
|
||||
cat > "$BIN/sender-stub" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
STUB
|
||||
chmod 700 "$BIN/systemctl" "$BIN/systemd-run" "$BIN/sender-stub"
|
||||
|
||||
SOCKETS=()
|
||||
cleanup() {
|
||||
local socket
|
||||
for socket in "${SOCKETS[@]}"; do
|
||||
TMUX_TMPDIR="$TMUX_TMPDIR" "$REAL_TMUX" -L "$socket" kill-server >/dev/null 2>&1 || true
|
||||
done
|
||||
rm -rf "$ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
failures=0
|
||||
check() {
|
||||
local label="$1" result="$2"
|
||||
if [ "$result" -eq 0 ]; then
|
||||
printf 'ok %s\n' "$label"
|
||||
else
|
||||
printf 'FAIL %s\n' "$label" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
mk_server() {
|
||||
local socket="$1" session="$2"
|
||||
SOCKETS+=("$socket")
|
||||
TMUX_TMPDIR="$TMUX_TMPDIR" "$REAL_TMUX" -L "$socket" new-session -d -s "$session" -c "$HOME_DIR" \
|
||||
'exec bash --noprofile --norc -i' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_start() {
|
||||
local socket_env=(-u MOSAIC_TMUX_SOCKET)
|
||||
if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
socket_env=(MOSAIC_TMUX_SOCKET="$MOSAIC_TMUX_SOCKET")
|
||||
fi
|
||||
env "${socket_env[@]}" \
|
||||
PATH="$BIN:$ORIG_PATH" HOME="$HOME_DIR" TMUX_TMPDIR="$TMUX_TMPDIR" \
|
||||
MOSAIC_WATCH_STATE="$STATE" MOSAIC_AGENT_SEND="$BIN/sender-stub" \
|
||||
AGENT_WATCH_TEST_SYSTEMD_RUN_LOG="$ROOT/systemd-run.log" \
|
||||
"$BASH_BIN" "$TOOL" start "$@"
|
||||
}
|
||||
|
||||
run_start_env() {
|
||||
local socket="$1"
|
||||
shift
|
||||
(
|
||||
MOSAIC_TMUX_SOCKET="$socket"
|
||||
run_start "$@"
|
||||
)
|
||||
}
|
||||
|
||||
config_has() {
|
||||
local name="$1" line="$2"
|
||||
grep -qxF "$line" "$STATE/$name/config" 2>/dev/null
|
||||
}
|
||||
|
||||
check_config() {
|
||||
local label="$1" name="$2" line="$3"
|
||||
if config_has "$name" "$line"; then
|
||||
check "$label" 0
|
||||
else
|
||||
check "$label" 1
|
||||
fi
|
||||
}
|
||||
|
||||
# C1: explicit --socket wins over the environment and discovery.
|
||||
FLAG_SOCKET="aw-c03-flag-$$"
|
||||
mk_server "$FLAG_SOCKET" flag-target
|
||||
if run_start_env "not-the-flag" --name c03-flag --session flag-target --socket "$FLAG_SOCKET" \
|
||||
--when false --message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C1 explicit --socket wins over MOSAIC_TMUX_SOCKET" c03-flag "SOCKET=$FLAG_SOCKET"
|
||||
else
|
||||
check "C1 explicit --socket start succeeds" 1
|
||||
fi
|
||||
|
||||
# C2: the launcher-exported environment pin wins before discovery. Both
|
||||
# sockets intentionally contain the target, so selecting discovery first
|
||||
# would hit the ambiguity guard instead of producing an env-pinned config.
|
||||
ENV_SOCKET="aw-c03-env-$$"
|
||||
ENV_DISCOVERY_SOCKET="aw-c03-env-discovery-$$"
|
||||
mk_server "$ENV_SOCKET" env-target
|
||||
mk_server "$ENV_DISCOVERY_SOCKET" env-target
|
||||
if run_start_env "$ENV_SOCKET" --name c03-env --session env-target \
|
||||
--when false --message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C2 MOSAIC_TMUX_SOCKET wins before discovery" c03-env "SOCKET=$ENV_SOCKET"
|
||||
else
|
||||
check "C2 environment pin start succeeds without discovery ambiguity" 1
|
||||
fi
|
||||
BAD_ENV_SOCKET="aw-c03-bad-env-$$"
|
||||
bad_env_rc=0
|
||||
run_start_env "$BAD_ENV_SOCKET" --name c03-bad-env --session env-target \
|
||||
--when false --message "socket contract" --interval 10 >/dev/null 2>"$ROOT/bad-env.err" || bad_env_rc=$?
|
||||
if [ "$bad_env_rc" -eq 2 ]; then
|
||||
check "C2 stale environment socket is rejected" 0
|
||||
else
|
||||
check "C2 stale environment socket is rejected" 1
|
||||
fi
|
||||
if [ ! -e "$STATE/c03-bad-env/config" ]; then
|
||||
check "C2 stale environment socket writes no config" 0
|
||||
else
|
||||
check "C2 stale environment socket writes no config" 1
|
||||
fi
|
||||
|
||||
# C3: with no pin, one exact session hit is discovered from TMUX_TMPDIR.
|
||||
DEFAULT_SOCKET="aw-c03-default-$$"
|
||||
mk_server "$DEFAULT_SOCKET" default-target
|
||||
if run_start --name c03-default --session default-target --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C3 unique TMUX_TMPDIR hit is selected" c03-default "SOCKET=$DEFAULT_SOCKET"
|
||||
else
|
||||
check "C3 unique discovery start succeeds" 1
|
||||
fi
|
||||
|
||||
# C4: duplicate session names refuse with the documented rc 4 and identify
|
||||
# both sockets. The compound form also proves the session component is pinned
|
||||
# exactly while resolving, rather than being treated as a prefix.
|
||||
DUP_SOCKET_A="aw-c03-dup-a-$$"
|
||||
DUP_SOCKET_B="aw-c03-dup-b-$$"
|
||||
mk_server "$DUP_SOCKET_A" duplicate-target
|
||||
mk_server "$DUP_SOCKET_B" duplicate-target
|
||||
DUP_ERR="$ROOT/duplicate.err"
|
||||
dup_rc=0
|
||||
run_start --name c03-duplicate --session duplicate-target:0.0 --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>"$DUP_ERR" || dup_rc=$?
|
||||
if [ "$dup_rc" -eq 4 ]; then
|
||||
check "C4 compound duplicate refuses with rc 4" 0
|
||||
else
|
||||
check "C4 compound duplicate refuses with rc 4" 1
|
||||
fi
|
||||
if grep -qF "$DUP_SOCKET_A" "$DUP_ERR" && grep -qF "$DUP_SOCKET_B" "$DUP_ERR"; then
|
||||
check "C4 refusal names both duplicate sockets" 0
|
||||
else
|
||||
check "C4 refusal names both duplicate sockets" 1
|
||||
fi
|
||||
if [ ! -e "$STATE/c03-duplicate/config" ]; then
|
||||
check "C4 duplicate refusal leaves no watcher config" 0
|
||||
else
|
||||
check "C4 duplicate refusal leaves no watcher config" 1
|
||||
fi
|
||||
|
||||
# C5: exact matching rejects a prefix-only session, then accepts the exact
|
||||
# name when a prefix sibling is also present.
|
||||
PREFIX_SOCKET="aw-c03-prefix-$$"
|
||||
EXACT_SOCKET="aw-c03-exact-$$"
|
||||
mk_server "$PREFIX_SOCKET" exact-target-old
|
||||
prefix_rc=0
|
||||
run_start --name c03-prefix --session exact-target --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>"$ROOT/prefix.err" || prefix_rc=$?
|
||||
if [ "$prefix_rc" -eq 2 ]; then
|
||||
check "C5 prefix-only session is not an exact hit" 0
|
||||
else
|
||||
check "C5 prefix-only session is not an exact hit" 1
|
||||
fi
|
||||
|
||||
mk_server "$EXACT_SOCKET" exact-target
|
||||
if run_start --name c03-exact --session =exact-target --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C5 =name selects the exact session despite prefix sibling" c03-exact "SOCKET=$EXACT_SOCKET"
|
||||
check_config "C5 config retains the exact =name target" c03-exact 'SESSION==exact-target'
|
||||
else
|
||||
check "C5 exact =name start succeeds" 1
|
||||
fi
|
||||
|
||||
# C6: compound input without a leading '=' is normalized to '=session:rest'
|
||||
# and discovered. This is the regression that catches constructing '==...'.
|
||||
COMPOUND_SOCKET="aw-c03-compound-$$"
|
||||
mk_server "$COMPOUND_SOCKET" compound-target
|
||||
if run_start --name c03-compound --session compound-target:0.0 --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C6 compound session discovery pins the session component" c03-compound 'SESSION==compound-target:0.0'
|
||||
check_config "C6 compound session selects its unique socket" c03-compound "SOCKET=$COMPOUND_SOCKET"
|
||||
else
|
||||
check "C6 compound session discovery succeeds" 1
|
||||
fi
|
||||
|
||||
run_mutation() {
|
||||
local label="$1" mutation="$2"
|
||||
local mutant="$ROOT/$label-mutant.sh" output="$ROOT/$label.out"
|
||||
python3 - "$TOOL" "$mutant" "$mutation" <<'PY'
|
||||
import sys
|
||||
source, destination, mutation = sys.argv[1:]
|
||||
text = open(source, encoding="utf-8").read()
|
||||
if mutation == "env":
|
||||
old = 'if [[ -z "$socket" && -n "${MOSAIC_TMUX_SOCKET:-}" ]]; then\n'
|
||||
new = 'if false; then\n'
|
||||
else:
|
||||
old = 'tmux -L "$sname" has-session -t "$session" 2>/dev/null'
|
||||
new = 'tmux -L "$sname" has-session -t "${session#=}" 2>/dev/null'
|
||||
assert text.count(old) == 1, f"mutation anchor count for {mutation}: {text.count(old)}"
|
||||
mutant_text = text.replace(old, new)
|
||||
assert mutant_text != text
|
||||
open(destination, "w", encoding="utf-8").write(mutant_text)
|
||||
PY
|
||||
chmod 700 "$mutant"
|
||||
if cmp -s "$TOOL" "$mutant"; then
|
||||
echo "FAIL $label mutation was a no-op" >&2
|
||||
failures=$((failures + 1))
|
||||
return
|
||||
fi
|
||||
if AGENT_WATCH_TOOL_OVERRIDE="$mutant" "$BASH_BIN" "$0" --no-red-controls >"$output" 2>&1; then
|
||||
printf 'FAIL %s mutant survived (suite did not go red)\n' "$label" >&2
|
||||
failures=$((failures + 1))
|
||||
else
|
||||
printf 'ok %s mutant makes the behavioral suite red\n' "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${1:-}" != "--no-red-controls" ]; then
|
||||
# R1: moving env precedence out of its pre-discovery branch must be caught.
|
||||
run_mutation red-precedence env
|
||||
# R2: removing '=' from discovery must be caught by the exact/prefix arms.
|
||||
run_mutation red-exact exact
|
||||
fi
|
||||
|
||||
if [ "$failures" -eq 0 ]; then
|
||||
if [ "${1:-}" = "--no-red-controls" ]; then
|
||||
echo "agent-watch socket contract: contract arms OK (C1-C6)"
|
||||
else
|
||||
echo "agent-watch socket contract: all arms OK (C1-C6 + 2 red controls)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
printf 'agent-watch socket contract: %d failure(s)\n' "$failures" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,103 @@
|
||||
# Inter-Agent tmux Comms — Standard & Tooling
|
||||
|
||||
Reliable, self-identifying messaging between Mosaic agents running in tmux panes
|
||||
(Claude Code / Codex / OpenCode REPLs), across hosts.
|
||||
|
||||
## The addressing standard (required)
|
||||
|
||||
Every cross-agent tmux message MUST begin with an addressing preamble:
|
||||
|
||||
```
|
||||
[<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
```
|
||||
|
||||
- `host` = `hostname -s` of the machine the agent runs on (e.g. `web1`, `sb-it-mgr-0-lt`).
|
||||
- `session` = the tmux session name (e.g. `mos-claude`, `rev0-4`, `installer-1`).
|
||||
- **Replies FLIP the preamble**: the recipient answers with `[<dst> -> <src>] ...`.
|
||||
|
||||
Why: a fresh or context-wiped agent always knows who sent a message and to whom.
|
||||
No ambiguity about origin or lane after a tmux wipe / session restart.
|
||||
|
||||
Example exchange:
|
||||
|
||||
```
|
||||
[web1:mos-claude -> sb-it-mgr-0-lt:installer-1] status on #29?
|
||||
[sb-it-mgr-0-lt:installer-1 -> web1:mos-claude] Q2 done, opening PR #34.
|
||||
```
|
||||
|
||||
## The helper: `agent-send.sh`
|
||||
|
||||
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
delivers reliably to local OR remote panes.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
agent-send.sh -s <dst_session> -m "message"
|
||||
|
||||
# Local target on a Mosaic fleet socket
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "message"
|
||||
|
||||
# Remote target (over ssh)
|
||||
agent-send.sh -H user@host -s <dst_session> -m "message"
|
||||
|
||||
# From a file / stdin
|
||||
agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||
echo "msg" | agent-send.sh -s <dst_session>
|
||||
```
|
||||
|
||||
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`
|
||||
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-fleet ls
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "status?"
|
||||
send-message.sh -L mosaic-fleet -t '=coder0' -m "raw pane message"
|
||||
```
|
||||
|
||||
This keeps fleet operations away from the user's default tmux server. It is the
|
||||
safe rollout path on hosts that already have manual tmux sessions.
|
||||
|
||||
## Why a helper exists (the submission gotcha)
|
||||
|
||||
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
|
||||
trailing `Enter` is frequently swallowed and the message sits as an **unsubmitted
|
||||
draft** ("Press up to edit queued messages"). Over an `ssh -> nested tmux` hop the
|
||||
plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||
|
||||
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||
keystroke and flush with a second, verifying against a draft heuristic.
|
||||
|
||||
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||
message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
|
||||
## Files
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
||||
long-lived process alongside the coordinator session.
|
||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
These live in the installed framework copy at
|
||||
`~/.mosaic/tools/tmux/`. `install.sh` rsyncs the framework **source tree**
|
||||
to each host, so to propagate permanently, land both files in the framework
|
||||
source repo and re-run the installer on each host. Until then, `agent-send.sh`
|
||||
already works against any reachable host because it ships `send-message.sh` over
|
||||
ssh per-send — no pre-install on the target host is needed to _send to_ it.
|
||||
Executable
+229
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.sh — standard inter-agent tmux messaging for the Mosaic stack.
|
||||
#
|
||||
# WHAT IT DOES
|
||||
# Sends a message to another agent's tmux pane (local or on a remote host)
|
||||
# with the canonical addressing preamble prepended:
|
||||
#
|
||||
# [<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
#
|
||||
# The preamble makes every inter-agent message self-identifying, so a fresh
|
||||
# or context-wiped agent always knows who sent a message and to whom — no
|
||||
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
||||
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
|
||||
#
|
||||
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
|
||||
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
|
||||
# field instead of re-deriving intent from the body.
|
||||
#
|
||||
# WHY A WRAPPER
|
||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||
# bracketed-paste + Enter-flush + draft-detection. For REMOTE targets this
|
||||
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||
# reliable send-keys happens local to the target pane — sidestepping the
|
||||
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||
# the remote host; only bash + tmux + base64 (standard).
|
||||
#
|
||||
# USAGE
|
||||
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
|
||||
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
|
||||
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received"
|
||||
# echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
#
|
||||
# Exit 4: local target session exists on multiple socket servers and no
|
||||
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||
# -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
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
# Default: local hostname, or (remote) resolved via one ssh.
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -C CLASS triage class for a comms daemon. One of:
|
||||
# terminal-log log-only; never needs the agent's attention
|
||||
# actionable carries a decision/blocker/gate — deliver
|
||||
# human from a human operator — deliver
|
||||
# reaction an emoji/ack reaction
|
||||
# digest machine-wake, coalescible; batched wake/heartbeat signal
|
||||
# Long form: --class CLASS (or --class=CLASS). When SET, the
|
||||
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
|
||||
# [<src> -> <dst> class=terminal-log] <message>
|
||||
# When OMITTED, NO token is emitted and the preamble is
|
||||
# byte-for-byte identical to the classic format. Consumers MUST
|
||||
# treat an absent class as 'actionable' (fail-safe: agent sees it).
|
||||
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||
# -r N Enter-flush attempts passed through (default 2)
|
||||
# -v verbose: print pane tail after delivery
|
||||
# -h help
|
||||
#
|
||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
||||
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction|digest))?\] (.*)$
|
||||
# group 1 = src label group 2 = dst host:session
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
# Sender is overridable via env purely for testing (inject a capture stub). The
|
||||
# default is the canonical send-message.sh beside this script; production callers
|
||||
# never set AGENT_SEND_SENDER, so behavior is unchanged.
|
||||
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
|
||||
|
||||
# Translate the long option --class[=value] into "-C value" so getopts (which is
|
||||
# short-option-only) can parse it. Every other argument passes through untouched,
|
||||
# so callers that never use --class hit the exact original getopts path.
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
|
||||
args+=(-C "$2"); shift 2 ;;
|
||||
--class=*) args+=(-C "${1#*=}"); shift ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- ${args[@]+"${args[@]}"}
|
||||
|
||||
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
|
||||
SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
|
||||
usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
||||
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
||||
C) CLASS=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||
|
||||
# Validate the triage class only when one was given. An absent class emits NO
|
||||
# token (preamble byte-identical to the classic format); the consumer defaults
|
||||
# absent => actionable.
|
||||
CLASS_TOKEN=""
|
||||
if [ -n "$CLASS" ]; then
|
||||
case "$CLASS" in
|
||||
terminal-log|actionable|human|reaction|digest) CLASS_TOKEN=" class=${CLASS}" ;;
|
||||
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction, digest)" >&2; exit 3 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Message body from -f / -m / stdin.
|
||||
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||
if [ -z "$src_sess" ]; then
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
# Inside tmux: display-message resolves against this client's own session.
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
else
|
||||
# Outside tmux with no name: display-message reports the LAST-ACTIVE
|
||||
# session — someone else's identity (measured 2026-08-20: a nameless
|
||||
# non-tmux sender was stamped "peggy", a live seat, forged silently).
|
||||
# Stamp an explicit unverified label instead; deliberate senders use -S.
|
||||
src_sess="unverified"
|
||||
fi
|
||||
fi
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
# Destination host label for the preamble.
|
||||
if [ -z "$DST_HOST" ]; then
|
||||
if [ -n "$SSH_TARGET" ]; then
|
||||
DST_HOST=$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$SSH_TARGET" 'hostname -s' 2>/dev/null || echo "${SSH_TARGET#*@}")
|
||||
else
|
||||
DST_HOST=$(hostname -s 2>/dev/null || echo "local")
|
||||
fi
|
||||
fi
|
||||
|
||||
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
|
||||
FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
|
||||
# Exact session matching for the sender target (codex PR #1466): without
|
||||
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||
# socket discovery so the discovery probes use the same target semantics
|
||||
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||
DST_TARGET="$DST_SESSION"
|
||||
case "$DST_SESSION" in
|
||||
=*) ;;
|
||||
*:*)
|
||||
# Compound target (session:win.pane): pin the SESSION component exact
|
||||
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||
;;
|
||||
*) DST_TARGET="=$DST_SESSION" ;;
|
||||
esac
|
||||
|
||||
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||
# no explicit or env choice exists and the session name lives on multiple
|
||||
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||
# leak into remote sends, where -L would target a socket on the remote
|
||||
# host (codex PR #1466).
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
hits=""
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||
printf ' %s\n' $hits >&2
|
||||
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||
fi
|
||||
fi
|
||||
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
fi
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
|
||||
#
|
||||
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
|
||||
# base64 payload and prints the FULL message (preamble + body) so we can assert
|
||||
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
|
||||
# the preamble is deterministic across machines.
|
||||
#
|
||||
# Guarantees locked here:
|
||||
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
|
||||
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
|
||||
# 3. --class=<c> (equals form) parses identically to the space form.
|
||||
# 4. -C <c> short form parses identically.
|
||||
# 5. invalid class => exit 3, nothing sent.
|
||||
# 6. --class with no value => exit 3.
|
||||
# 7. the documented consumer regex parses producer output for every class.
|
||||
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
|
||||
# 9. sender fallback queries local tmux, never the destination -L socket.
|
||||
# 10. an undeterminable sender is stamped as "?".
|
||||
# 11. --class digest is accepted (machine-wake, coalescible canon class).
|
||||
# 12. -C digest short form parses identically.
|
||||
# 13. the documented consumer regex parses producer output for class=digest.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TOOL="$HERE/agent-send.sh"
|
||||
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
b64=""
|
||||
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
printf '%s' "$b64" | base64 -d
|
||||
STUB_EOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
# Fake tmux distinguishes the sender's default socket from a destination socket.
|
||||
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
case "${FAKE_TMUX_MODE:-sessions}" in
|
||||
unavailable) exit 1 ;;
|
||||
sessions)
|
||||
if [ "${1:-}" = "-L" ]; then
|
||||
printf '%s\n' 'destination-holder'
|
||||
else
|
||||
printf '%s\n' 'local-agent'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
TMUX_EOF
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux.
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
|
||||
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
# Documented consumer grammar — the daemon will mirror exactly this.
|
||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction|digest)\] (.*)$'
|
||||
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
|
||||
|
||||
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
|
||||
got=$(run -s mos -m "hello world")
|
||||
want='[a:src -> dsthost:mos] hello world'
|
||||
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|
||||
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
|
||||
|
||||
# 2. --class space form emits the token.
|
||||
got=$(run -s mos --class terminal-log -m "ACK")
|
||||
want='[a:src -> dsthost:mos class=terminal-log] ACK'
|
||||
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|
||||
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
|
||||
|
||||
# 3. --class=value equals form.
|
||||
got=$(run -s mos --class=actionable -m "decide X")
|
||||
want='[a:src -> dsthost:mos class=actionable] decide X'
|
||||
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|
||||
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 4. -C short form.
|
||||
got=$(run -s mos -C human -m "from a person")
|
||||
want='[a:src -> dsthost:mos class=human] from a person'
|
||||
[ "$got" = "$want" ] && ok "-C human (short form)" \
|
||||
|| no "-C human (short form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 5. invalid class => exit 3, no send.
|
||||
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
|
||||
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|
||||
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
|
||||
fi
|
||||
|
||||
# 6. --class with no value => exit 3.
|
||||
if run -s mos -m "x" --class 2>/dev/null; then
|
||||
no "--class with no value rejected" "expected non-zero exit, got 0"
|
||||
else
|
||||
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
|
||||
fi
|
||||
|
||||
# 11. --class digest (space form) is accepted.
|
||||
got=$(run -s mos --class digest -m "wake payload")
|
||||
want='[a:src -> dsthost:mos class=digest] wake payload'
|
||||
if [ "$got" = "$want" ]; then ok "--class digest emits token"
|
||||
else no "--class digest emits token" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 12. -C digest short form.
|
||||
got=$(run -s mos -C digest -m "coalesced wake")
|
||||
want='[a:src -> dsthost:mos class=digest] coalesced wake'
|
||||
if [ "$got" = "$want" ]; then ok "-C digest (short form)"
|
||||
else no "-C digest (short form)" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 7. consumer grammar parses every class + classic line.
|
||||
for c in terminal-log actionable human reaction digest; do
|
||||
line=$(run -s mos --class "$c" -m "body $c")
|
||||
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
|
||||
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
|
||||
done
|
||||
classic=$(run -s mos -m "plain body")
|
||||
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
|
||||
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
|
||||
|
||||
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
|
||||
src_host=$(hostname -s)
|
||||
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
|
||||
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|
||||
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|
||||
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
|
||||
|
||||
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
|
||||
# Sender is INSIDE tmux: the only context where display-message self-lookup
|
||||
# is safe (it resolves against this client's own session).
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
|
||||
want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|
||||
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
|
||||
[[ "$got" != *":destination-holder ->"* ]] \
|
||||
&& ok "cross-socket fallback rejects destination holder identity" \
|
||||
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
|
||||
|
||||
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
|
||||
# someone else's identity (forgery vector). The label must be `unverified`,
|
||||
# never a borrowed name, even though a tmux server exists here and the fake
|
||||
# would confidently answer `local-agent`.
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
|
||||
want="[$src_host:unverified -> dsthost:mos] no tmux context"
|
||||
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|
||||
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
|
||||
|
||||
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
|
||||
want="[$src_host:? -> dsthost:mos] unknown fallback"
|
||||
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|
||||
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel
|
||||
# messages but leave them as unsubmitted prompt drafts. Intended for Mos only.
|
||||
set -uo pipefail
|
||||
|
||||
TARGET="${1:-mos-claude}"
|
||||
INTERVAL="${INTERVAL:-2}"
|
||||
STABLE_SECONDS="${STABLE_SECONDS:-4}"
|
||||
LOG_PREFIX="[auto-submit-drafts:$TARGET]"
|
||||
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
|
||||
prompt_text() {
|
||||
tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c '
|
||||
import sys, re
|
||||
lines = sys.stdin.read().splitlines()
|
||||
idx = None
|
||||
for i in range(len(lines)-1, -1, -1):
|
||||
if "❯" in lines[i]:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
raise SystemExit
|
||||
parts = []
|
||||
after = lines[idx].split("❯", 1)[1]
|
||||
parts.append(after)
|
||||
for line in lines[idx+1:]:
|
||||
# Stop at Claude Code separator/border lines.
|
||||
if "─" in line or "╰" in line or "╭" in line:
|
||||
break
|
||||
s = line.replace("\u00a0", " ")
|
||||
s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
|
||||
if s:
|
||||
parts.append(s)
|
||||
text = " ".join(parts).replace("\u00a0", " ")
|
||||
text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip()
|
||||
print(text)
|
||||
'
|
||||
}
|
||||
|
||||
while true; do
|
||||
if ! tmux has-session -t "$TARGET" 2>/dev/null; then
|
||||
echo "$LOG_PREFIX target missing; waiting" >&2
|
||||
sleep "$INTERVAL"
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
continue
|
||||
fi
|
||||
|
||||
current="$(prompt_text || true)"
|
||||
now="$(date +%s)"
|
||||
|
||||
if [[ -z "$current" ]]; then
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
sleep "$INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$current" != "$last_prompt" ]]; then
|
||||
last_prompt="$current"
|
||||
first_seen="$now"
|
||||
sleep "$INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
age=$(( now - first_seen ))
|
||||
if (( age >= STABLE_SECONDS )); then
|
||||
echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2
|
||||
tmux send-keys -t "$TARGET" C-j
|
||||
sleep 0.8
|
||||
tmux send-keys -t "$TARGET" C-m
|
||||
sleep 2
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
else
|
||||
sleep "$INTERVAL"
|
||||
fi
|
||||
done
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# send-message.sh — reliably deliver a message to a tmux pane running an
|
||||
# interactive REPL (e.g. a Claude Code / Codex agent).
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# Pasting multi-line text into an interactive agent REPL via `tmux send-keys`
|
||||
# is unreliable: the text lands in the input box but a single trailing Enter
|
||||
# in the same keystroke stream is frequently swallowed, so the message sits as
|
||||
# an UNSUBMITTED DRAFT ("Press up to edit queued messages") and the agent never
|
||||
# sees it. The mechanical fix is: paste as a bracketed paste (so embedded
|
||||
# newlines don't submit early), pause, then send Enter as its OWN keystroke,
|
||||
# pause, and send Enter again to flush. An extra Enter on an empty prompt is a
|
||||
# no-op in Claude Code, so the double-Enter is safe.
|
||||
#
|
||||
# USAGE
|
||||
# send-message.sh [-L socket_name] -t <target> -m "message"
|
||||
# send-message.sh [-L socket_name] -t <target> -f <file>
|
||||
# echo "message" | send-message.sh [-L socket_name] -t <target>
|
||||
# ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
|
||||
# -t TARGET tmux target: session, or session:window.pane [required]
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -b BASE64 message as base64 (ssh-safe transport; decoded internally)
|
||||
# -r N Enter-flush attempts (default 2)
|
||||
# -v verbose: print a short tail of the pane after delivery
|
||||
# -h help
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 delivered (submitted) or queued (agent busy; will process when free)
|
||||
# 1 tmux target not found
|
||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||
# input box could not be located to confirm the message actually landed.
|
||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||
# add a shape there before pointing this tool at a new runtime.
|
||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||
# see the input box clear of the message (or the queued banner), we fail loud
|
||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||
# 3 usage error
|
||||
set -uo pipefail
|
||||
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:t:m:f:b:r:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || { echo "ERROR: -t TARGET is required" >&2; usage 3; }
|
||||
if [ -n "$B64" ]; then MSG=$(printf '%s' "$B64" | base64 -d) || { echo "ERROR: bad -b base64" >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -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.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||||
fi
|
||||
|
||||
QUEUED_RE='Press up to edit queued messages'
|
||||
# A distinctive tail of the message to spot an unsubmitted draft on the input line.
|
||||
snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g' | tail -c 32)
|
||||
|
||||
# 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`.
|
||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||||
# simultaneous restart (briefs swapped between sessions).
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
||||
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||||
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
|
||||
# (e.g. the target vanished since the liveness check), delete explicitly —
|
||||
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
|
||||
# would otherwise accumulate forever.
|
||||
sleep 0.5
|
||||
|
||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||
# is what a submitted message leaves behind), so the caller must branch on the
|
||||
# return code, never on whether the output is empty.
|
||||
#
|
||||
# Two REPL shapes are recognised:
|
||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||
# readline REPLs.
|
||||
# * a box drawn as two horizontal `─` rules with the input between them and NO
|
||||
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
|
||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||
# drawn below the input box except the status line.
|
||||
#
|
||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||
# degrade gracefully: it turns every send to that runtime into a false
|
||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||
# arm of the same probe.
|
||||
locate_input_box() {
|
||||
local pane=$1 glyph_line rule_lines top bottom
|
||||
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
|
||||
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
|
||||
[ -n "$rule_lines" ] || return 1
|
||||
# Split the (at most two) captured line numbers with parameter expansion. Not
|
||||
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
|
||||
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
|
||||
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
|
||||
# With one rule captured both halves resolve to the same value and the
|
||||
# ordering test below rejects it, which is the answer we want anyway.
|
||||
top=${rule_lines%%$'\n'*}
|
||||
bottom=${rule_lines##*$'\n'}
|
||||
[ "$top" != "$bottom" ] || return 1
|
||||
[ "$bottom" -gt "$top" ] || return 1
|
||||
# An empty range (adjacent rules) prints nothing and still returns 0: found,
|
||||
# empty, which is the delivered shape.
|
||||
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
||||
# REPL input box located AND clear of our message tail. The historical bug was
|
||||
# treating ABSENCE of a draft as delivery: if the input box was never located
|
||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
||||
status="unconfirmed"
|
||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||
sleep 1.2
|
||||
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
||||
|
||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||
status="queued"; break
|
||||
fi
|
||||
# If we cannot see the input box, we have NO evidence of submission state —
|
||||
# stay UNCONFIRMED and retry; never infer delivery.
|
||||
if ! inputbox=$(locate_input_box "$pane"); then
|
||||
status="unconfirmed"; continue
|
||||
fi
|
||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
||||
# (Submitted messages scroll up into history; a draft stays in the box.)
|
||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||
status="draft"; continue
|
||||
fi
|
||||
# Input box located AND clear of our tail => positively submitted. This is the
|
||||
# only path to success besides the queued banner.
|
||||
status="delivered"; break
|
||||
done
|
||||
|
||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||
|
||||
case "$status" in
|
||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-agent-send-socket-live.sh — S2 v2 INDEPENDENT contract validation (P5).
|
||||
#
|
||||
# Author: code-be-02 (fresh-seat; derives from the DOCUMENTED CONTRACT of PR
|
||||
# #1466's socket resolution, deliberately not from test-send-message-socket.sh's
|
||||
# structure — marcie's arms cover the implementation, these cover the contract).
|
||||
#
|
||||
# LIVE tmux fixtures on PRIVATE scratch sockets under a scratch TMUX_TMPDIR:
|
||||
# the discovery loop reads ${TMUX_TMPDIR:-/tmp}/tmux-UID, so pointing
|
||||
# TMUX_TMPDIR at a scratch dir makes production sockets (mosaic-fleet included)
|
||||
# invisible to the tested process. Live tmux semantics ('=' targets, prefix
|
||||
# matching, socket dirs) are exercised for real.
|
||||
#
|
||||
# Contract under test (agent-send.sh, canonical usage/EXIT CODES sections):
|
||||
# C1 explicit -L wins over MOSAIC_TMUX_SOCKET; when pinned, discovery is
|
||||
# skipped ENTIRELY (zero has-session probes, not merely zero hits)
|
||||
# C2 MOSAIC_TMUX_SOCKET applies when no -L (local sends only)
|
||||
# C3 session on multiple sockets with no -L/env -> refusal rc 4, message
|
||||
# names the conflicting sockets and the -L hint; nothing sent
|
||||
# C4 socket discovery reads TMUX_TMPDIR (never plain TMPDIR)
|
||||
# C5 no unique hit -> default socket (sender invoked with no -L);
|
||||
# remote (-H) sends do NO local discovery and do not forward the env
|
||||
# C6 '=name' targets match exactly (no prefix); explicit '=X' passes
|
||||
# through verbatim; compound 'sess:win.pane' pins the session component
|
||||
# exact ('=sess:win.pane')
|
||||
#
|
||||
# Seams: AGENT_SEND_SENDER (intended stub seam) captures the sender args;
|
||||
# a PATH-front tmux wrapper logs probes then execs the real binary; a PATH
|
||||
# ssh stub captures the remote command line. Sabotage controls prove the
|
||||
# arms bind: moved env-default -> C2 red; dropped exit-4 -> C3 red.
|
||||
# Skip rc 77 without a tmux binary. Scratch servers killed via trap.
|
||||
set -uo pipefail
|
||||
|
||||
# NOTE: running a COPY of this suite from another directory resolves TOOL next
|
||||
# to the COPY (readlink -f) — agent-send.sh must sit beside it, or set
|
||||
# AGENT_SEND_TOOL_OVERRIDE. Debugging artifact of the here-relative design.
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
TOOL="${AGENT_SEND_TOOL_OVERRIDE:-$HERE/agent-send.sh}"
|
||||
REAL_TMUX="$(command -v tmux 2>/dev/null || true)"
|
||||
[ -n "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; }
|
||||
|
||||
SCRATCH="$(mktemp -d)"; SCRATCH="$(cd "$SCRATCH" && pwd)" # absolute (marcie input b)
|
||||
DECOY="$(mktemp -d)"; DECOY="$(cd "$DECOY" && pwd)"
|
||||
mkdir -p "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
# tmux refuses socket dirs with group/other bits ('unsafe permissions'):
|
||||
# mktemp -d is 0700 but mkdir'd children default to umask (0755) — pin 0700
|
||||
chmod 700 "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
BIN="$SCRATCH/bin"; mkdir -p "$BIN"
|
||||
CAP="$SCRATCH/sender-captured"; PROBES="$SCRATCH/tmux-probes"; SSHLOG="$SCRATCH/ssh-captured"
|
||||
: > "$PROBES"
|
||||
|
||||
# sender stub: capture args, "send" nothing (socket/target choice is the test)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" > %s\nexit 0\n' "$CAP" > "$BIN/sender-stub"
|
||||
# tmux wrapper: log invocations, exec the real binary (live semantics)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %s\nexec %s "$@"\n' "$PROBES" "$REAL_TMUX" > "$BIN/tmux"
|
||||
# ssh stub: capture the remote command line; swallow stdin (the sender script)
|
||||
printf '#!/usr/bin/env bash\nprintf "SSH:%%s\\n" "$*" >> %s\ncat > /dev/null\nexit 0\n' "$SSHLOG" > "$BIN/ssh"
|
||||
chmod +x "$BIN/sender-stub" "$BIN/tmux" "$BIN/ssh"
|
||||
|
||||
sock_pid_a=""; sock_pid_b=""
|
||||
cleanup() {
|
||||
[ -n "$sock_pid_a" ] && kill "$sock_pid_a" 2>/dev/null
|
||||
for s in sockA sockB sockX decoyD; do
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null
|
||||
TMUX_TMPDIR="$DECOY" "$REAL_TMUX" -L "decoyD" kill-server 2>/dev/null
|
||||
done
|
||||
rm -rf "$SCRATCH" "$DECOY"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mk_server() { # $1 socket, $2 session-name, $3 dir (SCRATCH|DECOY)
|
||||
TMUX_TMPDIR="${3:?}" "$REAL_TMUX" -L "$1" new-session -d -s "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
run() { # passes through; caller sets env per arm
|
||||
PATH="$BIN:$PATH" AGENT_SEND_SENDER="$BIN/sender-stub" MOSAIC_AGENT_NAME=code-be-02 \
|
||||
bash "$TOOL" -S test:src "$@"
|
||||
}
|
||||
|
||||
probe_count() { grep -c "has-session" "$PROBES" || true; }
|
||||
cap_has() { grep -qF -e "$1" "$CAP" 2>/dev/null; }
|
||||
|
||||
fail=0
|
||||
ck() { if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; }
|
||||
probes_reset() { : > "$PROBES"; }
|
||||
cap_reset() { rm -f "$CAP"; }
|
||||
|
||||
# --- fixtures: sockA=t1, sockB=t1 (same name, two sockets), sockX=t1 ------------
|
||||
mk_server sockA t1 "$SCRATCH"
|
||||
mk_server sockB t1 "$SCRATCH"
|
||||
mk_server sockX t1 "$SCRATCH"
|
||||
|
||||
# --- C1: explicit -L beats env; discovery skipped entirely -----------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -L sockX -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L sockX" && ! grep -qF -- "-L envsock" "$CAP"
|
||||
ck "C1: explicit -L wins over MOSAIC_TMUX_SOCKET (sender got -L sockX, not envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C1: pinned -L skips discovery ENTIRELY (0 has-session probes, not 0 hits)" $?
|
||||
|
||||
# --- C2: env applies when no -L; discovery skipped -------------------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L envsock"
|
||||
ck "C2: MOSAIC_TMUX_SOCKET used when no -L (sender got -L envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C2: env pin skips discovery (0 probes)" $?
|
||||
|
||||
# --- C3: multi-socket ambiguity refuses rc 4, names sockets, sends nothing -------
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
err="$(TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 4 ]
|
||||
ck "C3: ambiguous session (no -L/env) refuses with rc 4 (contract-stable)" $?
|
||||
echo "$err" | grep -q "multiple sockets" && echo "$err" | grep -qF "sockA" && echo "$err" | grep -qF "sockB"
|
||||
ck "C3: refusal message names BOTH conflicting sockets (sockA, sockB)" $?
|
||||
echo "$err" | grep -qF -- "-L"
|
||||
ck "C3: refusal message carries the -L disambiguation hint" $?
|
||||
[ ! -f "$CAP" ]
|
||||
ck "C3: nothing sent on refusal (sender never invoked)" $?
|
||||
|
||||
# --- C4: discovery reads TMUX_TMPDIR, never plain TMPDIR -------------------------
|
||||
# decoy server lives under $DECOY/tmux-UID; TMPDIR points there, TMUX_TMPDIR at $SCRATCH
|
||||
mk_server decoyD onlydecoy "$DECOY"
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s onlydecoy -m hi >/dev/null 2>&1
|
||||
! cap_has "-L decoyD"
|
||||
ck "C4: a TMPDIR-only socket is NOT consulted (no -L decoyD despite TMPDIR=decoy)" $?
|
||||
# and a session unique in the TMUX_TMPDIR tree IS discovered there
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -ge 2 ]
|
||||
ck "C4: TMUX_TMPDIR tree probed when unpinned (discovery active; ambiguous name exercises the probe loop)" $?
|
||||
|
||||
# --- C5: no unique hit -> default socket; remote sends: no local resolution ------
|
||||
# kill sockA/sockB/sockX so the scratch tree holds only decoy-free empties
|
||||
for s in sockA sockB sockX; do TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null; done
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C5: zero unique hit -> default socket (sender invoked with NO -L)" $?
|
||||
cap_reset; probes_reset
|
||||
rm -f "$SSHLOG"
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -H user@fakehost -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C5: remote send does NO local discovery (0 probes with -H)" $?
|
||||
[ -f "$SSHLOG" ] && ! grep -qF -- "-L envsock" "$SSHLOG"
|
||||
ck "C5: MOSAIC_TMUX_SOCKET not forwarded to remote (ssh line carries no -L envsock)" $?
|
||||
|
||||
# --- C6: '=name' exact matching; verbatim '=X'; compound pinning -----------------
|
||||
mk_server sockA t1old "$SCRATCH" # ONLY t1old exists now
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C6: t1 does NOT prefix-match t1old ('=t1' probe exact; zero hit -> default)" $?
|
||||
grep -qF 'has-session -t =t1' "$PROBES"
|
||||
ck "C6: discovery probes used the exact ('=t1') target form" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s =t1old -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old' "$CAP"
|
||||
ck "C6: already-exact '=X' input passes through verbatim" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s t1old:0.0 -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old:0.0' "$CAP"
|
||||
ck "C6: compound 'sess:win.pane' pins the session component exact (=sess:0.0)" $?
|
||||
|
||||
# --- red controls: the arms bind --------------------------------------------------
|
||||
SAB="$SCRATCH/agent-send-sabotaged.sh"
|
||||
# (a) move the env-default AFTER discovery: C2 must go red
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
envblk = '''if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
'''
|
||||
assert s.count(envblk) == 1
|
||||
s2 = s.replace(envblk, "")
|
||||
anchor = 'socket_args=()'
|
||||
assert s.count(anchor) == 1
|
||||
s2 = s2.replace(anchor, envblk + anchor)
|
||||
assert s2 != s
|
||||
open(dst, "w").write(s2)
|
||||
PY
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if cap_has "-L envsock"; then ck "red-a: sabotaged precedence (env moved after discovery) is CAUGHT by C2 shape" 0; else ck "red-a: sabotaged precedence CAUGHT (envsock lost -> discovered/default socket used)" 0; fi
|
||||
# control validity: with sabotage, the SABOTAGED tool must NOT pin envsock with 0 probes
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if [ "$(probe_count)" -gt 0 ] || ! cap_has "-L envsock"; then
|
||||
ck "red-a validity: sabotage effective (behavior differs from clean tool)" 0
|
||||
else
|
||||
ck "red-a validity: sabotage was a NO-OP — control invalid" 1
|
||||
fi
|
||||
# (b) drop the exit 4: C3 must go red (send proceeds instead of refusing)
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
old = " exit 4\n"
|
||||
assert s.count(old) == 1
|
||||
s = s.replace(old, " :\n")
|
||||
open(dst, "w").write(s)
|
||||
PY
|
||||
mk_server sockB t1old "$SCRATCH" # second socket carrying the same name -> ambiguity shape
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1; src_rc=$?
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L sockB kill-server 2>/dev/null
|
||||
if [ "$src_rc" -eq 4 ]; then
|
||||
ck "red-b: sabotaged refusal still exits 4 — sabotage was a NO-OP, control invalid" 1
|
||||
else
|
||||
ck "red-b: sabotage effective (exit 4 dropped; rc=$src_rc) — C3 pins what the clean tool restores" 0
|
||||
fi
|
||||
|
||||
# --- verdict -----------------------------------------------------------------------
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "agent-send socket contract (live): all arms OK (C1-C6 + both red controls)"
|
||||
exit 0
|
||||
fi
|
||||
echo "agent-send socket contract (live): FAILURES above"
|
||||
exit 1
|
||||
Executable
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live tmux semantics on private sockets only. A caller may run this suite from
|
||||
# inside mosaic-fleet, where inherited TMUX otherwise overrides TMUX_TMPDIR for
|
||||
# every bare tmux command. Clear pane context and keep both the named and
|
||||
# default fixtures below one scratch TMUX_TMPDIR.
|
||||
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)
|
||||
TEST_TMUX_TMPDIR="$TMPDIR/tmux"
|
||||
mkdir -p "$TEST_TMUX_TMPDIR"
|
||||
chmod 700 "$TEST_TMUX_TMPDIR"
|
||||
unset TMUX TMUX_PANE
|
||||
export TMUX_TMPDIR="$TEST_TMUX_TMPDIR"
|
||||
ART_OUT=$(mktemp)
|
||||
AMB_OUT=$(mktemp)
|
||||
AMB_ERR=$(mktemp)
|
||||
A2_OUT=$(mktemp)
|
||||
A2_ERR=$(mktemp)
|
||||
UNIQ_OUT=$(mktemp)
|
||||
UNIQ_ERR=$(mktemp)
|
||||
TWIN="twin-$RANDOM-$$"
|
||||
cleanup() {
|
||||
local test_rc=$? residue=0
|
||||
trap - EXIT
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default kill-server >/dev/null 2>&1 || true
|
||||
sleep 0.2
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: named scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: default scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
rm -rf "$TMPDIR" "$ART_OUT" "$AMB_OUT" "$AMB_ERR" "$A2_OUT" "$A2_ERR" "$UNIQ_OUT" "$UNIQ_ERR"
|
||||
if [ "$test_rc" -ne 0 ]; then
|
||||
exit "$test_rc"
|
||||
fi
|
||||
exit "$residue"
|
||||
}
|
||||
trap cleanup 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" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "named socket hello" <<<"$default_pane"; 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
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||
grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "agent socket hello" <<<"$default_pane"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
||||
CONC_N=5
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
done
|
||||
pids=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
||||
done
|
||||
sleep 0.2
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||
grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|
||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||
for j in $(seq 1 "$CONC_N"); do
|
||||
[ "$j" = "$i" ] && continue
|
||||
if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
|
||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||
|
||||
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
amb_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
a2_rc=0
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||
sleep 0.2
|
||||
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||
fi
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||
# and deliver there.
|
||||
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||
# #1466): a session named TWIN-old must not count as a hit for target
|
||||
# TWIN (tmux target syntax prefix-matches without '=').
|
||||
PSEUDO="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A3_ERR=$(mktemp)
|
||||
a3_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||
# (rc 1) instead of delivering into the prefix-named session.
|
||||
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null | grep -qF "prefix trap"; then
|
||||
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||
rm -f "$A3_ERR"
|
||||
|
||||
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||
PSEUDO2="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A4_ERR=$(mktemp)
|
||||
a4_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null | grep -qF "compound trap"; then
|
||||
fail "compound delivery landed in the prefix-named session"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||
rm -f "$A4_ERR"
|
||||
|
||||
uniq_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||
sleep 0.2
|
||||
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||
fail "auto-resolution cross-delivered to the default socket pane"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-send-message-verdict.sh — locks the fail-loud verdict logic of the patched
|
||||
# send-message.sh against three real tmux-pane fixtures on a throwaway socket:
|
||||
#
|
||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
||||
# "could not confirm submission").
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
|
||||
# the glyph probe could not see this box at all, so EVERY send
|
||||
# to such a pane reported "may be UNDELIVERED" while landing.
|
||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||
# also reported unconfirmed, so the true state was invisible.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND="$HERE/send-message.sh"
|
||||
SOCKET="verdict-test-$RANDOM-$$"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || { echo "tmux required" >&2; exit 1; }
|
||||
|
||||
# --- Fixture 1: a submitting REPL with a ❯ prompt box (interactive bash, glyph PS1).
|
||||
# readline strips bracketed-paste markers just like a real agent REPL; Enter
|
||||
# executes (text -> scrollback), leaving a fresh empty `❯ ` box.
|
||||
tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \
|
||||
'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
||||
else
|
||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 3: a ❯ box that never submits (sleep ignores stdin; TTY echo keeps the
|
||||
# pasted tail sitting on the ❯ line) => draft => exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s draft -c "$TMP" \
|
||||
'printf "❯ "; exec sleep infinity'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=draft" -r 1 -m "verdict fixture three stuck unsubmitted draft" 2>"$TMP/e3"); then
|
||||
no "draft: unsubmitted message must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e3"; then
|
||||
ok "draft: stuck ❯-line message => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: stuck ❯-line message => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e3")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
|
||||
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
|
||||
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
|
||||
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
|
||||
# MODE=keep leaves the text sitting in the box.
|
||||
cat > "$TMP/pibox.sh" <<'PIBOX'
|
||||
#!/usr/bin/env bash
|
||||
MODE=${1:-clear}
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n\n'
|
||||
printf '%s\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
printf 'tok 0 model fixture\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
|
||||
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
|
||||
draw
|
||||
done
|
||||
PIBOX
|
||||
chmod +x "$TMP/pibox.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
|
||||
fi
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
|
||||
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
|
||||
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -1,3 +1,5 @@
|
||||
# POC user
|
||||
|
||||
This is an isolated local runtime test.
|
||||
|
||||
The user's name is Jason.
|
||||
Reference in New Issue
Block a user