Compare commits

...
Author SHA1 Message Date
mvschwarz a41d1091be Release 0.1.12 2026-04-19 10:50:39 -07:00
mvschwarz ca5f36d5b7 test: allow canonical reference docs in guard 2026-04-19 10:50:36 -07:00
Mike Schwarz 74cf893c00 fix: clean transcript redraw and status noise (#10)
* fix: clean transcript redraw and status noise

* fix: avoid transcript cleanup false positives
2026-04-19 10:45:17 -07:00
mvschwarz 37e2d5fda7 daemon: normalize user-clearable startup blockers 2026-04-19 10:39:23 -07:00
mvschwarz 0d7e6eca65 daemon: normalize stale Claude startup state 2026-04-19 01:31:16 -07:00
mvschwarz 233ec8128c demo: switch proof scripts to rig commands 2026-04-19 01:25:56 -07:00
mvschwarz 43a0ace5d0 daemon: reject pod-aware node relaunch via legacy route 2026-04-19 01:22:50 -07:00
mvschwarz 848b0327be cli: extend import instantiate timeout budget 2026-04-19 01:18:54 -07:00
mvschwarz 2500ee028e cleanup stray daemon test database artifact 2026-04-19 01:10:32 -07:00
mvschwarz 105ed7e34d fix Claude MCP startup blockers for managed sessions 2026-04-19 01:10:20 -07:00
mvschwarz c44507fd11 harden restore continuity and recovery guidance 2026-04-19 00:23:45 -07:00
mvschwarz 30cf604d4c daemon: accept codex footer with trailing blank lines 2026-04-18 14:42:44 -07:00
mvschwarz e4619cf13a daemon: recognize resumed codex tui after header scrolls 2026-04-18 14:42:44 -07:00
mvschwarz a8adfe257a daemon: capture codex resume ids from wrapped processes 2026-04-18 14:42:44 -07:00
mvschwarz 8ca8e43753 daemon: ignore stale codex update banner once tui is live 2026-04-18 14:42:44 -07:00
mvschwarz 8b074614dc cli: detect unhealthy tmux control sockets 2026-04-18 13:57:38 -07:00
mvschwarz d10d277ebf Release 0.1.11 2026-04-14 12:40:58 -07:00
mvschwarz 9632c2b9bf fix(cli): use real cmux settings for setup and doctor 2026-04-14 12:40:13 -07:00
mvschwarz b8758ed0ee Fix cmux onboarding detection and setup 2026-04-14 11:21:45 -07:00
mvschwarz fd22057318 Release 0.1.10 2026-04-13 22:53:26 -07:00
mvschwarz 138618637c Document launch walkthrough with annotated screenshots 2026-04-13 22:06:40 -07:00
mvschwarz 4eda5c5ef4 Harden launch onboarding and first-run setup 2026-04-13 16:44:29 -07:00
mvschwarz d8881d867e fix(cli): recover daemon state when state file is missing 2026-04-13 14:26:25 -07:00
mvschwarzandClaude Opus 4.6 697f8e60f8 fix(runtime-adapters): propagate rig-role skip into result contracts
Cycle-1 (3023de8) prevented the CLAUDE.md / AGENTS.md write but
left the adapter reporting the entry as projected (via
ProjectionResult.projected) and the file as delivered (via
StartupDeliveryResult.delivered++). r2 caught: adapter contract
lies — skipped work shows up as completed, violating the
honest-detection constraint the cycle-1 fix was meant to restore.

mergeGuidance now returns boolean (true=merged, false=rig-role skip).
projectEntry propagates the return. project()'s loop routes the
entry into the `skipped` array when mergeGuidance reports skip.
deliverStartup's guidance_merge case `continue`s past the
`delivered++` on skip so counts reflect actual writes.

Same single-chokepoint shape as cycle-1; no new special cases, still
narrow on blockId === 'rig-role'. Architect's three binding
constraints: (1) adapter-layer only (both adapters + tests); (2)
honest detection — this IS the restoration (the point of the patch);
(3) no broader-allow special case — still only rig-role.

Tests updated + added (6 cycle-1 tests + 2 new cycle-2 regression):
- projectEntry rig-role asserts `result.skipped` contains 'rig-role'
  AND `result.projected` does not (each adapter)
- projectEntry non-rig-role asserts `result.projected` contains
  block id AND `result.skipped` does not (each adapter — NEW
  regression-on-contract)
- deliverStartup rig-role asserts `result.delivered === 0` AND
  `result.failed === []` (each adapter — was ===1 in cycle-1)

Gates: vitest 40/41 in claude+codex adapter suites (1 preexisting
unrelated Codex thread-id env fail still present via stash+retest);
tsc --noEmit clean in packages/daemon. New commit on top of 3023de8
per cycle discipline, not amended.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 12:56:23 -07:00
mvschwarzandClaude Opus 4.6 3023de8112 fix(runtime-adapters): skip rig-role managed-block injection
The rig-role managed-block regenerator pairs (target-file x spec)
without seat correlation, so multi-seat pods collide into one
CLAUDE.md/AGENTS.md body. openrig-pm + corpus-pm were behaviorally
affected; openrig-build saw the on-disk symptom only (startup.files
insulated runtime).

SHAPE 1 surgical fix per plat-architect: in claude-code-adapter and
codex-runtime-adapter, refuse mergeManagedBlock when blockId is
`rig-role`. Per-seat role content travels through the send_text
startup path instead, which preserves seat identity. Skip is logged
(structured, never silent) so the collision can't land quietly.

Adapter-layer only (both adapters + their tests). No domain, route,
or UI changes. Narrow check on the single blockId; no broader
special-case.

Tests added (6):
- projectEntry + category=guidance + effectiveId=rig-role does not
  write CLAUDE.md / AGENTS.md (each adapter)
- projectEntry with non-rig-role guidance still merges (regression,
  each adapter)
- deliverStartup guidance_merge for path=rig-role does not write
  (each adapter)

Gates: vitest 38/39 in claude+codex adapter suites (1 preexisting
unrelated Codex thread-id env fail confirmed via stash); tsc
--noEmit clean in packages/daemon.

Shape 2 (config sweep of agent.yaml rig-role references) is a
separate follow-up PR per architect scoping.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 12:43:33 -07:00
mvschwarz bc2c899b45 fix(cmux-transport): reject error text and parse panel rows safely 2026-04-13 11:06:03 -07:00
mvschwarzandClaude Opus 4.6 e74ebbc91b fix(cmux-transport): narrow regex capture for strict TS
parseCmuxCommands fed match[1] (typed string | undefined under
noUncheckedIndexedAccess) into Set.add(string), tripping TS2345.
Guard the capture before adding.

Tests unchanged: cmux-transport 24/24, cmux-adapter 33/33.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 10:30:53 -07:00
mvschwarzandClaude Opus 4.6 e4a03ae48b fix(cmux-transport): track cmux 0.63.x surface with legacy fallback
cmux 0.63.x removed list-surfaces and agent-pids and added
list-panels / list-panes / list-pane-surfaces. The adapter hard-coded
the removed commands, so every surface.list request failed and
queryAgentPIDs misreported state.

Probe `cmux --help` at factory time; dispatch surface.list to
list-panels (modern) with a list-surfaces fallback; normalize the
panels payload to {surfaces} so CmuxAdapter.listSurfaces is unchanged.
workspace.agentPIDs uses legacy agent-pids when present, otherwise
throws CmuxSurfaceError{code:'unavailable', method}, preserving the
existing 'unavailable' vocabulary without a route-layer change.

Adapter-only scope: cmux-transport.ts + its test. No domain/route/UI
changes. cmux-transport.test.ts 24/24 green; cmux-adapter.test.ts
33/33 unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 09:17:14 -07:00
mvschwarzandClaude Opus 4.6 b9e64c004a docs: cli-reference count + setup row (DRIFT-B)
Reflects the 41 program.addCommand() registrations in
packages/cli/src/index.ts:98-138, including setup (setup.ts:383).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 07:27:00 -07:00
da581a9471 feat: PM toolkit — 7 skills, PM agent, and pm-team rig (#4)
* feat: add PM toolkit — 7 skills, PM agent, and pm-team rig

Adds a complete product management workflow as shared skills:

Skills (specs/agents/shared/skills/pm/):
- office-hours: YC-style validation with 6 forcing questions → validation.md
- context-builder: Three-layer context gathering → background.md
- requirements-writer: Conversational intake → requirements.md (GIVEN/WHEN/THEN)
- ui-mockup: ASCII wireframes + HTML mockups + live prototypes
- plan-review: Strategy + design + engineering review before dev
- exec-summary: 1-2 page summary orienting sales, leadership, and engineering
- backlog-capture: Rapid idea capture with deduplication

Agent (specs/agents/product-management/pm/):
- PM agent with startup guidance and 8-step feature flow

Rig (specs/rigs/focused/pm-team/):
- PM lead + researcher + coder topology with delegation edges

The skills chain together: each step's output (validation.md → background.md →
requirements.md → mockups → executive-summary.md) feeds the next step
automatically. Downstream skills read upstream outputs by convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Fix PM toolkit spec validation

---------

Co-authored-by: Matti Kakkori <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Mike Schwarz <[email protected]>
2026-04-12 15:30:17 -07:00
mvschwarz c10b8f8f81 docs: fix rig bundle reference launch semantics 2026-04-11 21:52:34 -07:00
Mike SchwarzandClaude Opus 4.6 81262deb52 Add canonical RigBundle reference documentation
Forensically traced from bundle-types.ts, bundle-archive.ts, and
pod-bundle-assembler.ts. Covers:

- Archive format (.tar.gz with .sha256 sibling digest)
- Manifest schema v2 (pod-aware) with full field reference
- Security model (self-consistency, not authenticity)
- Safety guarantees (no symlinks, no traversal, digest + integrity)
- CLI surface (create, inspect, install, rig up)
- Assembly process (how agent refs are resolved, vendored, rewritten)
- Validation rules for manifest, archive safety, and assembly
- Legacy v1 compatibility note

Validated against a real bundle (specright-pm.rigbundle): manifest
fields, agent entries, integrity section all match documented schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 21:32:25 -07:00
Mike SchwarzandClaude Opus 4.6 fccb65a211 Add optional docs field to RigSpec (#8)
* Add optional docs field to RigSpec for bundled documentation

Add a top-level `docs` array field to the pod-aware RigSpec schema.
Each entry has a `path` field (safe relative path, same validation as
culture_file and startup file paths).

The engine does not consume these files for instantiation — they are
metadata for humans and agents setting up the environment before launch.
The bundle packer includes listed docs in the archive so setup
instructions travel with the distributable artifact.

Schema changes:
- types.ts: add RigSpecDoc interface and docs field to RigSpec
- rigspec-schema.ts: validate docs array with path safety checks
- rigspec-schema.ts: normalize docs into typed array
- rigspec-codec.ts: serialize docs field

Bundle changes:
- pod-bundle-assembler.ts: collect docs files into the bundle archive

Tests:
- rigspec-schema-docs.test.ts: 7 tests covering validation, rejection,
  normalization, and path safety

Reference doc:
- docs/reference/rig-spec.md: document the docs field

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Fix review findings: null guard, missing-docs failure, bundle tests

Addresses all findings from R2 and orch1-peer independent reviews:

1. Guard against null/non-object entries in docs validation — docs: [null]
   and docs: [42] now return clean validation errors instead of crashing
   the validator (R2 #1, peer #1)

2. Bundle assembly fails loudly when a declared docs file is missing from
   disk — prevents silently shipping bundles that advertise docs they
   don't contain (R2 #2, peer #2)

3. Add bundle-level tests proving docs are collected and missing docs
   fail assembly (R2 #3)

Tests: 5 new tests (3 schema null/primitive/string + 2 bundle positive/negative)
Full suite: 128 files, 1722/1722 passing

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Provision runtime config and disclose setup mutations

* Fix: force acceptEdits for managed sessions, don't inherit restrictive modes

deliverStartup unconditionally sets permissions.defaultMode = "acceptEdits"
for managed Claude sessions instead of preserving existing restrictive modes
via nullish coalesce. A pre-existing denyAll or other restrictive mode in
.claude/settings.local.json would previously survive provisioning, causing
managed rigs to hit prompt/block behavior in reused workspaces.

Found by: dev1-qa independent repro
Fix: change ?? "acceptEdits" to unconditional "acceptEdits" assignment
Test: regression test seeds denyAll, verifies deliverStartup overrides it

Full suite: 128 files, 1723/1723 passing

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Fix JSON normalization, disclosure copy, and README accuracy

Addresses CodeRabbit findings verified by QA:

1. readJsonObject now excludes arrays (typeof "object" includes arrays
   in JS). Prevents silent property loss when settings.local.json or
   .mcp.json contains a bare JSON array instead of an object.

2. provisionPermissionsAndMcps refactored to use readJsonObject,
   readJsonObjectField, and readStringArray consistently instead of
   raw JSON.parse + manual type checks. Same helpers used elsewhere
   in the adapter.

3. Setup disclosure for .claude/settings.local.json now mentions
   statusLine mutation alongside permissions (CodeRabbit item 3).

4. README clarifies that managed runtime boot performs config writes
   while rig setup only discloses the paths (CodeRabbit item 6).

5. Regression test: array-valued settings.local.json and .mcp.json
   are recovered to proper objects during provisioning.

Full suite: 128 files, 1724/1724 passing

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Harden setup disclosure tests against copy churn

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 18:42:35 -07:00
Mike Schwarz ae4ebb53a7 Release 0.1.9 2026-04-11 15:16:34 -07:00
Mike Schwarz 93a36c5f57 Preserve cmux context and harden cmux health 2026-04-11 15:10:00 -07:00
Mike Schwarz 0aefd99a0e Sanitize daemon launcher env for cmux 2026-04-11 14:35:58 -07:00
Mike SchwarzandClaude Opus 4.6 db8c54208a Add rig-architect skill as a packaged builtin
- Add rig-architect SKILL.md to shared skills (specs/agents/shared/skills/rig-architect/)
- Register in shared agent.yaml so it's available via the normal skill system
- Skill teaches: design process (intent to topology), authoring workflow,
  agent startup content strategy, diagnosis protocol, pattern catalog
- References ~/.openrig/reference/ docs for canonical spec format details
- Knowledge-transfer style skill, not workflow-compliance

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 14:06:40 -07:00
Mike Schwarz d4088d042e Prepare demo setup runtimes and Claude onboarding 2026-04-11 14:06:24 -07:00
Mike SchwarzandClaude Opus 4.6 48f3c9dd25 Move reference docs to docs/reference/ as canonical source of truth
- Move reference docs from packages/daemon/docs/reference/ to
  docs/reference/ at the repo root — this is the authoring location
- Update .gitignore to track docs/reference/ alongside docs/as-built/
- Update build-package.sh to copy from docs/reference/ into the
  assembled CLI package (not from inside the daemon package)
- Daemon startup still copies the bundled docs to ~/.openrig/reference/
  on start for stable agent-accessible paths

Flow: docs/reference/ (edit here) → build → cli/daemon/docs/reference/
(npm package) → daemon start → ~/.openrig/reference/ (agents read here)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 13:55:43 -07:00
Mike SchwarzandClaude Opus 4.6 b60725bccd Add canonical reference docs and ship them to ~/.openrig/reference/
- Add docs/reference/ inside daemon package: rig-spec.md, agent-spec.md,
  agent-startup-guide.md, edge-types.md — all forensically traced from
  actual parser/validator code, examples validated against rig spec validate
  and rig agent validate
- Daemon startup copies bundled reference docs to ~/.openrig/reference/
  on every start, keeping them in sync with the installed version
- Build script includes daemon/docs/ in the assembled CLI package
- Agents can read reference docs at a stable path (~/.openrig/reference/)
  without needing CLI discovery commands

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 13:49:08 -07:00
Mike SchwarzandClaude Opus 4.6 36f6cc199b docs: update launch onboarding for rig setup
Quick Start leads with rig setup -> rig up demo -> rig ui open.
Post-demo guidance points to orch1.lead and Open CMUX with tmux
attach fallback. Setup vs doctor distinction added with honest
best-effort phase-1 framing. Agent-driven profile-choice guidance
tells agents to ask users about core vs full before invoking.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 11:35:56 -07:00
Mike SchwarzandClaude Opus 4.6 357d490ef3 feat: add doctor-backed verification to rig setup
runSetup now resolves runDoctorChecks() including async checks and
embeds verification.checks in the result using doctor check names
and statuses as-is. ready is derived from both setup step failures
and verification fail statuses — warn/skipped alone do not flip
ready to false. Command handler builds default doctorDeps for
non-dry-run execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 11:30:58 -07:00
Mike SchwarzandClaude Opus 4.6 e71ac524dc feat: add rig setup with phase-1 profile model
Non-interactive rig setup command with core and full profiles. Core
profile: brew check, tmux/cmux install, tmux config managed block,
verification. Full profile extends with jq and gh. --dry-run shows
plan without mutations and exits 0. --json returns structured result
with step ids and statuses. Failure in one step does not crash setup;
later steps are attempted or skipped honestly.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 11:23:07 -07:00
Mike SchwarzandClaude Opus 4.6 0dc01f188e feat: distinguish shell and daemon cmux health in rig doctor
Rename shell cmux check to cmux_shell. Add async cmux_daemon check
that probes the running daemon's /api/adapters/cmux/status. When shell
cmux works but daemon cannot control it, report warn with mismatch
guidance. Daemon not reachable uses honest 'skipped' status. Shell
cmux missing omits cmux_daemon entirely. DoctorCheck status union
extended with 'skipped'.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 11:10:45 -07:00
Mike SchwarzandClaude Opus 4.6 9e9b3e020b fix: sanitize daemon launch env for packaged cmux
Add buildDaemonEnv() that scrubs terminal/GUI/session variables
(GHOSTTY_*, XPC_*, __CF*, TERM_PROGRAM, TERMINFO, CMUX_SOCKET_PATH,
CMUX_WORKSPACE, CMUX_SURFACE_ID) from the daemon process environment.
Preserves HOME, PATH, USER, SHELL, and all non-scrubbed vars. Explicit
OPENRIG_* launch values always override inherited values. startDaemon()
now uses buildDaemonEnv() instead of raw process.env spread.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 10:56:30 -07:00
Mike Schwarz 68eb886041 Fix packaged CLI runtime and demo launch 2026-04-10 08:33:31 -07:00
Mike SchwarzandClaude Opus 4.6 6e0d9f8943 Bump version to 0.1.7
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 02:27:14 -07:00
Mike Schwarz 099edb33e1 Add rig destroy recovery command (#1)
* Add rig destroy recovery command

* Remove ignored plan doc from destroy PR

* Harden rig destroy safety and honesty
2026-04-10 02:23:56 -07:00
Mike Schwarz 39ab0f5508 Guard against tracked docs outside as-built (#3) 2026-04-10 02:13:52 -07:00
Mike Schwarz 9199ed7954 Warn when tmux mouse mode is disabled (#2)
* Warn when tmux mouse mode is disabled

* Improve tmux mouse doctor guidance
2026-04-10 02:12:29 -07:00
mvschwarzandClaude Opus 4.6 b9330f635b Rewrite README for launch
Lead with value prop, screenshot, and quick start instead of sysadmin
content. Add demo rig preview, architecture overview, key concepts,
agent-managed software example, and Managed Agents comparison link.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 00:13:46 -07:00
Mike Schwarz 4fcd9764eb Add cmux recovery and doctor guidance 2026-04-09 23:37:59 -07:00
Mike SchwarzandClaude Opus 4.6 9abb516e6a fix: make cmux system status honest with live probe
GET /api/adapters/cmux/status now calls connect() on every request
to perform a live capabilities probe instead of returning stale
startup-cached state. System panel label updated to 'cmux control'
with honest description reflecting current OpenRig control health.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 22:15:33 -07:00
Mike SchwarzandClaude Opus 4.6 428529d883 feat: expose cmux action for every topology node
Remove cmuxSurface gate on topology toolbar CMUX button — now renders
for all nodes with rigId. Retarget from /focus to /open-cmux. Both
unbound and bound nodes use the open-or-focus route. Flash feedback
preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 22:04:51 -07:00
Mike SchwarzandClaude Opus 4.6 3f7e7e1ce5 feat: wire node detail Open CMUX action to open-or-focus route
Rename node detail cmux button to Open CMUX, retarget from /focus
to /open-cmux route. Button renders for all nodes including unbound.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 21:57:17 -07:00
Mike SchwarzandClaude Opus 4.6 1663286d33 feat: add cmux open-or-focus node route
Add NodeCmuxService with openOrFocusNodeSurface() and POST /open-cmux
route. Three branches: focus existing bound surface, create new terminal
for tmux-backed nodes (attaches into tmux), create honest helper console
for external-cli nodes. All side effects checked honestly — sendText and
focusSurface failures return ok:false. Binding persisted on creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 21:45:34 -07:00
Mike SchwarzandClaude Opus 4.6 496aef5a28 feat: modernize cmux adapter and transport
Update cmux transport to modern CLI shape: focus-panel, send --surface,
current-workspace, new-surface. Adapter normalizes real cmux response
payloads: workspace_id for current workspace, ref-first fallback chain
(created_surface_ref -> created_surface_id -> surface_ref -> surface_id)
for surface creation. Version logic stays in transport layer only.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 21:12:48 -07:00
Mike SchwarzandClaude Opus 4.6 7a29f192d0 Review fix 5: narrow runtime env-health contract honestly
Update services-readiness.ts comments to list shipped consumers
(boot-time gate, on-demand receipt capture) and mark background
monitor and rig ps env summary as deferred. Update architecture.md
with explicit runtime truth model. Update cli-reference.md to
reflect wired --volumes and note rig ps env gap.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 20:13:41 -07:00
Mike SchwarzandClaude Opus 4.6 94ed046c72 Review fix 4: resolve relative local: agent refs for drilldown
Fix resolveMemberAgent in LibraryReview to resolve relative local:
refs against the rig source path instead of only matching the narrow
local:agents/<name> pattern. Handles both simple and deeply nested
relative refs like local:../../../agents/apps/vault-specialist.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 19:54:15 -07:00
Mike SchwarzandClaude Opus 4.6 d6521cb89d Review fix 3: wire rig env down --volumes end-to-end
Add policyOverride option to ServiceOrchestrator.teardown() so callers
can override the persisted down policy. Route threads volumes=true as
policyOverride=down_and_volumes. Existing callers with no opts still
use persisted policy. Dead comment block removed from route.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 19:46:54 -07:00
Mike SchwarzandClaude Opus 4.6 8c1479faf7 Review fix 2: make env route honest about probe refresh failure
Track probe outcome as probeStatus (fresh/stale/no_orchestrator) in
GET /api/rigs/:rigId/env response. Failed probes and null results
return stale with probeError instead of silently serving cached
receipts as fresh truth. No probeStatus when hasServices is false.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 19:39:53 -07:00
Mike SchwarzandClaude Opus 4.6 ef2b03272a Review fix 1: reject service-backed .rigbundle launch honestly
Fail early in the pod-bundle bootstrap path when the rig spec contains
a services block. Service-backed rigs require a stable source directory
and cannot be launched from .rigbundle archives. The rejection stays
inside the try/finally cleanup block so extracted temp dirs are always
cleaned up. Error uses resolve_spec stage with services_unsupported code.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 19:34:50 -07:00
Mike Schwarz 2f4722cd03 docs: refresh as-built architecture and cli reference 2026-04-09 18:54:03 -07:00
Mike Schwarz 8c10e9632e Teach CLI and openrig-user about managed apps 2026-04-09 18:18:13 -07:00
Mike SchwarzandClaude Opus 4.6 971da63cc9 Update lockfile for 0.1.6
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 17:54:37 -07:00
Mike SchwarzandClaude Opus 4.6 778aa94ae9 Bump version to 0.1.6
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 17:48:40 -07:00
Mike Schwarz fa4cd5034c update gitignore 2026-04-09 17:38:25 -07:00
Mike Schwarz 1cefe8e07b Fix vault specialist dogfood regressions 2026-04-09 17:18:53 -07:00
Mike SchwarzandClaude Opus 4.6 e653db411e Add Apache 2.0 license
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 17:08:40 -07:00
Mike SchwarzandClaude Opus 4.6 d6981151ef Vault specialist: add specialist visibility in library review
Add Specialist Agent summary card to LibraryReview for service-backed
rigs, showing canonical identity (vault.specialist). Update test mocks
to canonical topology and summary mentioning specialist. Non-service
rigs explicitly tested to show no specialist card.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:56:36 -07:00
Mike SchwarzandClaude Opus 4.6 672d724dff Vault specialist: swap secrets-manager to vault.specialist topology
Update secrets-manager rig to use canonical managed app convention:
pod id vault, member id specialist, agent_ref pointing to the new
vault-specialist AgentSpec. Summary explicitly mentions the Vault
specialist agent.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:51:50 -07:00
Mike SchwarzandClaude Opus 4.6 1ba4540451 Vault specialist: add agent spec with guidance, startup, and skill
New built-in vault-specialist AgentSpec at agents/apps/vault-specialist/
with Vault-specific role guidance, startup context (dev mode, proof
token, access paths), and vault-user skill (health, API auth, secret
CRUD). Default profile loads shared skills plus vault-user.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:48:36 -07:00
Mike Schwarz 72b1dc7069 Fix managed app env tab dogfood regressions 2026-04-09 15:11:33 -07:00
Mike SchwarzandClaude Opus 4.6 a6c1487ffa Phase A Step 6: runtime env panel with useRigEnv hook
Extend env route to return surfaces from specJson. Add useRigEnv hook
with 10s polling. Add RigEnvPanel component showing overall env state
(Healthy/Degraded/Stopped/Unknown), per-service health, surface links,
View Logs, and Stop Env. Add Env tab to RigDetailPanel between Info
and Chat, shown only for service-backed rigs.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 14:43:26 -07:00
Mike SchwarzandClaude Opus 4.6 d16fadcbb0 Phase A Step 5: env-aware library review with setup prompt
Add environment tab to RigSpecDisplay gated by showEnvironmentTab prop,
only passed from LibraryReview. Tab shows compose services, health gates,
and surfaces. Add Copy Setup Prompt action for service-backed rigs with
buildSetupPrompt helper. Draft/open-file review explicitly excluded via
regression test.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 14:30:39 -07:00
Mike SchwarzandClaude Opus 4.6 4ab7ee68a3 Phase A Step 4: upgrade SpecsPanel with filter chips and richer rows
Add unified library section with All/Apps/Rigs/Agents filter chips.
Use single useSpecLibrary() all-entries query for the merged list.
Richer rows show type badge (APP/RIG/AGENT), stability badge
(Stable/Experimental/Community), and summary. Drafts remain in
their original Rig Specs / Agent Specs sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 14:21:18 -07:00
Mike SchwarzandClaude Opus 4.6 464b810fec Phase A Step 3: extend rig-spec review data with services details
Add RigSpecServicesReview to review output for service-backed rigs.
Domain extracts kind, composeFile, projectName, downPolicy, waitFor,
and surfaces from raw YAML (filesystem-free). Route layer enriches
with composePreview by reading the compose file (best-effort). Wait
target types match the canonical RigServicesWaitTarget contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 14:08:46 -07:00
Mike SchwarzandClaude Opus 4.6 fa867b5ae6 Phase A Step 2: extend library entry metadata with hasServices
Add hasServices?: boolean to SpecLibraryEntry in both daemon and UI.
In classifySpec(), detect services block by parsing raw YAML for rig
entries. Only set when true to keep agent entries clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 13:48:57 -07:00
Mike SchwarzandClaude Opus 4.6 9c16026744 Phase A Step 1: move secrets-manager into indexed built-in library tree
Move secrets-manager spec from specs root into rigs/launch/secrets-manager/
so it is naturally discoverable by the built-in library scanner. Fix
agent_ref relative path for the new depth. Update starter tests to expect
secrets-manager in the indexed rig library scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 13:43:30 -07:00
Mike Schwarz 8eef407062 Fix rig env T07 dogfood regressions 2026-04-09 06:24:33 -07:00
Mike SchwarzandClaude Opus 4.6 70998e8ea8 Phase 5 T06: Secrets Manager proof-of-concept spec
Built-in secrets-manager rig spec backed by HashiCorp Vault in dev mode.
Compose file with health check, wait_for HTTP health target, surfaced
URLs (UI + API) and commands. Receipt-only continuity (no checkpoint
hooks in v1). Proves the generic services substrate without special-case
code.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 05:20:11 -07:00
Mike SchwarzandClaude Opus 4.6 aa4f1659ee Phase 5 T05: env routes, CLI, and startup wiring
Daemon: GET /api/rigs/:rigId/env (status with fresh receipt),
GET /env/logs (service logs), POST /env/down (teardown services).
Routes mounted under existing rig-scoped pattern.

CLI: rig env status/logs/down subcommands with --json support.
No rig env restart (v1 contract).

Startup wires serviceOrchestrator + composeAdapter into AppDeps
and middleware context. 5 CLI tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 05:18:43 -07:00
Mike SchwarzandClaude Opus 4.6 b17c8857c4 Phase 5 T04: snapshot/restore/teardown continuity for services
Snapshot captures envReceipt from persisted services record. Teardown
calls ServiceOrchestrator.teardown() per down_policy (both early-return
and normal paths). SnapshotData gains optional envReceipt field.
Receipt-only honesty: no checkpoint artifacts unless hooks are defined.
6 tests covering snapshot capture, teardown policies, no-services path.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 05:11:55 -07:00
Mike SchwarzandClaude Opus 4.6 0f564a2905 Phase 5 T02+T03: Compose adapter, ServiceOrchestrator, bootstrap/restore service gate
T02: ComposeServicesAdapter (adapters/) for docker compose up/down/
status/logs/probe. ServiceOrchestrator (domain/) as leaf service for
boot/teardown/captureReceipt. services-readiness.ts shared evaluator
for wait targets + derived health. 21 tests.

T03: Bootstrap prelaunch hook in PodRigInstantiator.instantiate() runs
service gate between topology creation and node launch. Bootstrap
orchestrator builds hook via canonical pod-aware codec, persists
services record, boots services. Restore orchestrator boots services
before agent restore. service_boot_failed added to InstantiateOutcome
and RestoreOutcome. Startup wires ServiceOrchestrator into both
orchestrators. 4 integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 05:06:08 -07:00
Mike Schwarz ec47323f5e Add compose services orchestrator 2026-04-09 04:43:11 -07:00
Mike Schwarz 959394a4ca Add rig services schema and persistence 2026-04-09 04:30:08 -07:00
Mike SchwarzandClaude Opus 4.6 76006c2ece Vendor mental-model-ha and wire into orchestrator + implementer
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 04:24:33 -07:00
Mike Schwarz fe0a000d51 Bump version to 0.1.5 2026-04-09 04:09:42 -07:00
Mike Schwarz b56ce63b13 Fix refactor launch timeout and spec scanning 2026-04-09 04:04:05 -07:00
Mike Schwarz 0e4bfaa418 Revert "Standardize canonical session names"
This reverts commit 4b0544a127.
2026-04-09 03:54:48 -07:00
Mike Schwarz 4b0544a127 Standardize canonical session names 2026-04-09 03:31:58 -07:00
Mike Schwarz 7ac72c37d0 Reorganize bundled spec library 2026-04-09 03:31:49 -07:00
Mike Schwarz e6f854a3e6 Bump version to 0.1.4 2026-04-09 03:06:27 -07:00
Mike Schwarz dd0385e310 Expand review team operating manual 2026-04-09 03:05:37 -07:00
Mike SchwarzandClaude Opus 4.6 4771f9adfa Include full skill directories for systematic-debugging and test-driven-development
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 01:55:31 -07:00
Mike SchwarzandClaude Opus 4.6 83df1da3d4 Replace stub skills with real source versions
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 01:54:20 -07:00
Mike SchwarzandClaude Opus 4.6 ebf106100b Vendor skills and wire demo rig for self-contained launch
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 01:29:45 -07:00
257 changed files with 23424 additions and 1344 deletions

No files matched your search

+8 -1
View File
@@ -3,9 +3,12 @@ dist/
.DS_Store
*.log
# All docs are internal except as-built
# All docs are internal except as-built and reference
docs/*
!docs/as-built/
!docs/reference/
notes/*
# Local environment
.env
@@ -16,6 +19,10 @@ docs/*
*.sqlite-wal
*.sqlite.pre-reboot-backup
.openrig/
.claude
.agents
.openrig
.worktrees/
# Local demo proof artifacts
demo/proof/
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Mike Schwarz
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+174
View File
@@ -0,0 +1,174 @@
# OpenRig
Open source multi-agent harness for coding teams.
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
![OpenRig UI](https://openrig.dev/screenshots/remotion/hero-ui-workspace.png)
## Quick Start
```bash
# Install
npm install -g @openrig/cli
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
rig setup
# Boot the demo rig (3 pods, 8 nodes, mixed runtimes)
rig up demo
# Open the UI
rig ui open
```
After the demo boots, open the UI and click **orch1.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
## Launch Walkthrough
The launch path above has been walked end-to-end on fresh macOS VMs. The only thing your agent can't do would be the oauth logins for claude and openai and dealing with permission prompts.
### 1. Open the demo rig
When the UI opens, the explorer is already visible on the left. Click the `demo` rig to load its live topology.
![Step 1: Click the demo rig in the explorer.](assets/ui/screenshots/launch-happy-path/guide-step-01-open-demo.png)
### 2. Click `CMUX` on `orch1.lead`
Once the topology loads, go to the `orch1.lead` node and click its `CMUX` button. That opens the orchestrator terminal directly.
![Step 2: Click CMUX on orch1.lead in the topology.](assets/ui/screenshots/launch-happy-path/guide-step-02-open-cmux.png)
> From here just talk to the orchestrator and tell it what you want it to build...
## What It Does
OpenRig is a multi-agent harness — it manages the system that coding agents form when you run them together. Not the agents themselves, but the team they create: which sessions are running, how they relate, how to recover after a reboot, and how to stop it from becoming terminal sprawl.
- **Define** topologies in YAML (RigSpec) with pods, edges, and continuity policies
- **Boot** everything with `rig up` — tmux sessions, harnesses, startup files, readiness checks
- **See** the topology in a live graph with explorer, node detail, and system log
- **Discover** existing Claude Code and Codex sessions in tmux and adopt them into a managed rig
- **Snapshot** the full topology on `rig down`, restore by name with `rig up <name>`
- **Communicate** across agents with `rig send`, `rig broadcast`, and `rig chatroom`
- **Evolve** running topologies with `rig expand`, `rig shrink`, `rig launch`, `rig remove`
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
## Demo Rig
OpenRig ships with a demo rig you can boot in seconds:
```bash
rig specs preview demo
```
```
demo (rig, pod_aware)
Launch-grade starter: a stable full product squad with two
orchestrators, implementation, QA, design, and two independent
reviewers.
Pod: orch1 (2 members)
lead — claude-code
peer — codex
Pod: dev1 (3 members)
impl — claude-code
qa — codex
design — claude-code
Pod: rev1 (2 members)
r1 — claude-code
r2 — codex
```
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, `product-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
Browse the library: `rig specs ls`
## How It Works
OpenRig is a local daemon + CLI + MCP server + React UI, built on tmux.
```
CLI / UI / MCP
|
Hono HTTP daemon
|
Domain services (52)
|
SQLite + tmux + runtime adapters
```
- **CLI**: 40+ commands designed for both humans and agents. Every mutating command ends with what happened, current state, and next action.
- **UI**: Explorer sidebar, topology graph with pod grouping, node detail panel, system log, chatroom.
- **MCP**: 17 tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
- **Runtimes**: Claude Code, Codex, and terminal nodes. Adapters for Pi and OpenHands in development.
## Key Concepts
- **RigSpec**: Declarative multi-agent harness definition in YAML. Pods, members, edges, continuity policies, culture file.
- **AgentSpec**: Reusable agent blueprint with skills, guidance, hooks, profiles, and startup contracts.
- **Pod**: Bounded context group. Agents in a pod share memory and can maintain each other's context.
- **Discovery**: `rig discover` fingerprints existing tmux sessions. `rig adopt` brings them under management.
- **Snapshot/Restore**: `rig down --snapshot` captures full state. `rig up <name>` restores from latest snapshot. Restore reports per-node outcomes (resumed, fresh, or failed).
- **RigBundle**: Portable archive with vendored AgentSpecs and SHA-256 integrity. Share topologies across machines.
- **Culture**: CULTURE.md sets coordination norms for the group. Research rigs get exploratory culture. Implementation rigs get conservative, trust-but-verify culture.
## Agent-Managed Software
A rig can package actual software alongside the agents that manage it. The shipped example is `secrets-manager`: a HashiCorp Vault instance operated by a specialist agent.
```bash
rig up secrets-manager
rig env status secrets-manager
rig send vault-specialist@secrets-manager "Check Vault health and report status." --verify
```
Requires Docker for service-backed rigs.
## Requirements
- Node.js 20+
- tmux
Optional:
- cmux for `Open CMUX` node surface controls
- Docker for service-backed rigs and managed apps
## Setup and Troubleshooting
- `rig setup` attempts core machine preparation: tmux, cmux, Claude Code, Codex, and tmux defaults. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
- `rig setup --full` attempts a broader operator workstation setup (jq, gh) on top of core.
- `rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes.
Both commands support `--json` for agent-driven workflows.
Managed runtime boot (during `rig up`) may modify runtime config to reduce permission and MCP friction. `rig setup` discloses these paths so agents know what was changed:
- global Claude: `~/.claude/settings.json` for OpenRig command allowlisting
- global Claude state: `~/.claude.json` for managed workspace trust and onboarding completion
- project Claude: `.claude/settings.local.json` for managed-session permissions
- project Claude MCP: `.mcp.json` for OpenRig-managed MCP servers
- global Codex: `~/.codex/config.toml` for workspace trust and MCP servers
Already-running adopted sessions may need restart before they pick up newly written runtime config.
**For agents:** Ask the user whether they want core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) before choosing the invocation. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues.
## Comparison with Claude Managed Agents
Anthropic shipped Claude Managed Agents — a cloud-hosted, Claude-only runtime at $0.08/session-hour. OpenRig is the local side: open source, cross-harness, runs on your machine, costs nothing.
[Full comparison](https://openrig.dev/compare/claude-managed-agents)
## Links
- **Website**: [openrig.dev](https://openrig.dev)
- **Blog**: [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs)
- **Open Specification**: [openrig.dev/specs](https://openrig.dev/specs)
- **Twitter**: [@_feralmachine](https://twitter.com/_feralmachine)
## License
Apache 2.0
@@ -0,0 +1,24 @@
# Launch Happy-Path Screenshot Set
Source capture:
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
- host-forwarded UI: `http://127.0.0.1:17439`
- guest daemon: `192.168.64.9:7433`
Purpose:
- README walkthrough
- website/docs reuse
- launch gate evidence bundle
Annotated README walkthrough:
- `guide-step-01-open-demo.png` — click the `demo` rig in the explorer
- `guide-step-02-open-cmux.png` — click the `CMUX` button on `orch1.lead` in the topology
Archived proof/source bundle:
- raw proof captures and guide source were moved out of the repo to:
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
Notes:
- The README should use the annotated walkthrough files.
- The raw captures are still the fidelity record for proof and future reuse, just not inside the product repo.
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+1 -1
View File
@@ -62,7 +62,7 @@ pods:
files: []
actions:
- type: send_text
value: "rigged status"
value: "rig status"
phase: after_ready
idempotent: true
applies_on: [fresh_start]
+7 -7
View File
@@ -10,11 +10,11 @@ echo ""
# 0. Start or reuse daemon
echo "Step 0: Ensure daemon is running..."
if rigged daemon start; then
if rig daemon start; then
sleep 2
else
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
if rigged ps --json >/dev/null 2>&1; then
if rig ps --json >/dev/null 2>&1; then
echo "Reusing existing healthy daemon."
else
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
@@ -25,7 +25,7 @@ echo ""
# 1. Boot
echo "Step 1: Boot demo topology..."
UP_OUTPUT=$(rigged up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
UP_OUTPUT=$(rig up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
if [ -z "$RIG_ID" ]; then
echo "ERROR: No rig ID found in boot output. Proof failed."
@@ -35,7 +35,7 @@ echo ""
# 2. Node status
echo "Step 2: Node status after boot..."
rigged ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
echo ""
# 3. Health check after boot
@@ -74,7 +74,7 @@ echo "Rig ID: $RIG_ID"
# 7. Tear down
echo ""
echo "Step 7: Tear down..."
DOWN_OUTPUT=$(rigged down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
DOWN_OUTPUT=$(rig down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
SNAPSHOT_ID=$(printf '%s\n' "$DOWN_OUTPUT" | sed -n 's/^Snapshot: //p' | tail -n1)
if [ -z "$SNAPSHOT_ID" ]; then
echo "ERROR: No snapshot ID found in teardown output. Proof failed."
@@ -90,12 +90,12 @@ echo ""
# 9. Restore via explicit snapshot + rig ID
echo "Step 9: Restore via explicit snapshot + rig ID..."
rigged restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
rig restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
echo ""
# 10. Node status after restore
echo "Step 10: Node status after restore..."
rigged ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
echo ""
echo "=== Automated proof artifacts produced ==="
+8 -8
View File
@@ -6,11 +6,11 @@ echo ""
# Start daemon
echo "Starting daemon..."
if rigged daemon start; then
if rig daemon start; then
sleep 2
else
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
if rigged ps --json >/dev/null 2>&1; then
if rig ps --json >/dev/null 2>&1; then
echo "Reusing existing healthy daemon."
else
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
@@ -21,18 +21,18 @@ fi
# Boot the demo topology
echo ""
echo "Booting demo topology..."
UP_OUTPUT=$(rigged up demo/rig.yaml)
UP_OUTPUT=$(rig up demo/rig.yaml)
printf '%s\n' "$UP_OUTPUT"
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
if [ -z "$RIG_ID" ]; then
echo "ERROR: failed to determine rig ID from 'rigged up' output." >&2
echo "ERROR: failed to determine rig ID from 'rig up' output." >&2
exit 1
fi
# Show node status
echo ""
echo "Node status:"
rigged ps --nodes
rig ps --nodes
# Establish a restore-safe baseline for the demo rig.
echo ""
@@ -55,6 +55,6 @@ echo "=== Demo topology is running ==="
echo "Dashboard: http://localhost:5173"
echo ""
echo "Next steps:"
echo " rigged ps --nodes # Check node status"
echo " rigged down $RIG_ID # Tear down (auto-snapshots)"
echo " rigged restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
echo " rig ps --nodes # Check node status"
echo " rig down $RIG_ID # Tear down (auto-snapshots)"
echo " rig restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
+6 -6
View File
@@ -59,12 +59,12 @@ export function runCommand(command: string, args: string[], cwd = repoRoot()): s
}).trim();
}
export function runRigged(args: string[]): string {
return runCommand("rigged", args);
export function runRig(args: string[]): string {
return runCommand("rig", args);
}
export function runRiggedJson<T>(args: string[]): T {
const output = runRigged(args);
export function runRigJson<T>(args: string[]): T {
const output = runRig(args);
return JSON.parse(output) as T;
}
@@ -77,7 +77,7 @@ export function sleep(ms: number): Promise<void> {
}
export function listRigSummaries(): DemoRigSummary[] {
return runRiggedJson<DemoRigSummary[]>(["ps", "--json"]);
return runRigJson<DemoRigSummary[]>(["ps", "--json"]);
}
export function getCurrentRigSummary(rigNameOrId: string): DemoRigSummary | null {
@@ -95,7 +95,7 @@ export function listRigNodes(rigNameOrId: string): DemoNodeEntry[] {
return [];
}
return filterNodesForRigId(
runRiggedJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
runRigJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
summary.rigId
);
}
+1 -1
View File
@@ -59,7 +59,7 @@ export async function probeNodeResume(
const pollMs = opts.pollMs ?? 500;
const maxWaitMs = opts.maxWaitMs ?? 6_000;
const attempts = Math.max(1, Math.floor(maxWaitMs / pollMs));
const sessionName = `rigged-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
const sessionName = `rig-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
try {
runTmux(["new-session", "-d", "-s", sessionName, "-c", resolveNodeCwd(node.cwd ?? ".")]);
+1 -1
View File
@@ -100,7 +100,7 @@ function sendWarmup(sessionName: string | null, text: string): void {
}
execFileSync(
"rigged",
"rig",
["send", sessionName, text, "--verify", "--force", "--json"],
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
);
+243 -80
View File
@@ -1,16 +1,16 @@
# OpenRig — As-Built Architecture
## Final Stretch 1 Snapshot (as of 2026-04-05)
## Current Architecture
Status:
- Four implementation rounds have landed: AgentSpec reboot (16 tasks), North Star (16 tasks), Post-North-Star (11 tasks + dogfood fixes + UI polish), and Final Stretch 1 (spec library, `whoami`, adopted-session parity, specs/discovery drawer workflows, reusable spec display surfaces, live-node full-details route, graph hover/runtime hints).
- Shipped source footprint: `253` source files across the three packages.
- Daemon footprint: `133` source files total, including `79` domain files, `21` route files, `10` adapters, and `17` migrations.
- CLI footprint: `37` source files.
- UI footprint: `83` source files.
- OpenRig currently ships a pod-aware multi-agent runtime, rig-scoped environment management, a filesystem-backed spec library, managed-app browsing/review surfaces, and the canonical `secrets-manager` + `vault.specialist` agent-managed app example.
- Shipped source footprint: `286` source files across the three packages.
- Daemon footprint: `150` source files total, including `90` domain files, `22` route files, `11` adapters, and `20` migrations.
- CLI footprint: `49` source files.
- UI footprint: `87` source files.
- Current full-suite verification during this refresh:
- daemon: `112/112` files, `1461/1461` passing
- CLI: `29/29` files, `275/275` passing
- UI: `35` files, `361/364` passing (`3` known design/foundation failures)
- daemon: `127/127` files, `1668/1668` passing
- CLI: `37/37` files, `366/366` passing
- UI: `37/37` files, `388/388` passing
Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
@@ -18,42 +18,47 @@ Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
## 1. System Overview
OpenRig is a local control plane for multi-agent coding topologies. The system currently has four architectural layers:
OpenRig is a local control plane for multi-agent coding topologies. The system currently has six architectural layers:
1. **AgentSpec / pod-aware core**: spec parsing, resolution, precedence, startup orchestration, snapshot/restore, bundles.
2. **North Star operator layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
3. **Post-North-Star transport/history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
4. **Final Stretch 1 authoring/identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
2. **Operator and topology layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
3. **Communication and history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
4. **Authoring and identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
5. **Rig environment layer**: rig-scoped services records, Compose-backed service orchestration, readiness gates, env snapshot/restore integration, runtime env status/logs/down surfaces.
6. **Agent-managed software layer**: managed-app classification in the library, app-focused browse/review/runtime UI, managed-app-aware CLI/help surfaces, and the canonical `secrets-manager` + `vault.specialist` example.
Legacy flat-node/package flows remain for backward compatibility.
The stack is:
```text
CLI (31 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
CLI (40 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
v
Hono daemon routes (21 route groups + dedicated health/export/static handlers)
Hono daemon routes (22 route groups + dedicated health/export/static handlers)
|
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
+-- env routes (status / logs / down)
+-- transport routes (send/capture/broadcast)
+-- transcript routes (tail/grep)
+-- ask routes (context evidence packs)
+-- chat routes (durable rig messaging + SSE)
+-- spec review/library routes
+-- whoami identity route
+-- spec review/library routes (managed-app enrichment + compose preview)
+-- whoami identity + context-usage route
|
v
Framework-free domain services
|
+-- SQLite state (17 migrations)
+-- SQLite state (20 migrations)
+-- tmux / cmux / resume adapters
+-- runtime adapters (Claude Code / Codex / Terminal)
+-- RigEnv substrate (compose adapter, readiness evaluator, service orchestrator)
+-- transport layer (SessionTransport)
+-- transcript store (pipe-pane backed)
+-- chat repository (SQLite backed)
+-- spec review service
+-- spec library service
+-- spec review service (services metadata + compose preview seam)
+-- spec library service (`hasServices` classification)
+-- context-usage store / monitor
+-- whoami identity service
The core product loop:
@@ -66,7 +71,7 @@ The core product loop:
### HTTP routes
`createApp()` now mounts 21 route groups plus:
`createApp()` now mounts 22 route groups plus:
- `GET /healthz`
- `GET /api/rigs/:rigId/spec`
- `GET /api/rigs/:rigId/spec.json`
@@ -74,7 +79,7 @@ The core product loop:
In addition to the reboot-era routes:
**North Star additions:**
**Operator and topology routes:**
- `GET /api/rigs/:rigId/nodes` — canonical shared node-inventory projection
- `GET /api/rigs/:rigId/nodes/:logicalId` — rich node-detail payload for the drawer
- `POST /api/rigs/:rigId/nodes/:logicalId/launch` — manual node launch
@@ -82,7 +87,7 @@ In addition to the reboot-era routes:
- `POST /api/rigs/:id/up` — existing-rig restore by rig ID
- `POST /api/discovery/draft-rig` — generate candidate rig spec from discovered sessions
**Final Stretch 1 additions:**
**Authoring and identity routes:**
- `POST /api/rigs/import/materialize` — create pod-aware rig topology without launching
- `POST /api/discovery/:id/bind` — bind discovered session to an existing logical node
- `POST /api/discovery/:id/adopt` — UI-friendly composite adopt route (`bind` or `create_and_bind`)
@@ -90,7 +95,12 @@ In addition to the reboot-era routes:
- `/api/specs/library` — list/get/review/sync filesystem-backed library entries
- `/api/whoami` — daemon-backed runtime identity projection by `nodeId` or `sessionName`
**Post-North-Star additions:**
**Rig environment routes:**
- `GET /api/rigs/:rigId/env` — live rig-environment status with best-effort fresh receipt capture
- `GET /api/rigs/:rigId/env/logs` — service logs from the Compose adapter
- `POST /api/rigs/:rigId/env/down` — explicit env teardown for service-backed rigs
**Communication and history routes:**
- `/api/transport` — `POST /send`, `/capture`, `/broadcast` — communication primitives
- `/api/transcripts` — `GET /:session/tail`, `/:session/grep` — transcript access
- `/api/ask` — `POST /` — context evidence pack over rig summary + transcripts + chat
@@ -103,27 +113,39 @@ Important route behaviors:
- `/api/transport/send` classifies target activity before sending and can refuse unless forced
- `/api/ask` returns context/evidence, not LLM-synthesized answers
- `/api/rigs/:rigId/chat` streams via SSE for real-time delivery
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`)
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`), managed-app services metadata, and best-effort compose preview
- `/api/whoami` fails honestly on missing/ambiguous identity instead of fabricating “healthy” state
- `/api/rigs/:rigId/env` returns `{ ok: true, hasServices: false }` honestly when a rig has no service substrate
- `/api/rigs/:rigId/env/down` currently honors stored down policy; explicit volume-removal override is not fully wired through yet
### CLI commands
`index.ts` currently mounts 31 command groups. Reboot-era commands plus:
`index.ts` currently mounts 40 command groups. Reboot-era commands plus:
**North Star additions:**
**Operator and topology commands:**
- `rig up <rig-name>` — existing-rig restore by name (auto-finds latest snapshot)
- `rig down` — auto-snapshots before teardown, handoff includes restore command
- `rig discover --draft` — generate candidate rig spec from discovered sessions
- Changed: `rig ps --nodes` shows per-node detail with session names, status, startup status
**Final Stretch 1 additions:**
**Authoring and identity commands:**
- `rig specs ls/show/preview/add/sync` — agent-facing spec library browse/review workflow
- `rig whoami --json` — daemon-backed runtime identity with peers, edges, transcript helpers, and command hints
- `rig bind <discoveredId> <rigId> <logicalId>` — bind discovered session to an existing node
- `rig adopt <file> --bind logicalId=session` — materialize then bind live sessions into a rig
- Changed: `rig up <source>` and `rig bootstrap <source>` now resolve spec-library names and fail loudly on rig-name vs library-name ambiguity
**Post-North-Star additions (8 new command groups):**
**Rig environment commands:**
- `rig env status <rig>` — inspect rig-scoped environment status for service-backed rigs and managed apps
- `rig env logs <rig> [service]` — fetch service logs
- `rig env down <rig>` — tear down the rig environment
**Managed-app-aware help and copy:**
- `rig up` help/examples explicitly include library-backed managed apps like `secrets-manager`
- `rig specs` help/list/preview text now distinguishes rigs, agents, and managed apps
- `openrig-user` now teaches the shipped managed-app model (`software + specialist`) and cross-rig specialist interaction
**Communication and history commands:**
- `rig send <session> "message" [--verify] [--force]` — send to agent terminal
- `rig capture <session> [--lines N] [--json]` — capture agent pane content
- `rig broadcast --rig <name> "message"` — multi-agent broadcast
@@ -139,7 +161,7 @@ The CLI-hosted MCP server exposes 17 tools:
1-12. Reboot-era: `rigged_up`, `rigged_down`, `rigged_ps`, `rigged_status`, `rigged_snapshot_create`, `rigged_snapshot_list`, `rigged_restore`, `rigged_discover`, `rigged_claim`, `rigged_bundle_inspect`, `rigged_agent_validate`, `rigged_rig_validate`
13-17. Post-North-Star additions:
13-17. Communication and operator additions:
- `rigged_rig_nodes` — node inventory for a rig (agents can look up infrastructure sessions)
- `rigged_send` — send message to agent terminal
- `rigged_capture` — capture agent pane content
@@ -157,13 +179,21 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
- The shared drawer has five primary surfaces:
- **Rig drawer:** identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab
- **Node drawer:** runtime-first identity, peers, edges, transcript helpers, compact spec summary, status (with restore outcome), startup files, recent events, and `Open Full Details`
- **Specs drawer:** library list, draft/spec review routing, and spec authoring entry points
- **Specs drawer:** unified library list with `All / Apps / Rigs / Agents` filters, type/stability badges, draft/spec review routing, and spec authoring entry points
- **Discovery drawer:** placement-first adopt/bind/materialize flow
- **System drawer:** global event/activity surface replacing the old status-bar role
- Pod selection opens the rig drawer with pod section expanded (no separate pod drawer)
- Human-readable IDs are UI-only: pod labels instead of ULIDs in explorer, short ULID tails for glanceability, full IDs in detail views
- Infrastructure/terminal nodes have distinct visual treatment
- Graph shows pod grouping via React Flow group nodes, status colors (green=ready, amber=launching, red=failed, gray=stopped), and lightweight hover/runtime hints
- Service-backed library entries render as `APP` rows instead of generic rigs
- `LibraryReview` for a managed app shows:
- `Library — Managed App`
- `Copy Setup Prompt`
- a `Specialist Agent` card (canonical example: `vault.specialist`)
- a library-only `Environment` tab with compose preview, health gates, and surfaces
- The rig drawer now has `Info`, `Env`, and `Chat Room` tabs for service-backed rigs
- `RigEnvPanel` exposes overall env state, per-service health, health gates, surfaces, logs, and explicit env teardown
- Explicit center-workspace routes now exist for:
- `/specs`
- `/specs/rig`
@@ -175,7 +205,7 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
## 3. Database Schema
The daemon now has 17 migrations.
The daemon now has 20 migrations.
### Core state tables
@@ -199,6 +229,9 @@ The daemon now has 17 migrations.
**bindings**
- Physical surface attachment: tmux/cmux coordinates.
- `019_external_cli_attachment.ts` extends this row shape with:
- `attachment_type`
- `external_session_name`
**sessions**
- Live execution state.
@@ -240,7 +273,7 @@ These remain active:
- `label`
- `summary`
- serialized continuity policy
- Final Stretch 1 made `namespace` first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
- `namespace` is first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
**continuity_state** (`014_agentspec_reboot.ts`)
- Live per-`pod_id` / `node_id` operational continuity state.
@@ -263,11 +296,30 @@ These remain active:
- Indexed by `(rig_id, created_at)` for fast rig-scoped queries
- Note: transcript persistence is filesystem-backed (pipe-pane → log files), not SQLite. Chat is SQLite-backed.
**context_usage** (`018_context_usage.ts`)
- Persisted per-node context usage snapshots for runtime/operator surfaces.
- Used by the daemon-owned context monitor and `whoami`/inventory projections.
**rig_services** (`020_rig_services.ts`)
- Persisted rig-scoped environment record for service-backed rigs.
- Stores:
- `rig_id`
- `kind`
- `spec_json`
- `rig_root`
- resolved `compose_file`
- resolved `project_name`
- `latest_receipt_json`
- timestamps
Migration boundary:
- `014_agentspec_reboot.ts` adds the reboot schema shape.
- `015_startup_context.ts` adds persisted startup replay context.
- `016_chat_messages.ts` adds durable rig chat.
- `017_pod_namespace.ts` backfills/persists authored pod namespace for export, adoption, and identity parity.
- `018_context_usage.ts` adds context-usage persistence.
- `019_external_cli_attachment.ts` extends binding rows for external CLI attachment.
- `020_rig_services.ts` adds rig-scoped services persistence.
---
@@ -284,6 +336,17 @@ Migration boundary:
- Uses `version: "0.2"` and `pods[]`.
- Owns cross-pod `edges[]`, rig-level startup overlays, and `cultureFile`.
**RigServicesSpec**
- Optional `services` block on a pod-aware `RigSpec`.
- Current shipped service kind is Compose-backed env management with:
- `composeFile`
- `projectName?`
- `profiles?`
- `downPolicy?`
- `waitFor?`
- `surfaces?`
- `checkpoints?`
**RigSpecPod**
- Pod-local bounded context with `members[]`, pod-local `edges[]`, pod startup, and optional continuity policy.
@@ -329,13 +392,21 @@ Migration boundary:
- Pre-launch vs interactive delivery split: `guidance_merge`/`skill_install` happen before harness boot (filesystem); `send_text` happens after harness is ready (TUI).
- Persists replay context including resume token for future restores.
### Operator-layer types (North Star + Post-North-Star)
**EnvReceipt**
- RigEnv capture result persisted for service-backed rigs.
- Carries service status/health plus evaluated wait targets and capture timestamp.
**RigServicesRecord**
- Persisted database representation of a rig-scoped services substrate.
- Holds resolved compose path, persisted spec JSON, project name, and latest receipt JSON.
### Operator, identity, and communication
**NodeInventoryEntry**
- Universal node projection consumed by CLI/UI/MCP: rigId, rigName, logicalId, canonicalSessionName, podId, nodeKind (agent/infrastructure), runtime, sessionStatus, startupStatus, restoreOutcome, tmuxAttachCommand, resumeCommand, latestError.
**NodeDetailEntry**
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus Final Stretch 1 live-identity fields:
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus live-identity fields:
- `peers`
- directional `edges`
- transcript helpers/path
@@ -396,7 +467,7 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
- `startup-validation.ts`: shared startup block validation
- `path-safety.ts`: shared relative-path safety checks
- `spec-validation-service.ts`: pure raw-YAML validation helpers
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data and provenance state
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data, provenance state, and managed-app services metadata (`waitFor`, `surfaces`, `composePreview`)
### Resolution pipeline
@@ -415,6 +486,15 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
- `rigspec-exporter.ts`: dual-format live rig export back to YAML/JSON
- `pod-repository.ts`: pod CRUD plus live continuity-state CRUD
### Rig environment and agent-managed software
- `compose-services-adapter.ts`: thin Compose-backed adapter for `up`, `ps`, `logs`, and teardown
- `services-readiness.ts`: shared wait-target evaluator for `url`, `tcp`, and `service + condition: healthy`
- `service-orchestrator.ts`: rig-scoped env boot, receipt capture, readiness gating, persisted receipt updates, and teardown
- `rig-repository.ts`: now persists `rig_services` rows and surfaces `hasServices` in rig summaries for runtime/UI gating
- `spec-library-service.ts`: classifies builtin/user specs and marks service-backed rigs with `hasServices`
- `spec-review-service.ts`: lifts services metadata into review models; route layer enriches with compose preview
### Runtime adapters
Three runtime adapters implement the five-method contract: projection, startup delivery, harness launch, readiness, and installed-resource listing.
@@ -429,18 +509,20 @@ Three runtime adapters implement the five-method contract: projection, startup d
- launches via `codex`, resumes via `codex resume <threadId>`
- readiness probe: polls for Codex ready indicator
**TerminalAdapter** (North Star)
**TerminalAdapter**
- no-op project/deliver/launch (shell IS the harness)
- immediate readiness (shell is ready as soon as tmux session exists)
- for infrastructure nodes: servers, log tails, build watchers
### Node inventory and operator surfaces (North Star)
### Node inventory and operator surfaces
- `node-inventory.ts`: universal node-level projection. The single source of truth for node state consumed by CLI (`ps --nodes`), UI (explorer + graph + drawer), and MCP (`rigged_rig_nodes`). Core + extended field tiers.
- `demo-rig-selector.ts`: existing-rig power-on helper — finds the right rig by name from ps summary
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows
- `context-usage-store.ts`: SQLite + state-dir persistence for per-node context usage
- `context-monitor.ts`: daemon-owned monitor that records context usage for runtime/operator surfaces
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows, now with context-usage access
### Transport and communication (Post-North-Star)
### Transport and communication
- `session-transport.ts`: communication primitives — send/capture/broadcast with session resolution (canonical + legacy names), mid-work detection, honest error reporting, pod/rig/global targeting
- `transcript-store.ts`: pipe-pane transcript management — ANSI stripping on read, boundary markers, readTail, grep. Filesystem-backed, not SQLite.
@@ -448,29 +530,29 @@ Three runtime adapters implement the five-method contract: projection, startup d
- `ask-service.ts`: context engineering evidence pack — gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance. Does NOT call an external LLM.
- `chat-repository.ts`: durable rig-scoped chat — CRUD for chat_messages table, SSE-compatible event emission
### Resume honesty (North Star)
### Resume honesty
- `native-resume-probe.ts`: honest assessment of whether a harness actually resumed vs fresh-launched — probes pane content for runtime-specific indicators
- `resume-metadata-refresher.ts`: post-launch resume token capture — reads Claude conversation IDs from `.claude/` state, Codex thread IDs from SQLite state files
- `codex-thread-id.ts`: Codex-specific thread ID extraction from the Codex SQLite database
### Discovery extensions (North Star)
### Discovery extensions
- `draft-rig-generator.ts`: synthesize a candidate RigSpec YAML from discovered sessions — groups by CWD, suggests pods, sanitizes IDs
- `claim-service.ts`: adopted-session integration service — claim/bind/create-and-bind flows, tmux metadata writes, and post-claim onboarding hint
### Final Stretch 1 authoring and library surfaces
### Authoring, library, and identity surfaces
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, and reusable display components
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review and `hasServices`
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, reusable display components, and managed-app env details
- `whoami-service.ts`: daemon-side identity surface with peers/edges/transcript helpers
### Snapshot, restore, and continuity
- `checkpoint-store.ts`: checkpoint persistence with pod/continuity context
- `snapshot-capture.ts`: captures pods, continuity state, and startup replay context
- `snapshot-capture.ts`: captures pods, continuity state, startup replay context, and latest env receipt
- `snapshot-repository.ts`: snapshot CRUD
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, and topology ordering
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, topology ordering, and RigEnv boot gating before agent restore
### Bundles, bootstrap, and legacy compatibility
@@ -551,14 +633,14 @@ Restore now:
- restore states: `resumed` / `rebuilt` / `fresh` / `failed`
- failed resume is FAILED loudly — no automatic fresh fallback
### Auto-snapshot + existing-rig power-on (North Star)
### Auto-snapshot and existing-rig power-on
- `rig down <rigId>` auto-captures an `auto-pre-down` snapshot before teardown
- `rig up <rig-name>` (no file extension) searches for existing rig by name, restores from latest auto-pre-down snapshot
- If no snapshot: error with guidance ("No saved snapshot for rig 'X'. Boot from a spec or bundle path.")
- Post-command handoff: down output includes snapshot ID + restore command; up output includes node statuses + attach command
### Communication flow (Post-North-Star)
### Communication flow
`rig send <session> "message"` → CLI → `POST /api/transport/send` → `SessionTransport`:
1. Resolve session name (canonical or legacy, by session/rig/pod/global)
@@ -567,7 +649,7 @@ Restore now:
4. Optional `--verify`: capture post-send pane, check message visibility
5. Honest result with reason on failure
### Transcript flow (Post-North-Star)
### Transcript flow
1. `NodeLauncher` starts `pipe-pane` immediately after tmux session creation (before harness boot)
2. Raw terminal output streams to `~/.openrig/transcripts/{rig-name}/{session-name}.log`
@@ -576,7 +658,7 @@ Restore now:
5. On restore: boundary marker written before re-launch. Pipe-pane reconnects to same file (append).
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance
### Chat flow (Post-North-Star)
### Chat flow
1. `rig chatroom send <rig> "message"` → `POST /api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`
2. SSE stream: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages
@@ -585,7 +667,7 @@ Restore now:
5. MCP: `rigged_chatroom_send` + `rigged_chatroom_watch`
6. Source of truth: daemon-backed SQLite (`chat_messages` table), not tmux scrollback
### Config + preflight flow (Post-North-Star)
### Config and preflight flow
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`
- Precedence: CLI flag > env var > config file > default
@@ -593,11 +675,11 @@ Restore now:
- Auto-preflight runs on `rig up` and daemon start
- Every preflight error: what failed + why it matters + what to do (3-part pattern)
### Discovery-to-draft-rig flow (North Star)
### Discovery-to-draft-rig flow
`rig discover --draft` → scan tmux sessions → group by CWD → suggest pod structure → generate candidate RigSpec YAML → output to stdout or file
### Spec review and spec library flow (Final Stretch 1)
### Spec review and spec library flow
1. Raw YAML preview:
- UI/CLI posts YAML to `/api/specs/review/rig` or `/api/specs/review/agent`
@@ -606,12 +688,17 @@ Restore now:
2. Filesystem-backed library:
- `SpecLibraryService` scans builtin + user roots (`packages/daemon/specs`, `~/.openrig/specs`) with legacy fallback from `~/.rigged/specs`
- each YAML file is classified via structured review
- service-backed rigs are marked `hasServices`
- `/api/specs/library` serves list/get/review/sync
3. CLI:
- `rig specs ls/show/preview/add/sync`
- `rig up` / `rig bootstrap` resolve library names before falling back to other source kinds
4. UI:
- `Specs` drawer presents a unified library list with `All / Apps / Rigs / Agents`
- service-backed entries render as `APP`
- library review shows specialist identity, setup prompt, and environment details
### Whoami and adopted-session parity flow (Final Stretch 1)
### Whoami and adopted-session parity flow
1. Managed sessions still prefer projected `OPENRIG_NODE_ID` / `OPENRIG_SESSION_NAME`.
2. Adopted sessions use tmux-owned metadata written at claim/bind time:
@@ -628,7 +715,7 @@ Restore now:
- raw tmux session-name fallback
4. The daemon owns the truth surface through `/api/whoami`; tmux metadata is an adopted-session anchor, not sovereign truth.
### Materialize / bind / adopt flow (Final Stretch 1)
### Materialize / bind / adopt flow
1. `POST /api/rigs/import/materialize` creates a pod-aware topology without launching sessions.
2. `POST /api/discovery/:id/bind` attaches a discovered live session to an existing logical node.
@@ -640,7 +727,7 @@ Restore now:
- `rig adopt`
5. Authored pod namespace is preserved through adoption so logical ids stay `${podNamespace}.${memberName}`.
### Live identity / specs UI flow (Final Stretch 1)
### Live identity / specs UI flow
1. Explorer/graph selection opens the shared right drawer.
2. Node drawer stays runtime-first and now includes:
@@ -656,6 +743,48 @@ Restore now:
- live full-details surfaces
5. Graph nodes carry lightweight hover/runtime hints without replacing drawer-first operation.
### Rig environment flow
1. Rig instantiation persists a `rig_services` record when a pod-aware `RigSpec` has a `services` block.
2. `ServiceOrchestrator.boot(...)` runs before agent launch/restore:
- Compose `up`
- evaluate `waitFor`
- persist `EnvReceipt`
- fail honestly on timeout or unhealthy waits
3. `GET /api/rigs/:rigId/env` returns live status:
- `hasServices: false` for non-service rigs
- otherwise kind, compose identity, receipt, and surfaces
4. `rig env status/logs/down` and the rig drawer `Env` tab are thin client surfaces over those routes.
5. Snapshot/restore preserves the latest env receipt and reboots services before agent restore.
6. **Current runtime truth model:** Runtime env truth is provided through boot-time gating (services must be healthy before agents launch) and on-demand inspection via `rig env` / the `Env` tab, which polls with honest `probeStatus` (fresh/stale/no_orchestrator). Background health monitoring and `rig ps` env health summary are deferred.
### Agent-managed app flow
1. A managed app is a service-backed rig plus a specialist agent.
2. The canonical shipped example is:
- rig: `secrets-manager`
- pod: `vault`
- member: `specialist`
- session: `vault-specialist@secrets-manager`
3. Specs/UI flow:
- browse `secrets-manager` under `Apps`
- open library review
- inspect specialist identity, env stack, health gates, and surfaces
- copy the setup prompt
4. Runtime flow:
- launch the rig
- services boot before `vault.specialist`
- the rig drawer `Env` tab exposes state, health, surfaces, logs, and teardown
### Specialist interaction flow
1. Other humans or agents address a specialist by session name, e.g. `vault-specialist@secrets-manager`.
2. Cross-rig communication uses the existing transport seam:
- `rig send vault-specialist@secrets-manager "..." --verify`
3. No new routing primitive exists here:
- delegation is currently conventional/social
- session transport remains the transport truth
---
## 7. Architecture Rules
@@ -735,7 +864,7 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
`createDaemon()` now does the following:
1. Open SQLite and run all 17 migrations.
1. Open SQLite and run all 20 migrations.
2. Construct core repositories and legacy services.
3. Construct package/bootstrap/discovery services.
4. Construct rebooted startup/runtime services:
@@ -743,19 +872,24 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
- `ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`
- `PodRigInstantiator`
- `PodBundleSourceResolver`
5. Construct North Star + Post-North-Star services:
5. Construct rig environment services:
- `ComposeServicesAdapter`
- `ServiceOrchestrator`
6. Construct operator, transport, and history services:
- `TranscriptStore` (with config from env for transcript path/enabled)
- `SessionTransport`
- `ChatRepository`
- `AskService` (with `HistoryQuery`)
- `ResumeMetadataRefresher`
- `NodeInventory`
6. Construct Final Stretch 1 services:
- `ResumeMetadataRefresher`
- `ContextUsageStore`
- `ContextMonitor`
- `NodeInventory`
7. Construct authoring, identity, and managed-app services:
- `SpecReviewService`
- `SpecLibraryService` (builtin + user roots)
- `WhoamiService`
7. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
8. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including specs review/library and `whoami`.
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
9. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including env, specs review/library, and `whoami`.
---
@@ -763,25 +897,18 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
### Test footprint
- daemon: 112 Vitest files / 1,461 tests
- CLI: 29 Vitest files / 275 tests
- UI: 35 Vitest files / 364 tests
- total: 176 files / 2,100 tests
- daemon: 127 Vitest files / 1,668 tests
- CLI: 37 Vitest files / 366 tests
- UI: 37 Vitest files / 388 tests
- total: 201 files / 2,422 tests
### Verified during this doc refresh
- daemon: `1461/1461` passing
- CLI: `275/275` passing
- UI: `361/364` passing
- daemon: `1668/1668` passing
- CLI: `366/366` passing
- UI: `388/388` passing
Current known UI failures during this refresh:
- `design-compliance.test.tsx`
- non-zero border-radius count still above zero
- stale `StatusBar.tsx` file assumption in the mono/data-display assertion
- `tailwind-foundation.test.tsx`
- missing `.bg-card` assertion in generated CSS
### Post-North-Star test suites
### Communication, identity, and authoring suites
In addition to reboot-era coverage:
@@ -804,9 +931,23 @@ In addition to reboot-era coverage:
- `adopt.test.ts` — CLI materialize/bind adoption flow
- UI tests for spec review displays, spec library review, specs panel, live node details, and adopted-session node identity
### Rig environment and agent-managed software suites
- `rigspec-services.test.ts` — RigSpec `services` schema and parsing
- `service-orchestrator.test.ts` — Compose-backed env boot, readiness, receipt capture, teardown
- `services-up.test.ts` — end-to-end service-backed rig boot
- `services-snapshot-restore.test.ts` — env receipt capture + restore gating
- `env-routes.test.ts` — runtime env status/logs/down route contracts
- `spec-library-starters.test.ts` — builtin `secrets-manager` + `vault-specialist` presence, topology, and `hasServices`
- `spec-review-service.test.ts` / `spec-library-routes.test.ts` — managed-app services review, wait-target contract, compose preview
- CLI `env.test.ts` — env status/logs/down command surface
- UI `specs-panel.test.tsx` — `All / Apps / Rigs / Agents`, type/stability badges
- UI `library-review.test.tsx` — specialist card, setup prompt, managed-app environment tab
- UI `rig-detail-panel.test.tsx` — runtime `Env` tab and env-state rendering
### Dogfood status
Post-North-Star + Final Stretch 1 dogfood verified:
Core operator, communication, identity, and library flows verified:
- Full product loop: `rig up demo/rig.yaml` → harnesses launch → `rig ps --nodes` → `rig down` → `rig up demo-rig` (restore by name) → agents resume
- Communication: `rig send`, `rig capture`, `rig broadcast` across running rig
@@ -820,6 +961,25 @@ Post-North-Star + Final Stretch 1 dogfood verified:
- Adoption: materialize + bind/adopt flows from discovery/specs workflows
- MCP: all 17 tools verified
Rig environment and agent-managed software flows additionally verified:
- Managed-app browse flow in the UI:
- `Specs` → `Apps` → `secrets-manager`
- managed-app review with `vault.specialist`
- `secrets-manager` launch as a real service-backed rig with a specialist agent
- Real Vault operations through `vault-specialist@secrets-manager`
- health check
- write secret
- read secret back
- Cross-rig direct messaging to `vault-specialist@secrets-manager`
- Runtime `Env` tab:
- overall state
- health gates
- surfaces
- logs
- explicit teardown
- Failure honesty when Vault is stopped mid-rig
---
## 11. Remaining Compatibility Notes
@@ -834,6 +994,9 @@ These are the main intentional limits that still describe the shipped system:
6. Chat is rig-scoped only — no cross-rig channels or DMs.
7. `--verify` on `rig send` checks pane content for message visibility but can produce false positives from pre-existing matching content. Known limitation.
8. Terminal node readiness is shell-ready only — no service health probes.
9. `rig env down --volumes` exists in the CLI surface, but the explicit daemon-side override is not fully plumbed through yet.
10. Managed-app service surfaces are descriptive only — OpenRig does not automatically inject service URLs/tokens into agent prompts beyond authored startup/context files.
11. Specialist delegation is conventional, not automatic — other agents/humans address the specialist by session name or other normal communication surfaces.
---
+72 -6
View File
@@ -1,6 +1,6 @@
# OpenRig CLI Reference
Verified against the shipped CLI on 2026-04-07 using:
Verified against the shipped CLI on 2026-04-09 using:
- `packages/cli/src/index.ts`
- `packages/cli/src/commands/*.ts`
- `packages/cli/src/mcp-server.ts`
@@ -11,9 +11,10 @@ This document reflects the current `rig` surface as shipped. Where live help tex
## Overview
- Binary: `rig`
- Top-level command groups: `36`
- Top-level command groups: `41`
- Output mode: human-readable by default; many commands also support `--json`
- Daemon-backed commands fail when the daemon is stopped or unhealthy; `daemon`, `config`, `preflight`, and `doctor` also have local responsibilities
- Managed apps are launched through the normal spec/library surfaces; the canonical shipped example is `rig up secrets-manager`
- Legacy surface still shipped: `package`
## Top-Level Commands
@@ -31,11 +32,13 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `bootstrap` | Bootstrap a rig from a spec file |
| `requirements` | Check requirements for a rig spec |
| `discover` | Scan for unmanaged tmux sessions |
| `attach` | Attach the current shell or agent into a rig node |
| `bind` | Bind a discovered session to a rig node |
| `adopt` | Materialize topology and bind discovered live sessions |
| `bundle` | Manage rig bundles |
| `up` | Bootstrap a rig from a spec or bundle |
| `down` | Tear down a rig |
| `env` | Inspect and control rig environment services |
| `ps` | List rigs and their status |
| `mcp` | MCP server for agent integration |
| `agent` | Manage agent specs |
@@ -51,11 +54,14 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `config` | Inspect and change OpenRig configuration |
| `preflight` | Check system readiness for OpenRig |
| `doctor` | Verify OpenRig install health |
| `destroy` | Destroy OpenRig local state for recovery |
| `expand` | Add a pod to a running rig |
| `unclaim` | Release an adopted session without killing tmux |
| `release` | Release claimed sessions from a rig |
| `launch` | Launch or relaunch a node in a running rig |
| `remove` | Remove a node from a running rig |
| `shrink` | Remove an entire pod from a running rig |
| `setup` | Prepare the machine for OpenRig |
## Core Daemon and System Commands
@@ -124,7 +130,24 @@ Usage: `rig doctor [--json]`
Notes:
- Verifies install health for packaged/local CLI usage.
- Checks daemon dist, UI dist, Node version, tmux availability, writable state paths, and daemon port availability.
- Checks daemon dist, UI dist, Node version, `tmux`, optional `cmux` control health, writable state paths, and daemon port availability.
- On macOS, also warns when tmux mouse mode appears disabled, gives the current-server fix (`tmux set -g mouse on`), and points to the persistent fix in `~/.tmux.conf`.
- `cmux` issues are warnings, not hard failures. OpenRig still works without `cmux`; only `Open CMUX` workflows are unavailable.
- `--json` is suitable for agent use and only exits non-zero on real failures, not warnings.
### `rig destroy`
Usage:
- `rig destroy --state [--backup] --yes --confirm destroy-openrig-state`
- `rig destroy --all [--backup] --yes --confirm destroy-openrig-state`
Notes:
- This is the destructive recovery surface for polluted local OpenRig state.
- `--state` stops the daemon, clears the active OpenRig listener on the configured port if needed, rotates or deletes the effective state root, and recreates an empty state root.
- `--all` includes `--state` plus managed tmux session cleanup for sessions that are discoverable from the current OpenRig database.
- `--backup` moves the state root aside to a collision-safe timestamped path such as `~/.openrig.backup-YYYYMMDD-HHMMSS`.
- Managed tmux cleanup is intentionally conservative. It only removes sessions that are present in current DB state; unrelated tmux sessions are left alone.
- Human output prints a compact destroy plan followed by the destroy result.
### `rig mcp`
@@ -159,7 +182,10 @@ Shipped MCP tools:
Usage: `rig bootstrap <spec> [--plan] [--yes] [--json]`
Arguments:
- `spec`: path to a rig spec YAML file
- `spec`: path to a rig spec YAML file or a library name
Notes:
- Bare names resolve through the spec library before falling back to the raw source value.
### `rig requirements`
@@ -168,6 +194,10 @@ Usage: `rig requirements <spec> [--json]`
Arguments:
- `spec`: path to a rig spec YAML file
Notes:
- `rig requirements` is the spec/app-specific dependency surface.
- Use `rig doctor` for host-level install health, then `rig requirements <spec>` for rig-specific requirements.
### `rig up`
Usage: `rig up <source> [--plan] [--yes] [--target <root>] [--json]`
@@ -187,6 +217,7 @@ Current behavior notes:
- `--target <root>` is only for bundle/package installation. It does not override agent working directories.
- `local:` `agent_ref` values resolve relative to the rig spec directory, not the caller shell cwd.
- If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`.
- Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library.
Success modes:
- fresh boot
@@ -206,6 +237,20 @@ Notes:
- When `--snapshot` succeeds, human output includes the restore command.
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
### `rig env`
Usage:
- `rig env status <rig> [--json]`
- `rig env logs <rig> [service] [--tail <n>]`
- `rig env down <rig> [--volumes]`
Notes:
- This surface is only meaningful for service-backed rigs and managed apps.
- `status` resolves rig names or IDs and returns the env receipt with an honest freshness probe. The response includes `probeStatus` (fresh/stale/no_orchestrator) so operators can distinguish current truth from cached state.
- `logs` proxies compose-backed service logs; `[service]` is optional.
- `down` tears down the rig environment. `--volumes` overrides the stored down policy to force volume removal via `docker compose down --volumes`.
- Note: `rig ps` does not yet surface env health. Runtime env truth is available through `rig env status` and the rig drawer `Env` tab.
### `rig ps`
Usage:
@@ -312,9 +357,10 @@ Subcommands:
- `rename <name-or-id> <new-name> [--json]`
Notes:
- `specs` is the library surface.
- `specs` is the library surface for rigs, agents, and managed apps.
- `preview` returns structured review data from the daemon.
- `add` validates the file as either RigSpec or AgentSpec before copying it into the user library.
- `preview secrets-manager` is the canonical managed-app review example.
## Discovery and Topology Mutation
@@ -326,6 +372,18 @@ Notes:
- Scans unmanaged tmux sessions.
- `--draft` generates a candidate rig spec from the discovery set.
### `rig attach`
Usage:
- `rig attach --self --rig <rigId> --node <logicalId> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
- `rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
Notes:
- `--self` is currently required.
- Node attach and pod-create attach are exclusive modes.
- In tmux-backed shells, the command records tmux attachment metadata; otherwise it records an `external_cli` attachment.
- `--print-env` prints shell exports for `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME`.
### `rig bind`
Usage: `rig bind <discoveredId> --rig <rigId> (--node <logicalId> | --pod <namespace> --member <name>)`
@@ -364,6 +422,15 @@ Usage: `rig unclaim <sessionRef> [--json]`
Notes:
- Releases an adopted session without killing its tmux session.
### `rig release`
Usage: `rig release <rigId> [--delete] [--json]`
Notes:
- Releases all claimed/adopted sessions from a rig without killing their tmux sessions.
- `--delete` removes the rig record after a clean release.
- OpenRig-launched nodes still require `rig down`.
### `rig launch`
Usage: `rig launch <rigId> <nodeRef> [--json]`
@@ -487,6 +554,5 @@ These are not current top-level `rig` commands:
- `rig claim`
- `rig blame`
- `rig replay`
- `rig env`
If older docs or habits mention them, treat those references as stale.
+40 -24
View File
@@ -3,10 +3,10 @@
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
Current footprint:
- daemon: 133 source files (79 domain, 21 routes, 10 adapters, 17 migrations, 6 other)
- CLI: 37 source files
- UI: 83 source files
- total: 253 source files
- daemon: 150 source files (90 domain, 22 routes, 11 adapters, 20 migrations, 7 other)
- CLI: 49 source files
- UI: 87 source files
- total: 286 source files
How to use this doc:
- `Exports` lists the public symbols defined by the file.
@@ -16,6 +16,7 @@ How to use this doc:
Fast lookup:
- Daemon app wiring and mounted HTTP surface: `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/routes/*`
- AgentSpec / pod-aware engine: `packages/daemon/src/domain/agent-*`, `profile-resolver.ts`, `projection-planner.ts`, `startup-*`, `rigspec-*`, `pod-repository.ts`
- Rig environment / managed-app substrate: `packages/daemon/src/adapters/compose-services-adapter.ts`, `packages/daemon/src/domain/service-orchestrator.ts`, `packages/daemon/src/domain/services-readiness.ts`, `packages/daemon/src/routes/env.ts`
- Snapshot / restore / continuity: `packages/daemon/src/domain/snapshot-*`, `checkpoint-store.ts`, `restore-orchestrator.ts`
- Transport / communication: `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/routes/transport.ts`
- Transcripts / persistence: `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/domain/history-query.ts`, `packages/daemon/src/routes/transcripts.ts`
@@ -23,13 +24,15 @@ Fast lookup:
- Chat: `packages/daemon/src/domain/chat-repository.ts`, `packages/daemon/src/routes/chat.ts`
- Spec review / library / identity: `packages/daemon/src/domain/spec-review-service.ts`, `packages/daemon/src/domain/spec-library-service.ts`, `packages/daemon/src/domain/whoami-service.ts`, `packages/daemon/src/routes/spec-review.ts`, `packages/daemon/src/routes/spec-library.ts`, `packages/daemon/src/routes/whoami.ts`
- Node inventory: `packages/daemon/src/domain/node-inventory.ts`
- Context usage: `packages/daemon/src/domain/context-usage-store.ts`, `packages/daemon/src/domain/context-monitor.ts`
- Resume honesty: `packages/daemon/src/domain/native-resume-probe.ts`, `resume-metadata-refresher.ts`, `codex-thread-id.ts`
- Package / bootstrap / bundle flows: `packages/daemon/src/domain/package-*`, `install-*`, `bootstrap-*`, `bundle-*`
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (31 groups)
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (40 groups)
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
- UI spec/library surfaces: `packages/ui/src/components/RigSpecDisplay.tsx`, `packages/ui/src/components/AgentSpecDisplay.tsx`, `packages/ui/src/components/LibraryReview.tsx`, `packages/ui/src/components/SpecsWorkspace.tsx`
- UI managed-app runtime surfaces: `packages/ui/src/components/RigEnvPanel.tsx`, `packages/ui/src/hooks/useRigEnv.ts`, `packages/ui/src/lib/build-setup-prompt.ts`
- UI live identity surfaces: `packages/ui/src/components/LiveIdentityDisplay.tsx`, `packages/ui/src/components/LiveNodeDetails.tsx`
- UI data layer: `packages/ui/src/hooks/*`
@@ -38,14 +41,15 @@ Fast lookup:
### Entrypoints And Wiring
- `packages/daemon/src/index.ts`: CLI entrypoint for starting the daemon HTTP server. Exports: startServer. Related: `packages/daemon/src/startup.js`.
- `packages/daemon/src/seed.ts`: Development/test seed entrypoint for populating sample daemon state. Exports: none. Related: `packages/daemon/src/db/connection.js`, `packages/daemon/src/db/migrate.js`, `packages/daemon/src/db/migrations/001_core_schema.js`, `packages/daemon/src/db/migrations/002_bindings_sessions.js`, +3 more.
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 17 migrations, wires legacy, reboot, North Star, Post-North-Star, and Final Stretch 1 services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 20 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, and Final Stretch 3 managed-app services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
### Adapters
- `packages/daemon/src/adapters/claude-code-adapter.ts`: Adapter implementation for claude code integration. Exports: ClaudeAdapterFsOps, ClaudeCodeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
- `packages/daemon/src/adapters/claude-resume.ts`: Resume adapter for claude sessions. Exports: ClaudeResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
- `packages/daemon/src/adapters/cmux-transport.ts`: Adapter/util module for cmux transport integration. Exports: createCmuxCliTransport. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
- `packages/daemon/src/adapters/cmux.ts`: Adapter/util module for cmux integration. Exports: CmuxAdapter, CmuxResult, CmuxStatus, CmuxSurface, CmuxTransport, CmuxTransportFactory, CmuxWorkspace. Related: none.
- `packages/daemon/src/adapters/compose-services-adapter.ts`: Compose-backed rig-environment adapter for `up`, `ps`, `logs`, and teardown. Exports: ComposePsService, ComposeServicesAdapter, ComposeServicesUpParams, ComposeServicesUpResult, ComposeServicesLogsParams, ComposeServicesLogsResult, ComposeServicesDownParams, ComposeServicesDownResult. Related: `packages/daemon/src/adapters/tmux-exec.js`.
- `packages/daemon/src/adapters/codex-resume.ts`: Resume adapter for codex sessions. Exports: CodexResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
- `packages/daemon/src/adapters/codex-runtime-adapter.ts`: Adapter implementation for codex runtime integration. Exports: CodexAdapterFsOps, CodexRuntimeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
- `packages/daemon/src/adapters/terminal-adapter.ts`: Infrastructure runtime adapter for terminal/shell nodes. No-op project/deliver/launch, immediate readiness. Exports: TerminalAdapter. Related: `packages/daemon/src/domain/runtime-adapter.js`.
@@ -75,19 +79,26 @@ Fast lookup:
- `packages/daemon/src/db/migrations/015_startup_context.ts`: Database migration for startup context. Exports: startupContextSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/016_chat_messages.ts`: Database migration for durable rig-scoped chat messages. Exports: chatMessagesSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/017_pod_namespace.ts`: Database migration that persists authored pod namespace separately from pod DB id for export, adoption, and identity parity. Exports: podNamespaceSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/018_context_usage.ts`: Database migration for per-node context-usage persistence. Exports: contextUsageSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/019_external_cli_attachment.ts`: Database migration extending bindings for external CLI attachment metadata. Exports: externalCliAttachmentSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/020_rig_services.ts`: Database migration for rig-scoped services records and persisted env receipts. Exports: rigServicesSchema. Related: `packages/daemon/src/db/migrate.js`.
### Domain: Core Runtime
- `packages/daemon/src/domain/errors.ts`: Domain-specific error types, currently including `RigNotFoundError`. Exports: RigNotFoundError. Related: none.
- `packages/daemon/src/domain/event-bus.ts`: Persistent event log plus in-process pub/sub for routes, SSE, and domain emitters. Exports: EventBus. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/graph-projection.ts`: Pure projection from persisted rig/session state to React Flow graph nodes and edges. Exports: ReactFlowGraph, RigGraphInput, projectRigToGraph. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/context-monitor.ts`: Daemon-owned context-usage recorder that samples live runtime state and updates persistent usage summaries. Exports: ContextMonitor. Related: `packages/daemon/src/domain/context-usage-store.js`.
- `packages/daemon/src/domain/context-usage-store.ts`: SQLite-backed store for per-node context usage snapshots used by whoami/node inventory surfaces. Exports: ContextUsageStore, ContextUsageSnapshot, ContextUsageStoreOpts. Related: none.
- `packages/daemon/src/domain/node-launcher.ts`: Atomic node launch service that creates tmux, session rows, bindings, and launch events together. Exports: LaunchResult, NodeLauncher. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-name.js`, +2 more.
- `packages/daemon/src/domain/ps-projection.ts`: Builds the `/api/ps` summary view over rigs, nodes, sessions, and latest snapshots. Exports: PsEntry, PsProjectionService. Related: none.
- `packages/daemon/src/domain/reconciler.ts`: Marks missing tmux sessions as detached and keeps persisted session state aligned with reality. Exports: ReconcileResult, Reconciler. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-registry.js`.
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, and edge state. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, edge, and rig-services state. Also projects `hasServices` into rig summaries for runtime/UI gating. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/rig-teardown.ts`: Graceful rig shutdown/delete orchestrator used by `/api/down`. Exports: RigTeardownOrchestrator, TeardownResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +2 more.
- `packages/daemon/src/domain/service-orchestrator.ts`: Rig-scoped services orchestrator for env boot, readiness gating, receipt capture, and teardown. Exports: ServiceBootResult, ServiceOrchestrator, ServiceTeardownResult. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/services-readiness.js`.
- `packages/daemon/src/domain/session-name.ts`: Tmux session-name derivation and validation helpers. Exports: deriveSessionName, validateSessionName. Related: none.
- `packages/daemon/src/domain/session-registry.ts`: Session and binding lifecycle repository, including claimed-session registration and startup-status updates. Exports: SessionRegistry. Related: `packages/daemon/src/domain/session-name.js`, `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
- `packages/daemon/src/domain/services-readiness.ts`: Shared readiness evaluator for RigEnv wait targets (`url`, `tcp`, `service + condition`). Exports: WaitTargetFailure, WaitTargetSuccess, evaluateWaitTargets. Related: none.
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, RigEnv/service contracts, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, EnvCheckpoint, EnvReceipt, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigServicesRecord, RigServicesSpec, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
- `packages/daemon/src/domain/up-command-router.ts`: Source router that classifies a path as a rig spec or rig bundle before bootstrap. Exports: RouteResult, SourceKind, UpCommandRouter. Related: `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
### Domain: Specs, Resolution, Startup
@@ -104,8 +115,8 @@ Fast lookup:
- `packages/daemon/src/domain/rigspec-schema.ts`: Validation and normalization for both legacy and rebooted RigSpec schemas. Exports: LegacyRigSpecSchema, RigSpecSchema. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/startup-validation.js`, `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/runtime-adapter.ts`: Runtime-adapter contract plus bridge types used by startup orchestration and runtime-specific adapters. Exports: InstalledResource, NodeBinding, ProjectionResult, ReadinessResult, ResolvedStartupFile, RuntimeAdapter, StartupDeliveryResult. Related: `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/spec-validation-service.ts`: Pure validation helpers for raw AgentSpec and RigSpec YAML strings. Exports: validateAgentSpecFromYaml, validateRigSpecFromYaml. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, and provenance/source-state for preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, provenance/source-state, and managed-app services metadata used by preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, RigSpecServicesReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, marks service-backed rigs with `hasServices`, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/domain/whoami-service.ts`: Daemon-side runtime identity projector used by `/api/whoami`. Resolves by node or session, expands peers/edges/transcript helpers, and surfaces ambiguity honestly. Exports: WhoamiResult, WhoamiAmbiguousError, WhoamiService. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/transcript-store.js`.
- `packages/daemon/src/domain/startup-orchestrator.ts`: Startup execution engine for projection, startup-file delivery, action execution, readiness, and persisted replay context. Exports: StartupInput, StartupOrchestrator, StartupResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`, +2 more.
- `packages/daemon/src/domain/startup-resolver.ts`: Pure additive startup-layer merge function across agent, profile, rig, pod, member, and operator layers. Exports: StartupLayerInputs, resolveStartup. Related: `packages/daemon/src/domain/types.js`.
@@ -113,8 +124,8 @@ Fast lookup:
### Domain: Snapshots And Restore
- `packages/daemon/src/domain/checkpoint-store.ts`: Checkpoint persistence, lookup, and rig-wide checkpoint aggregation. Exports: CheckpointStore. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, and topology ordering. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, and startup context. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, topology ordering, and RigEnv boot gating before agent restore. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, startup context, and the latest rig-env receipt. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
- `packages/daemon/src/domain/snapshot-repository.ts`: SQLite-backed repository for snapshot CRUD and listing. Exports: SnapshotRepository. Related: `packages/daemon/src/domain/types.js`.
### Domain: Packages And Install Engine
@@ -176,6 +187,7 @@ Fast lookup:
- `packages/daemon/src/routes/bundles.ts`: Hono route module for dual-format bundle create/inspect/install. Supports legacy v1 package bundles and schema-version-2 pod bundles, including v2 inspect responses with `agents[]` and integrity data. Exports: bundleRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-assembler.js`, +7 more.
- `packages/daemon/src/routes/discovery.ts`: Hono route module for discovery scan/list/detail plus Final Stretch flows: `draft-rig`, `bind`, and composite `adopt`. Exports: discoveryRoutes. Related: `packages/daemon/src/domain/claim-service.js`, `packages/daemon/src/domain/discovery-coordinator.js`, `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/draft-rig-generator.js`.
- `packages/daemon/src/routes/down.ts`: Hono route module for down API endpoints. Exports: downRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rig-teardown.js`.
- `packages/daemon/src/routes/env.ts`: Hono route module for rig-environment status, logs, and teardown. Refreshes env receipts best-effort and returns additive managed-app/runtime metadata (`hasServices`, `surfaces`, `receipt`). Exports: envRoutes. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/service-orchestrator.js`.
- `packages/daemon/src/routes/events.ts`: Hono route module for events API endpoints. Exports: eventsRoute. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/routes/packages.ts`: Hono route module for the legacy package-install surface (`/validate`, `/plan`, `/install`, history, rollback). AgentSpec validation no longer lives here. Exports: packagesRoutes. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, +6 more.
- `packages/daemon/src/routes/ps.ts`: Hono route module for ps API endpoints. Exports: psRoutes. Related: `packages/daemon/src/domain/ps-projection.js`.
@@ -188,7 +200,7 @@ Fast lookup:
- `packages/daemon/src/routes/ask.ts`: Hono route module for context evidence packs (`POST /`). Delegates to AskService. Exports: askRoutes. Related: `packages/daemon/src/domain/ask-service.js`.
- `packages/daemon/src/routes/chat.ts`: Hono route module for durable rig chat (`GET /watch` SSE stream, `POST /send`, `GET /history`, `POST /topic`). Delegates to ChatRepository, streams via SSE. Exports: chatRoutes. Related: `packages/daemon/src/domain/chat-repository.js`, `packages/daemon/src/domain/event-bus.js`.
- `packages/daemon/src/routes/spec-review.ts`: Hono route module for structured review over raw RigSpec / AgentSpec YAML. Accepts `sourceState` and returns review models or validation errors. Exports: specReviewRoutes. Related: `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Enriches service-backed reviews with best-effort compose preview data for managed-app UI surfaces. Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/routes/whoami.ts`: Hono route module for daemon-backed runtime identity lookup by `nodeId` or `sessionName`. Exports: whoamiRoutes. Related: `packages/daemon/src/domain/whoami-service.js`.
- `packages/daemon/src/routes/up.ts`: Hono route module for `/api/up`, routing direct rig specs, bundles, and rig-name restore into the staged bootstrap flow. Exports: upRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/up-command-router.js`.
@@ -197,7 +209,7 @@ Fast lookup:
### Entrypoints And Infrastructure
- `packages/cli/src/client.ts`: Shared HTTP client for talking to the daemon API, including raw JSON posts and text-body posts with optional extra headers such as `X-Rig-Root`. Exports: DaemonClient, DaemonConnectionError, DaemonResponse. Related: none.
- `packages/cli/src/daemon-lifecycle.ts`: Starts, stops, inspects, and tails the local daemon process. Spawns the daemon with `process.execPath` and bypasses local `daemon.json` gating when `OPENRIG_URL` is set by probing remote `/healthz` directly. Exports: DaemonState, DaemonStatus, LOG_FILE, LifecycleDeps, OPENRIG_DIR, STATE_FILE, StartOptions, getDaemonPath, getDaemonStatus, readLogs, startDaemon, stopDaemon, tailLogs. Related: none.
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (31 mounted command groups, including Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +18 more.
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (40 mounted command groups, including env status/logs/down, `destroy`, and Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +19 more.
- `packages/cli/src/mcp-server.ts`: Implements the CLI-hosted MCP server over stdio, wrapping 17 daemon-backed tools including node inventory, send/capture, bundle/spec validation, and chatroom. Exports: createMcpServer. Related: `packages/cli/src/client.js`.
### Commands
@@ -209,6 +221,7 @@ Fast lookup:
- `packages/cli/src/commands/daemon.ts`: Commander command module for `daemon`. Exports: daemonCommand, realDeps. Related: `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/discover.ts`: Commander command module for `discover`. Exports: discoverCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/down.ts`: Commander command module for `down`. Exports: downCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/env.ts`: Commander command module for `env` — rig environment status/logs/down for service-backed rigs and managed apps. Exports: envCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/commands/status.js`.
- `packages/cli/src/commands/export.ts`: Commander command module for `export`. Exports: ExportDeps, exportCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/import.ts`: Commander command module for `import`. Locally parses YAML to detect pod-aware rigs, sends `X-Rig-Root` for pod-aware preflight/instantiate flows, and supports `--rig-root`. Exports: ImportDeps, importCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/mcp.ts`: Commander command module for `mcp`. Exports: mcpCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`, +1 more.
@@ -219,9 +232,9 @@ Fast lookup:
- `packages/cli/src/commands/rig.ts`: Commander command module for `rig`. Subcommands: `validate` (POST `/api/rigs/import/validate`), `preflight` (POST `/api/rigs/import/preflight` with `X-Rig-Root` header). Both support `--json`. Exports: RigDeps, rigCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/snapshot.ts`: Commander command module for `snapshot`. Exports: snapshotCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/status.ts`: Commander command module for `status`. Exports: StatusDeps, statusCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. Help/examples and not-found guidance now explicitly call out managed apps. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/ui.ts`: Commander command module for `ui`. Exports: UiDeps, uiCommand. Related: `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Help/examples now treat managed apps like `secrets-manager` as first-class launch targets. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
- `packages/cli/src/commands/whoami.ts`: Commander command module for `whoami`. Resolves identity from explicit args, env vars, tmux metadata, and session-name fallback, then delegates to daemon `/api/whoami`. Exports: resolveIdentitySource, whoamiCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/adopt.ts`: Commander command module for `adopt` — materialize topology from a pod-aware RigSpec, then bind discovered live sessions according to `--bind` mappings. Exports: AdoptDeps, adoptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/import.js`, `packages/cli/src/daemon-lifecycle.js`.
@@ -273,19 +286,20 @@ Fast lookup:
### Components: North Star + Post-North-Star Additions
- `packages/ui/src/components/Explorer.tsx`: Hierarchical tree navigator — rigs → pods → agents with status indicators. Primary navigation surface. Click rig loads graph, click node opens detail drawer. Exports: Explorer. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useNodeInventory.js`.
- `packages/ui/src/components/SharedDetailDrawer.tsx`: Unified drawer surface keyed by `DrawerSelection`. Routes to rig, node, system, discovery, or specs panels without leaving the current workspace. Exports: DrawerSelection, SharedDetailDrawer. Related: `packages/ui/src/components/DiscoveryPanel.js`, `packages/ui/src/components/NodeDetailPanel.js`, `packages/ui/src/components/RigDetailPanel.js`, `packages/ui/src/components/SpecsPanel.js`, `packages/ui/src/components/SystemPanel.js`.
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab. Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots/actions, and runtime drawer tabs (`Info`, conditional `Env`, `Chat Room`). Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
- `packages/ui/src/components/RigEnvPanel.tsx`: Runtime env surface for service-backed rigs. Shows overall env state, per-service health, health gates, surfaces, logs, and teardown action. Exports: RigEnvPanel. Related: `packages/ui/src/hooks/useRigEnv.js`.
- `packages/ui/src/components/NodeDetailPanel.tsx`: Runtime-first node drawer. Shows rig/session/runtime identity, peers, directional edges, transcript helpers, compact spec summary, status, actions (tmux/cmux/resume/full-details), startup files, and recent events. Exports: NodeDetailPanel. Related: `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`, `packages/ui/src/lib/copy-text.js`, `packages/ui/src/lib/display-name.js`.
- `packages/ui/src/components/NodeDetailPlaceholder.tsx`: Empty state for node detail when no node is selected. Exports: NodeDetailPlaceholder. Related: none.
- `packages/ui/src/components/DiscoveryPanel.tsx`: Drawer-native discovery/adoption surface. Supports scan/list, draft-rig generation, placement-first bind/adopt, and pod/member targeting inside the shared drawer. Exports: DiscoveryPlacementTarget, DiscoveryPanel. Related: `packages/ui/src/hooks/useDiscovery.js`.
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Lists library entries, opens review routes, and bridges into specs workspace tasks. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Presents a unified library list with `All / Apps / Rigs / Agents`, type/stability badges, richer managed-app rows, and review-route handoff. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
- `packages/ui/src/components/RigChatPanel.tsx`: Chat room tab in rig drawer — send messages, watch live stream, view history. Exports: RigChatPanel. Related: `packages/ui/src/hooks/useRigChat.js`.
- `packages/ui/src/components/LiveIdentityDisplay.tsx`: Reusable runtime-identity subsection for peers, edges, transcript helpers, and compact spec summary. Used by the node drawer and full-details route. Exports: LiveIdentityDisplay. Related: none.
- `packages/ui/src/components/LiveNodeDetails.tsx`: Explicit center-workspace route for deep inspection of one live node. Composes runtime identity plus reusable spec display components. Exports: LiveNodeDetails. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`.
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Supports a library-only managed-app Environment tab with compose preview, health gates, and surfaces. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
- `packages/ui/src/components/AgentSpecDisplay.tsx`: Canonical AgentSpec display primitive used by preview, library, and live-details flows. Exports: AgentSpecDisplay. Related: none.
- `packages/ui/src/components/RigSpecReview.tsx`: Route-level rig review page that consumes `RigSpecDisplay` and specs workspace state. Exports: RigSpecReview. Related: `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
- `packages/ui/src/components/AgentSpecReview.tsx`: Route-level agent review page that consumes `AgentSpecDisplay` and specs workspace state. Exports: AgentSpecReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Service-backed rigs surface managed-app copy, specialist identity, setup-prompt copy, and env-aware review details. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
- `packages/ui/src/components/SpecTopologyPreview.tsx`: Lightweight topology preview for structured rig review models. Exports: SpecTopologyPreview. Related: none.
- `packages/ui/src/components/SystemPanel.tsx`: Drawer surface for system-wide event log/status tabs. Replaces the old status-bar role with a richer right-drawer system surface. Exports: SystemPanel. Related: `packages/ui/src/hooks/useActivityFeed.js`.
- `packages/ui/src/components/WorkspaceHome.tsx`: Landing page when no rig is selected. Entry point for explorer-first workflow. Exports: WorkspaceHome. Related: `packages/ui/src/components/AppShell.js`.
@@ -315,16 +329,17 @@ Fast lookup:
- `packages/ui/src/hooks/usePsEntries.ts`: `/api/ps` summary hook. Exports: PsEntry, usePsEntries. Related: none.
- `packages/ui/src/hooks/useRigEvents.ts`: Rig-scoped SSE subscription and invalidation hook. Exports: UseRigEventsResult, useRigEvents. Related: none.
- `packages/ui/src/hooks/useRigGraph.ts`: Rig graph fetch/transformation hook. Exports: useRigGraph. Related: none.
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Exports: RigSummary, useRigSummary. Related: none.
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Includes `hasServices` so runtime/UI can gate managed-app env surfaces. Exports: RigSummary, useRigSummary. Related: none.
- `packages/ui/src/hooks/useSnapshots.ts`: Snapshot list hook for one rig. Exports: Snapshot, useSnapshots. Related: none.
### Hooks: North Star + Post-North-Star Additions
- `packages/ui/src/hooks/useNodeInventory.ts`: Fetches node inventory for a rig from the canonical `GET /api/rigs/:rigId/nodes` endpoint. Exports: useNodeInventory. Related: none.
- `packages/ui/src/hooks/useNodeDetail.ts`: Fetches extended node detail from `GET /api/rigs/:rigId/nodes/:logicalId`, including peers, edges, transcript helpers, and compact spec summary for the adopted-parity/live-identity drawer. Exports: NodeDetailData, useNodeDetail. Related: none.
- `packages/ui/src/hooks/useRigEnv.ts`: Fetches `GET /api/rigs/:rigId/env` for service-backed rig status, surfaces, and receipt data, with runtime-friendly refetch behavior. Exports: RigEnvData, useRigEnv. Related: none.
- `packages/ui/src/hooks/useRigChat.ts`: Chat send/watch/history hooks for the rig chat panel. Exports: useRigChat. Related: none.
- `packages/ui/src/hooks/useGlobalEvents.ts`: Global SSE subscription for explorer status updates. Exports: useGlobalEvents. Related: none.
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results and `hasServices`-aware managed-app classification. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. The rig review type now carries managed-app services metadata and compose preview. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
### Lib Utilities
- `packages/ui/src/lib/edge-styles.ts`: Edge-style resolver for graph links. Exports: EdgeStyleResult, getEdgeStyle. Related: none.
@@ -338,4 +353,5 @@ Fast lookup:
### Lib Utilities: North Star + Post-North-Star Additions
- `packages/ui/src/lib/display-id.ts`: Short ULID display utility — uses ULID tail (not head) for better uniqueness in same-view contexts. UI-only, no backend changes. Exports: shortId. Related: none.
- `packages/ui/src/lib/display-name.ts`: Pod name extraction from qualified logical IDs (e.g., "dev1" from "dev1.impl"). Exports: displayPodName, inferPodName. Related: none.
- `packages/ui/src/lib/build-setup-prompt.ts`: Managed-app setup-prompt generator used by library review copy surfaces. Exports: buildSetupPrompt. Related: none.
- `packages/ui/src/lib/copy-text.ts`: Clipboard copy utility for the "Copy tmux attach" and "Copy resume command" buttons. Exports: copyText. Related: none.
+411
View File
@@ -0,0 +1,411 @@
# AgentSpec Reference
Version: 1.0
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/agent-manifest.ts`, `packages/daemon/src/domain/types.ts`
This is the canonical reference for the AgentSpec YAML format (`agent.yaml`). Every field, validation rule, and default documented here was traced from the actual parser and validator code.
---
## Minimal Valid Example
```yaml
name: my-agent
version: "1.0"
profiles:
default:
uses:
skills: []
guidance: []
subagents: []
hooks: []
runtime_resources: []
resources: {}
startup:
files: []
actions: []
```
## Practical Example (implementer agent)
```yaml
name: implementer
version: "1.0"
description: Implementation agent — writes code following TDD discipline
defaults:
runtime: claude-code
imports:
- ref: local:../../shared
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, development-team, test-driven-development, mental-model-ha]
guidance: []
subagents: []
hooks: []
runtime_resources: []
resources:
guidance:
- id: role
path: guidance/role.md
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
actions: []
```
## Complete Example (all features)
```yaml
name: vault-specialist
version: "1.0"
description: Vault specialist agent — manages HashiCorp Vault for this managed app
defaults:
runtime: claude-code
model: claude-opus-4-6
lifecycle:
execution_mode: interactive_resident
compaction_strategy: harness_native
restore_policy: resume_if_possible
imports:
- ref: local:../../shared
- ref: local:../common-tools
version: "2.0"
profiles:
default:
summary: Standard Vault operations profile
preferences:
runtime: claude-code
uses:
skills: [openrig-user, mental-model-ha, vault-user]
guidance: []
subagents: []
hooks: []
runtime_resources: []
startup:
files:
- path: guidance/profile-specific.md
delivery_hint: send_text
actions: []
lifecycle:
restore_policy: resume_if_possible
resources:
skills:
- id: vault-user
path: skills/vault-user
guidance:
- id: role
path: guidance/role.md
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.json
runtime: claude-code
type: settings
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
- path: startup/context.md
delivery_hint: send_text
required: true
- path: guidance/optional-tips.md
delivery_hint: guidance_merge
required: false
applies_on: [fresh_start]
actions:
- type: send_text
value: "Load vault-user skill and verify Vault health."
phase: after_ready
idempotent: true
```
---
## Top-Level Fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `name` | string | yes | — | Agent name. Used in spec library identification and validation messages. |
| `version` | string | yes | — | Spec version. Informational — not used for compatibility gating. |
| `description` | string | no | — | Human-readable description. Shown in spec library and review surfaces. |
| `defaults` | Defaults | no | — | Default runtime, model, and lifecycle settings. Applied when not overridden by the rig spec or profile. |
| `imports` | Import[] | no | `[]` | Other AgentSpecs to import. Resources from imported specs become available for profile `uses` references. |
| `profiles` | map<string, Profile> | no | `{}` | Named profiles. Each profile selects resources and can override startup/lifecycle. The rig spec member's `profile` field selects which profile to use. |
| `resources` | Resources | no | all empty | Declared resources (skills, guidance, subagents, hooks, runtime resources). These are the available pool that profiles select from via `uses`. |
| `startup` | StartupBlock | no | `{ files: [], actions: [] }` | Agent-level startup files and actions. Applied to all profiles via the startup layering model. |
---
## Defaults
```yaml
defaults:
runtime: claude-code
model: claude-opus-4-6
lifecycle:
execution_mode: interactive_resident
compaction_strategy: harness_native
restore_policy: resume_if_possible
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `runtime` | string | no | — | Default runtime for this agent. Can be overridden by the rig spec member's `runtime` field. |
| `model` | string | no | — | Default model. Can be overridden by the rig spec member's `model` field. |
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
### Lifecycle Defaults
| Field | Type | Default | Allowed Values |
|-------|------|---------|----------------|
| `execution_mode` | string | `interactive_resident` | `interactive_resident` (only value in v1; `wake_on_demand` is explicitly rejected) |
| `compaction_strategy` | string | `harness_native` | `harness_native`, `pod_continuity` (`custom_prompt` is explicitly rejected in v1) |
| `restore_policy` | string | `resume_if_possible` | `resume_if_possible`, `relaunch_fresh`, `checkpoint_only` |
---
## Imports
```yaml
imports:
- ref: local:../../shared
- ref: local:../common-tools
version: "2.0"
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ref` | string | yes | Reference to another AgentSpec directory. Must start with `local:` (relative) or `path:` (absolute). The referenced directory must contain an `agent.yaml`. |
| `version` | string | no | Optional version constraint. Must be an exact version — no ranges (`~`, `^`, `>=`, etc.). |
### Import Resolution
- `local:` paths resolve relative to the importing spec's directory
- `path:` paths are absolute filesystem paths
- Imported resources become available for `uses` references in profiles
- When referencing imported resources in `uses`, use qualified `namespace:id` format (e.g., `shared:openrig-user`)
- Unqualified references (just `id`) resolve against the spec's own local resources first
### The Shared Import Pattern
Most built-in agents import the shared builtin spec:
```yaml
imports:
- ref: local:../../shared
```
This gives access to the full pool of shared skills (openrig-user, mental-model-ha, development-team, etc.) and agents select the ones they need via profile `uses`.
---
## Profiles
```yaml
profiles:
default:
summary: Standard operations profile
preferences:
runtime: claude-code
model: claude-opus-4-6
uses:
skills: [openrig-user, mental-model-ha, vault-user]
guidance: [role]
subagents: []
hooks: []
runtime_resources: []
startup:
files: []
actions: []
lifecycle:
restore_policy: resume_if_possible
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `summary` | string | no | — | Profile description. |
| `preferences` | object | no | — | Runtime/model preferences for this profile. |
| `preferences.runtime` | string | no | — | Preferred runtime. |
| `preferences.model` | string | no | — | Preferred model. |
| `uses` | Uses | no | all empty | Selects which declared resources are active for this profile. |
| `startup` | StartupBlock | no | — | Profile-level startup files and actions. Merged with agent-level startup via layering. |
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. |
### Uses
The `uses` block selects which resources from the `resources` pool (including imported resources) are active for this profile.
```yaml
uses:
skills: [openrig-user, mental-model-ha, vault-user]
guidance: [role]
subagents: []
hooks: [pre-commit]
runtime_resources: [claude-settings]
```
Each array contains resource IDs. These can be:
- **Unqualified** (`vault-user`) — resolves against the spec's own `resources` first, then imported specs
- **Qualified** (`shared:openrig-user`) — resolves against a specific imported spec's resources
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
---
## Resources
```yaml
resources:
skills:
- id: vault-user
path: skills/vault-user
guidance:
- id: role
path: guidance/role.md
subagents:
- id: helper
path: subagents/helper
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.json
runtime: claude-code
type: settings
```
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`. The only resources delivered are those that the active profile's `uses` block references.
### Resource Categories
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Delivered via `skill_install`. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files. Delivered via `guidance_merge` into CLAUDE.md/AGENTS.md. |
| `subagents` | `id`, `path` | Subagent definitions. |
| `hooks` | `id`, `path`, `runtimes`* | Hook scripts. Optional `runtimes` array restricts to specific runtimes. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. |
*Fields marked with `*` are optional.
### Resource Path Rules
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths)
- Paths resolve relative to the agent spec's directory
- Resource IDs must be unique within their category
- Resource IDs are the identifiers used in `uses` references
### Guidance Resources
```yaml
guidance:
- id: role
path: guidance/role.md
target: CLAUDE.md # optional — where to merge
merge: managed_block # optional — how to merge (default: managed_block)
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Resource identifier. |
| `path` | string | yes | — | Relative path to the guidance file. |
| `target` | string | no | — | Target file for merging (e.g., `CLAUDE.md`). |
| `merge` | string | no | `managed_block` | Merge strategy. One of: `managed_block`, `append`. |
---
## Startup Block
The startup block follows the same format as in the RigSpec (see `docs/reference/rig-spec.md` for full details on files and actions).
Agent-level startup is applied to all profiles. Profile-level startup is applied only when that profile is active. Both merge additively via the startup layering model.
### Delivery Hint Quick Reference
| Hint | When Delivered | Mechanism |
|------|---------------|-----------|
| `auto` | Before harness boot | System chooses based on file type |
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
| `skill_install` | Before harness boot | Installed to runtime skill directory |
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
---
## Validation Rules Summary
1. `name` and `version` are required non-empty strings.
2. `imports` must be an array of objects with `ref` field.
3. Import `ref` must start with `local:` (relative) or `path:` (absolute).
4. Import `version` must be an exact version (no ranges).
5. `profiles` must be a map (object), not an array.
6. Profile `uses` references must resolve to declared resources (local or imported).
7. Unqualified `uses` references that don't resolve to local resources require imports to be present.
8. Qualified `uses` references must be in `namespace:id` format.
9. All resource paths must be safe relative paths.
10. Resource IDs must be unique within their category.
11. `runtime_resources` entries require `runtime` field.
12. Lifecycle `execution_mode` must be `interactive_resident`.
13. Lifecycle `compaction_strategy` must be `harness_native` or `pod_continuity`.
14. Lifecycle `restore_policy` must be `resume_if_possible`, `relaunch_fresh`, or `checkpoint_only`.
15. Startup files and actions follow the same validation rules as in RigSpec.
---
## File System Layout
An agent spec directory follows this conventional layout:
```
my-agent/
agent.yaml # required — the AgentSpec
guidance/
role.md # role definition
startup/
context.md # boot-time grounding
skills/
my-skill/
SKILL.md # skill content
hooks/
pre-commit.sh # hook script
runtime/
claude-settings.json # runtime-specific resource
```
The only required file is `agent.yaml`. Everything else is referenced by paths in the spec and must exist at those relative paths.
---
## Shipped Examples
| Agent | Location | Imports | Profile Skills | Purpose |
|-------|----------|---------|---------------|---------|
| `shared` | `specs/agents/shared/` | none | — (resource pool only) | Shared skill pool for all built-in agents |
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, mental-model-ha, etc. | TDD implementation agent |
| `qa` | `specs/agents/development/qa/` | `shared` | openrig-user, development-team, etc. | Quality assurance agent |
| `orchestrator` | `specs/agents/orchestration/orchestrator/` | `shared` | openrig-user, orchestration-team, etc. | Rig orchestration lead |
| `reviewer` | `specs/agents/review/reviewer/` | `shared` | openrig-user, review-team, etc. | Independent code reviewer |
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, mental-model-ha, vault-user | Vault domain specialist |
| `design` | `specs/agents/design/` | none | — | Product designer |
+358
View File
@@ -0,0 +1,358 @@
# Agent Startup Guide
Version: 0.2.0
Last validated: 2026-04-11
Applies to: OpenRig 0.1.x
This guide teaches you how to think about what goes into an agent's startup experience — what files to write, where to put them, and how the layering model delivers them. It is an authoring guide, not a schema reference. For field-level details, see `rig-spec.md` and `agent-spec.md`.
---
## Two Categories of Startup
Everything an agent receives at boot time falls into one of two categories:
### Category 1: Context Loading
The agent reads markdown files into its context window. This is the **primary mechanism** in OpenRig today and the one you should invest most of your authoring effort in.
Context loading shapes what the agent knows, believes, and is capable of:
- Who it is (role, identity, pod membership)
- How the team works (culture, communication norms, coordination protocols)
- What the project is (codebase context, architecture, domain knowledge)
- What it can do (skills, SOPs, operational procedures)
- What its environment looks like (services, access credentials, tools)
### Category 2: Deterministic Configuration
The rig spec declaratively installs things into the agent's runtime environment:
- Hooks (git hooks, pre-commit scripts)
- Permissions (`.claude/settings.json` allowlists, approval modes)
- MCPs (Model Context Protocol servers)
- System dependencies (tools, packages)
See the **Current Support Matrix** section at the end of this guide for what is reliable today vs experimental.
**The v0.2.0 recommendation:** Put as much of your setup logic as possible into Category 1 (context loading via markdown files). Describe the desired end state in startup files and let the agent handle the configuration. The deterministic path exists in the spec and will become more reliable over time, but for now the context-loading path is the one that works consistently across runtimes.
---
## Context Loading: What Goes Where
### Skills vs Startup Files — The Key Distinction
**Skills** are reusable SOPs. They teach an agent HOW to do something — how to use OpenRig, how to do TDD, how to run a code review, how to operate Vault. Skills transfer across rigs. A skill you write once can be used by any agent in any rig.
**Startup/guidance files** are rig-specific and role-specific. They tell an agent WHO it is, WHAT it's working on, and HOW this particular team operates. They are authored per-rig and often per-pod or per-member.
| Put in a skill when... | Put in a startup/guidance file when... |
|------------------------|---------------------------------------|
| The knowledge is reusable across rigs | The knowledge is specific to this rig or project |
| It teaches a procedure or methodology | It teaches identity, role, or team context |
| It could be useful to any agent of this type | It's only useful to agents in this specific topology |
| Examples: `openrig-user`, `test-driven-development`, `vault-user` | Examples: `role.md`, `CULTURE.md`, `startup/context.md` |
### The Belt-and-Suspenders Pattern for Skills
Skills are delivered to agents through two parallel paths:
1. **Spec declaration:** The agent spec's `resources.skills` + profile `uses.skills` ensures the skill files are projected to the agent's workspace (installed before harness boot)
2. **Startup instruction:** The guidance/startup files tell the agent to actually read and load those skills
Both paths are important. The spec projection ensures the files are physically present. The startup instruction ensures the agent knows to read them. This redundancy is intentional — it handles cases where one path fails.
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, mental-model-ha" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
### The File Types
**Role guidance (`guidance/role.md`)**
Tells the agent who it is and what its responsibilities are:
- Title and primary function
- Specific responsibilities (bulleted list)
- Working rhythm (how the agent operates day-to-day)
- Principles (behavioral guidelines)
- Relationship to other team members
This is the agent's identity document. Every agent should have one.
**Rig culture (`CULTURE.md`)**
The rig-wide constitution. Applied to ALL agents in the rig:
- Communication norms (use `rig send`/chatroom, not raw tmux)
- Coordination protocol (how work flows between pods)
- Quality standards (what "done" means)
- Commit/merge policy
- Escalation rules
Think of this as the team operating manual that every team member reads on day one.
**Startup context (`startup/context.md`)**
Boot-time grounding information specific to this agent's operating environment:
- Identity recovery instructions (`rig whoami --json`)
- Environment details (service URLs, access credentials, ports)
- System check instructions (what should be running, how to verify)
- Role-specific delegation information (who to ask, who delegates to you)
This is especially important for managed-app specialists (e.g., a Vault specialist needs to know the Vault address and dev token).
**Project documentation**
Rig-level startup files that teach the agent about the project:
- Architecture overview
- Key conventions
- Domain vocabulary
- Recent context (what's been happening, what's in progress)
These go in rig-level or pod-level startup blocks and are delivered to relevant agents.
---
## The Layering Model
Startup content is merged additively through layers. Each layer adds to what the previous layers provided. Later layers do NOT replace earlier layers — they append.
### The Layers (in delivery order)
```
1. Agent layer — from the AgentSpec's top-level startup block
2. Profile layer — from the active profile's startup block
3. Rig layer — from the RigSpec's top-level startup block
4. Culture layer — from the RigSpec's culture_file
5. Pod layer — from the pod's startup block
6. Member layer — from the member's startup block in the RigSpec
7. Operator layer — injected at runtime (openrig-start overlay, context collector, etc.)
```
### What Each Layer Is For
| Layer | Authored by | Purpose | Example content |
|-------|-------------|---------|-----------------|
| Agent | Agent spec author | Core identity and capabilities that travel with this agent type | Role guidance, default skills |
| Profile | Agent spec author | Profile-specific variations | Different skill sets for "default" vs "minimal" profiles |
| Rig | Rig spec author | Rig-wide context for all agents | Team norms, project documentation |
| Culture | Rig spec author | Rig constitution | `CULTURE.md` — communication, quality, operating philosophy |
| Pod | Rig spec author | Pod-specific coordination context | Pod SOP, intra-pod workflow |
| Member | Rig spec author | Individual member overrides | Member-specific instructions, cwd-specific context |
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md`, context collector |
### Practical Guidance
**Most rigs only need three layers:** agent (role.md), rig (culture), and operator (openrig-start). Start simple. Add pod and member layers only when agents in the same pod need different startup content.
**The culture layer is high-value and often skipped.** A rig without a `CULTURE.md` relies on agents to guess how the team communicates and coordinates. Write one. Even a short culture file dramatically improves team coherence.
**Member-level startup is for exceptions, not the rule.** If every member has its own startup block, the layering model is being used as a configuration dump. Refactor shared content up to the pod or rig level.
---
## Delivery Mechanisms
### How Files Reach the Agent
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Before harness boot | System chooses | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged into `CLAUDE.md` / `AGENTS.md` as a managed block | Role guidance, culture, project context |
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
### Delivery Timing Matters
Files delivered via `guidance_merge` and `skill_install` happen BEFORE the agent's harness boots. The agent sees them immediately when it starts — they're part of the initial context.
Files delivered via `send_text` happen AFTER the harness is ready. The agent receives them as messages in its terminal. Use this for:
- Identity grounding (agent reads and processes the instructions)
- Instructions to load skills (the files are already projected, the message tells the agent to read them)
- Context that should feel like an operator briefing, not pre-loaded content
### The `applies_on` Field
Each startup file and action specifies when it applies:
- `fresh_start` — delivered on first launch only
- `restore` — delivered on restore from snapshot
- Default: `[fresh_start, restore]` (both)
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
---
## Deterministic Configuration
The AgentSpec and RigSpec allow declaring deterministic environment configuration:
### What the Spec Supports
**Hooks** (in `resources.hooks`):
```yaml
resources:
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
```
Hooks are scripts copied to the agent's workspace. They can be git hooks, automation scripts, or environment setup.
**Runtime resources** (in `resources.runtime_resources`):
```yaml
resources:
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.json
runtime: claude-code
type: settings
```
Runtime-specific configuration files projected into the agent's runtime environment.
**Startup actions** (in `startup.actions`):
```yaml
startup:
actions:
- type: send_text
value: "/install-mcp my-server"
phase: after_ready
idempotent: true
```
Commands sent to the agent's terminal after it's ready. Can install MCPs, run setup commands, etc.
### Current Support Matrix (OpenRig 0.1.x)
| Capability | Status | Notes |
|------------|--------|-------|
| Guidance file projection (`guidance_merge`) | **Supported** | Reliable. Primary delivery mechanism. |
| Skill projection (`skill_install`) | **Supported** | Reliable. Skills are copied to workspace. |
| `send_text` delivery after ready | **Supported** | Reliable. Requires harness to be ready. |
| Hook projection | **Experimental** | Files are copied but execution/integration varies by runtime. |
| Runtime resource projection | **Experimental** | Files are projected but runtime-specific handling is not guaranteed. |
| Permission allowlisting | **Supported (manual)** | Works via `~/.claude/settings.json` or Codex launch flags. OpenRig can describe the desired state; agent self-configures. |
| MCP installation | **Experimental** | Claude Code: `/mcp` interactive command or `claude mcp add` from CLI. Can also be described in startup files for agent self-configuration. Reliability depends on runtime TUI state. |
| System dependency installation | **Not deterministic** | Describe in startup files; agent handles via shell commands. |
| Recurring tasks / wake timers | **Runtime-dependent** | Claude Code supports recurring tasks via the `/loop` command. Codex does not have a confirmed equivalent. Orchestrators should include `/loop` instructions in startup for Claude Code agents. |
### The v0.2.0 Approach: Describe, Then Let the Agent Handle It
For anything beyond `guidance_merge`, `skill_install`, and `send_text`, the recommended approach is:
1. **Describe the desired end state** in a startup file (e.g., "You need these MCP servers configured, these permissions set, these hooks installed")
2. **Include a system check** in the startup instructions ("Verify your environment: check that X is installed, Y is configured, Z is accessible")
3. **Empower the agent to self-configure** ("If any of these are missing, install/configure them")
4. **Optionally also declare it in the spec** for when deterministic support improves — the spec serves as the blueprint, and the startup file serves as the fallback instruction
This way, when deterministic support becomes fully reliable, the agent will boot up, see that everything is already set up (by the deterministic path), run its system check, confirm everything looks good, and proceed. Until then, the agent reads the instructions and handles the setup itself.
### Runtime Config Disclosure
OpenRig now performs some best-effort deterministic runtime configuration for managed sessions. These writes are intentionally invasive and should be disclosed plainly:
- Claude global config: `~/.claude/settings.json`
Purpose: allow `rig` commands without repeated Claude permission prompts.
- Claude global state: `~/.claude.json`
Purpose: pre-trust managed workspaces and mark onboarding complete for fresh managed sessions.
- Claude project-local config: `.claude/settings.local.json`
Purpose: apply managed-session Claude permissions inside the project without committing them to git.
- Claude project-local MCP config: `.mcp.json`
Purpose: configure OpenRig-managed MCP servers for Claude in that project.
- Codex global config: `~/.codex/config.toml`
Purpose: pre-trust managed workspaces and configure Codex MCP servers. Codex currently has no equivalent project-local MCP config path.
Two important caveats:
- these writes are best-effort and should still be paired with startup guidance so the local agent can verify and repair them if needed
- already-running adopted sessions may need restart before they pick up newly written config
### Runtime Differences
**Claude Code:**
- Reads from `CLAUDE.md` and `.claude/` directory
- Recurring tasks via the `/loop` command (e.g., `/loop 5m "check rig health"`) — this is NOT hooks; hooks are event-driven
- MCP server management via `/mcp` interactive command or `claude mcp add` from CLI
- Event-driven hooks system in `.claude/settings.json` — reacts to events like `PreToolUse`, `PostToolUse`, `SessionStart`, etc. (not time-based)
- Permission allowlisting via `.claude/settings.json` (`permissions.allow`, `permissions.deny`, `permissions.defaultMode`)
- Can self-configure MCP servers, permissions, and hooks from startup instructions
**Codex:**
- Reads from `AGENTS.md` and `.agents/` directory
- Recurring task support is limited — no confirmed equivalent of Claude Code's `/loop` command
- MCP configuration mechanism differs from Claude Code
- Approval mode controlled via launch flags (`-a`, `-s`, `--full-auto`)
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
---
## Patterns and Anti-Patterns
### Good Patterns
**Start with role + culture + one skill**
```
agent.yaml → guidance/role.md
rig.yaml → culture_file: CULTURE.md
profile → uses.skills: [openrig-user]
```
This is the minimum effective startup. The agent knows who it is, how the team works, and how to use the rig.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
**Tell the agent about its skills explicitly**
In a startup file or guidance, include a line like:
```
You have the following skills loaded: openrig-user, test-driven-development, mental-model-ha. Use them.
```
This prompts the agent to actually invoke the skills, not just have them as passive context.
**Include a system check in startup context**
```
## System Check
After identity recovery, verify:
1. `rig ps --nodes` shows your rig running
2. `rig env status` shows services healthy (if applicable)
3. Your working directory is correct
4. Required tools are available (node, npm, git, etc.)
If anything is missing, fix it before starting work.
```
### Anti-Patterns
**Dumping everything into one giant CLAUDE.md**
Don't. Use the layering model. Role goes in the agent spec. Culture goes in the rig spec. Project context goes in rig/pod startup files. If everything is in one file, you can't reuse any of it.
**Duplicating skill content in guidance files**
If you find yourself copying text from a skill into a guidance file, stop. Reference the skill instead. Skills are projected; guidance should point to them, not duplicate them.
**Using send_text for content that should be pre-loaded**
If the agent needs to know something BEFORE it starts reasoning, use `guidance_merge` (delivered before boot), not `send_text` (delivered after boot). `send_text` is for instructions the agent should process as a first task, not for foundational context.
**Over-specifying member-level startup**
If every member has a large startup block, the rig spec becomes a configuration dump. Refactor shared content up to the pod level. Member-level startup should be small overrides, not complete agent briefings.
**Relying on deterministic hooks for critical setup**
If your rig REQUIRES a hook to function and the hook installation fails silently, the agent won't know something is wrong. Always pair deterministic config with a startup instruction or system check that verifies the result.
---
## Authoring Checklist
When creating a new agent's startup experience:
- [ ] Write a `guidance/role.md` — who is this agent?
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
- [ ] Write a rig `CULTURE.md` if the rig doesn't have one
- [ ] Choose skills from the shared pool via profile `uses`
- [ ] Write a `startup/context.md` if the agent needs environment grounding
- [ ] Include a system check in the startup context
- [ ] Verify the agent spec validates: `rig agent validate agent.yaml`
- [ ] Verify the rig spec validates: `rig spec validate rig.yaml`
- [ ] Test by launching the rig and checking that the agent received the expected content
+126
View File
@@ -0,0 +1,126 @@
# Edge Types Reference
Version: 0.2.0
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`
---
## Overview
Edges define relationships between members in a rig topology. They appear in two places:
- **Pod-local edges** — between members in the same pod (use unqualified member IDs)
- **Cross-pod edges** — between members in different pods (use `pod.member` format)
See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by the validator:
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
| `delegates_to` | yes | **yes — affects launch order** | Source delegates work to target |
| `spawned_by` | yes | **yes — affects launch order** | Target was spawned by source |
| `can_observe` | yes | no | Source can observe target's output |
| `collaborates_with` | yes | no | Peer collaboration |
| `escalates_to` | yes | no | Source escalates to target |
## What Edges Actually Do Today (OpenRig 0.1.x)
### Launch ordering
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
This ordering is enforced in both `PodRigInstantiator` (initial launch) and `RestoreOrchestrator` (restore from snapshot). The code uses topological sort over the dependency graph — if there's a cycle, instantiation fails.
All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT constrain launch order.
### Graph visualization
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
### Identity projection
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
### Attach hint heuristic
The post-command handoff (the "Attach:" line after `rig up` and `rig restore`) uses edges to prefer the orchestrator as the default attach target. The heuristic looks for the first node with `delegates_to` outgoing edges.
## What Edges Do NOT Do Today
Edges do NOT currently:
- **Route messages** — `rig send` can target any session, regardless of edges
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
These are aspirational capabilities. The edge vocabulary is intentionally richer than the current runtime behavior so that the topology captures design intent even before the runtime enforces it.
## Design Intent (Why We Have Five Kinds)
The five kinds represent a taxonomy of how agents relate to each other in a working team:
**`delegates_to`** — the most common edge. An orchestrator delegates to an implementer. A lead delegates to a worker. This is the primary workflow direction. When an orchestrator sends a task, it uses `rig send` to the session it `delegates_to`.
**`spawned_by`** — for hierarchical launch relationships. A subagent spawned by a parent process. Less common in current topologies.
**`can_observe`** — the review/oversight relationship. A reviewer observes an implementer's work. The reviewer can `rig capture` and `rig transcript` the implementation session. This edge communicates intent: "I'm watching your output."
**`collaborates_with`** — peer relationships. Two agents working side-by-side at the same level of the hierarchy. Neither delegates to the other.
**`escalates_to`** — the reverse of delegation. When a worker hits a decision it can't make, it escalates to a lead. This is less common in current topologies but represents a real coordination pattern.
## Choosing Edge Kinds
### Common Patterns
**Orchestrator → workers:**
```yaml
edges:
- kind: delegates_to
from: orch.lead
to: dev.impl
```
**Reviewer → implementer:**
```yaml
edges:
- kind: can_observe
from: rev.r1
to: dev.impl
```
**Implementation pair:**
```yaml
edges:
- kind: delegates_to
from: impl
to: qa
```
### When You're Not Sure
If you're unsure which edge kind to use:
1. Does one agent give work to the other? → `delegates_to`
2. Does one agent watch the other's output? → `can_observe`
3. Are they equals working together? → `collaborates_with`
4. Does one report problems up? → `escalates_to`
5. Did one create the other? → `spawned_by`
If none fit, `can_observe` is the safest default — it documents the relationship without implying a workflow direction and doesn't affect launch ordering.
## Validation Rules
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
5. Self-edges are not validated at the schema level but are not meaningful
+367
View File
@@ -0,0 +1,367 @@
# RigBundle Reference
Version: 2 (pod-aware)
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/bundle-types.ts`, `packages/daemon/src/domain/bundle-archive.ts`, `packages/daemon/src/domain/pod-bundle-assembler.ts`
A `.rigbundle` is a self-contained distributable archive that packages a rig spec, all referenced agent specs, their resources (skills, guidance, startup files), culture file, documentation, and an integrity manifest into a single file. The recipient can install and launch the rig without needing the original source tree.
---
## Archive Format
A `.rigbundle` file is a gzip-compressed tar archive (`.tar.gz`) with a fixed structure.
### File extension
The archive MUST have the `.rigbundle` extension. The packer rejects output paths that don't end with `.rigbundle`.
### Sibling digest file
Every `.rigbundle` has a sibling `.rigbundle.sha256` file containing the SHA-256 hex digest of the archive. This detects corruption during transfer. The unpacker verifies this digest before extraction.
Example:
```
my-rig.rigbundle — the archive
my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
```
### Determinism
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode normalizes uid/gid/mode
- Maximum gzip compression (level 9)
This means the same inputs always produce the same archive hash.
---
## Archive Layout
```
bundle.yaml — manifest (required)
rig.yaml — the RigSpec (required, may be rewritten)
CULTURE.md — culture file (if declared in rig spec)
SETUP.md — documentation (if declared in rig spec docs field)
agents/
agent-name/
agent.yaml — AgentSpec
guidance/
role.md — guidance files
skills/
skill-name/
SKILL.md — skill files
startup/
context.md — startup files
```
### Key rules
- `bundle.yaml` is the manifest — always present, always at the root
- `rig.yaml` is the rig spec — rewritten during assembly with vendored `agent_ref` paths
- Agent directories are vendored copies of the original agent specs with all their resources
- Import refs are rewritten from the original `local:` or `path:` paths to bundle-relative `local:` paths
- All file paths within the archive are safe relative paths (no `..`, no absolute, no symlinks)
---
## Manifest (`bundle.yaml`)
The manifest is a YAML file at the archive root that describes the bundle contents.
### Schema Version 2 (pod-aware, current)
```yaml
schema_version: 2
name: my-bundle
version: "0.1.0"
created_at: "2026-04-11T22:32:48.570Z"
rig_spec: rig.yaml
agents:
- name: pm-lead
version: "1.0"
path: agents/pm-lead
original_ref: "local:agents/pm-lead"
hash: "ed4cff20..."
import_entries: []
- name: researcher
version: "1.0"
path: agents/researcher
original_ref: "local:agents/researcher"
hash: "11f8a077..."
import_entries:
- name: shared
version: "1.0"
path: agents/shared
original_ref: "local:../../shared"
hash: "abc123..."
culture_file: CULTURE.md
integrity:
algorithm: sha256
files:
rig.yaml: "b80c0674..."
CULTURE.md: "9354361b..."
agents/pm-lead/agent.yaml: "ed4cff20..."
# ... every file in the archive
```
### Manifest Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `schema_version` | number | yes | Must be `2` for pod-aware bundles. |
| `name` | string | yes | Bundle name. |
| `version` | string | yes | Bundle version. |
| `created_at` | string | yes | ISO-8601 timestamp of creation. |
| `rig_spec` | string | yes | Relative path to the rig spec within the archive. Safe relative path. |
| `agents` | AgentEntry[] | yes | Array of vendored agent entries. |
| `culture_file` | string | no | Relative path to the culture file if present. |
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. |
### Agent Entry Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Agent name (from agent.yaml). |
| `version` | string | no | Agent version. |
| `path` | string | yes | Relative path to the vendored agent directory. Safe relative path. |
| `original_ref` | string | yes | The original `agent_ref` before rewriting. |
| `hash` | string | yes | SHA-256 hash of the agent.yaml content. |
| `import_entries` | ImportEntry[] | yes | Vendored imports for this agent (may be empty). |
### Import Entry Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Imported agent name. |
| `version` | string | yes | Imported agent version. |
| `path` | string | yes | Relative path to the vendored import within the archive. |
| `original_ref` | string | yes | The original import ref before rewriting. |
| `hash` | string | yes | SHA-256 hash of the imported agent.yaml. |
### Integrity Section
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `algorithm` | string | yes | Must be `sha256`. |
| `files` | map<string, string> | yes | Map of archive-relative file path → SHA-256 hex hash. Every file in the archive (except `bundle.yaml` itself) should be listed. |
---
## Security Model
Bundle integrity provides **self-consistency verification, not authenticity**.
- The sibling `.sha256` file detects corruption during transfer
- The per-file integrity hashes detect tampering of individual files within the archive
- Neither mechanism authenticates the bundle author
An attacker who can rewrite the full bundle + digest can bypass verification. Users must trust the source they obtained the bundle from. This is the same trust model as unsigned npm packages and Docker images.
Future enhancement: cryptographic signing (Ed25519) for author authentication.
---
## Safety Guarantees
The unpacker enforces these safety rules before extraction:
1. **No symlinks or hardlinks** — `SymbolicLink` and `Link` entries are rejected
2. **No absolute paths** — entries starting with `/` are rejected
3. **No path traversal** — entries containing `..` segments are rejected
4. **Digest verification** — archive SHA-256 must match the sibling `.sha256` file
5. **Content integrity** — after extraction, per-file hashes are verified against the manifest
If any check fails, extraction is aborted and an error is thrown.
---
## CLI Surface
### Create a bundle
```bash
rig bundle create <spec-path> -o <output.rigbundle> [--rig-root <dir>] [--name <name>] [--bundle-version <ver>]
```
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `<spec-path>` | yes | — | Path to the rig spec YAML file. |
| `-o, --output` | yes | — | Output path. Must end with `.rigbundle`. |
| `--rig-root` | no | spec directory | Root directory for resolving `agent_ref` and other relative paths. |
| `--name` | no | `my-bundle` | Bundle name in the manifest. |
| `--bundle-version` | no | `0.1.0` | Bundle version in the manifest. |
The create command:
1. Validates the rig spec
2. Resolves all `agent_ref` paths and their imports
3. Vendors all agent specs, resources, and startup files into a staging directory
4. Rewrites `agent_ref` paths to bundle-relative `local:` refs
5. Collects culture file, docs files, and rig-level startup files
6. Computes per-file integrity hashes
7. Writes the manifest (`bundle.yaml`)
8. Packs into a deterministic `.tar.gz`
9. Writes the sibling `.sha256` digest
### Inspect a bundle
```bash
rig bundle inspect <bundle-path> [--json]
```
Shows the manifest, digest validity, and integrity verification result. Inspect extracts the archive into a temporary directory for safe validation, then cleans that directory up. It does not install the bundle.
### Install a bundle
```bash
rig bundle install <bundle-path> [--plan] [--yes] [--target <root>] [--json]
```
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `<bundle-path>` | yes | — | Path to the `.rigbundle` file. |
| `--plan` | no | `false` | Preview the bootstrap plan without installing or launching. |
| `--yes` | no | `false` | Auto-approve trusted actions during apply mode. |
| `--target <root>` | yes in apply mode | — | Target root directory for package installation. Required unless `--plan` is used. |
| `--json` | no | `false` | Emit machine-readable JSON. |
Extracts the bundle, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` unless you are running with `--plan`.
### Launch directly
```bash
rig up <bundle-path> [--target <root>] [--cwd <dir>]
```
`rig up` auto-detects `.rigbundle` files and routes them through the bundle bootstrap path.
- `--target <root>` controls where packaged files are installed
- if `--target` is omitted for a `.rigbundle`, the CLI defaults the install target to the current working directory
- `--cwd <dir>` does **not** change the install target; it only overrides the launched members' working directory for that run
---
## Assembly Process
When `rig bundle create` runs, the `PodBundleAssembler` performs these steps:
1. **Parse and validate** the rig spec
2. **Collect rig-level files:**
- Culture file (if `culture_file` is set)
- Docs files (if `docs` array is set) — **required: missing docs fail assembly**
- Rig-level startup files
- Pod-level and member-level startup files
3. **For each member's `agent_ref`:**
- Resolve the ref to an agent spec directory
- Copy the agent spec and all its resources (skills, guidance, hooks, startup, runtime resources)
- Recursively resolve and copy imports
- Record the agent entry in the manifest with its hash
- Rewrite the ref to a bundle-relative `local:` path
4. **Write the rewritten rig spec** to the staging directory
5. **Compute integrity** — SHA-256 of every file in the staging directory
6. **Write the manifest** (`bundle.yaml`) with all entries and integrity
7. **Pack** the staging directory into a deterministic tar.gz
### Terminal nodes
Members with `agent_ref: "builtin:terminal"` are bundle-native sentinels. They are not vendored — the runtime handles them directly.
### Deduplication
If multiple members reference the same agent spec (same resolved path), the spec is vendored once and all members' refs are rewritten to the same bundle-relative path.
### Import resolution
When an agent spec has `imports`, each import is resolved, vendored into the bundle, and the import refs in the vendored agent.yaml are rewritten to bundle-relative `local:` paths. Import entries are recorded in the manifest's agent entry.
---
## Validation Rules Summary
### Manifest validation (schema version 2)
1. `schema_version` must be `2`
2. `name` is required non-empty string
3. `version` is required non-empty string
4. `created_at` is required non-empty string
5. `rig_spec` is required and must be a safe relative path
6. `agents` must be an array
7. Each agent must have `name`, `path` (safe relative), and `hash`
8. Integrity `algorithm` must be `sha256`
9. Integrity `files` must be a non-empty map of safe-relative-path → 64-char hex hash
### Archive safety (enforced on unpack)
1. No symlinks or hardlinks
2. No absolute paths
3. No `..` path traversal
4. Archive digest must match sibling `.sha256`
5. Per-file content hashes must match integrity section
### Assembly validation
1. Rig spec must validate
2. All `agent_ref` paths must resolve to valid agent specs
3. All declared `docs` files must exist on disk (missing docs fail assembly)
4. Culture file and startup files are collected best-effort (missing = skipped)
---
## Legacy Bundles (Schema Version 1)
Schema version 1 bundles are the pre-reboot format using flat-node rig specs and package-based bundling. They are still supported for backward compatibility but should not be created for new rigs.
Key differences from v2:
- `schema_version: 1` in the manifest
- `packages` array instead of `agents` array
- Package entries have `original_source` instead of `original_ref`
- No `import_entries` in package entries
- Legacy rig spec format (flat nodes, not pods)
---
## Example: Creating and Using a Bundle
### Create
```bash
# From the rig directory
rig bundle create rig.yaml -o my-team.rigbundle --rig-root . --name my-team --bundle-version 1.0.0
```
Output:
```
Bundle created: my-team.rigbundle
Name: my-team v1.0.0
Hash: a1b2c3d4e5f6...
```
### Inspect
```bash
rig bundle inspect my-team.rigbundle
```
Output:
```
Bundle: my-team v1.0.0
Digest valid: true
Integrity: PASS
```
### Install and launch
```bash
cd ~/projects/my-project
rig up /path/to/my-team.rigbundle
```
Equivalent explicit form:
```bash
rig up /path/to/my-team.rigbundle --target ~/projects/my-project
```
The bundle is extracted to a temporary directory, integrity is verified, packaged files are installed into the target root, and the rig is bootstrapped with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
+479
View File
@@ -0,0 +1,479 @@
# RigSpec Reference
Version: 0.2 (pod-aware)
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
---
## Minimal Valid Example
```yaml
version: "0.2"
name: my-rig
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: "."
edges: []
edges: []
```
## Complete Example (all features)
```yaml
version: "0.2"
name: my-product-team
summary: A full product squad with orchestration, development, and review pods.
culture_file: culture/CULTURE.md
docs:
- path: SETUP.md
- path: README.md
startup:
files:
- path: guidance/team-norms.md
delivery_hint: guidance_merge
required: true
actions: []
services:
kind: compose
compose_file: docker-compose.yaml
project_name: my-product
profiles: [core]
down_policy: down
wait_for:
- url: http://127.0.0.1:5432/health
- service: redis
condition: healthy
surfaces:
urls:
- name: App
url: http://127.0.0.1:3000
commands:
- name: psql
command: "psql postgresql://app:[email protected]:5432/app"
checkpoints:
- id: postgres
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
pods:
- id: orch
label: Orchestration
members:
- id: lead
agent_ref: "local:agents/orchestrator"
profile: default
runtime: claude-code
cwd: "."
- id: peer
agent_ref: "local:agents/orchestrator"
profile: default
runtime: codex
cwd: "."
edges: []
- id: dev
label: Development
summary: Implementation and quality assurance pair.
continuity_policy:
enabled: true
sync_triggers: [pre_compaction, pre_shutdown]
artifacts:
session_log: true
restore_brief: true
restore_protocol:
peer_driven: true
verify_via_quiz: false
startup:
files:
- path: guidance/dev-sop.md
delivery_hint: guidance_merge
required: true
actions: []
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: "."
label: "Implementation Lead"
model: claude-opus-4-6
restore_policy: resume_if_possible
startup:
files:
- path: guidance/impl-specific.md
delivery_hint: send_text
required: false
applies_on: [fresh_start]
actions:
- type: send_text
value: "Load the implementation-pair skill and begin."
phase: after_ready
idempotent: true
- id: qa
agent_ref: "local:agents/qa"
profile: default
runtime: codex
cwd: "."
edges:
- kind: delegates_to
from: impl
to: qa
- id: rev
label: Review
members:
- id: r1
agent_ref: "local:agents/reviewer"
profile: default
runtime: claude-code
cwd: "."
- id: r2
agent_ref: "local:agents/reviewer"
profile: default
runtime: codex
cwd: "."
edges: []
edges:
- kind: delegates_to
from: orch.lead
to: dev.impl
- kind: delegates_to
from: orch.peer
to: dev.qa
- kind: can_observe
from: rev.r1
to: dev.impl
- kind: can_observe
from: rev.r2
to: dev.qa
```
---
## Top-Level Fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `version` | string | yes | — | Must be `"0.2"` for pod-aware specs. |
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. |
| `summary` | string | no | — | Human-readable description. Shown in spec library, review surfaces, and `rig specs show`. |
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
| `services` | ServicesBlock | no | — | Optional managed services (Docker Compose). When present, services boot before any agent launches. |
| `pods` | Pod[] | yes | — | At least one pod required. Each pod is a bounded context containing members and pod-local edges. |
| `edges` | CrossPodEdge[] | no | `[]` | Cross-pod edges connecting members in different pods. Must use fully-qualified `pod.member` IDs. |
---
## Pod
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Pod identifier. Must not contain dots. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
| `label` | string | yes | — | Human-readable pod name. Shown in UI explorer, graph groupings, and detail surfaces. |
| `summary` | string | no | — | Pod description. |
| `continuity_policy` | ContinuityPolicy | no | — | Pod-level continuity/restore policy. Controls compaction recovery, artifact management, and peer-driven restoration. |
| `startup` | StartupBlock | no | — | Pod-level startup files and actions. Applied to all members in this pod via the startup layering model. |
| `members` | Member[] | yes | — | At least one member required (enforced by pods needing content). |
| `edges` | PodLocalEdge[] | no | `[]` | Edges between members within this pod. Must use unqualified member IDs (not `pod.member`). |
### Pod ID Rules
- Must not contain dots (`.`)
- Must be unique across all pods in the rig
- Becomes the first segment of the qualified logical ID: `{podId}.{memberId}`
- Becomes the first segment of the canonical session name: `{podId}-{memberId}@{rigName}`
---
## Member
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Member identifier. Must not contain dots. Must be unique within the pod. |
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `terminal`. |
| `cwd` | string | yes | — | Working directory for the agent. Resolved relative to the rig root (the directory containing the rig spec). Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. |
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
| `restore_policy` | string | no | `resume_if_possible` | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. |
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
### Terminal Nodes
Terminal nodes are infrastructure processes (servers, log tails, build watchers) that are not agent runtimes. They require an exact triple:
```yaml
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
```
All three must be present together. Any partial combination is a validation error.
### agent_ref Rules
- Must start with `local:` or `path:`
- `local:` paths are relative to the rig spec file's directory (the rig root)
- `path:` paths are absolute filesystem paths
- The referenced path must contain an `agent.yaml` file
- Exception: `builtin:terminal` for terminal nodes
### Session Naming
The canonical session name is derived from the pod ID, member ID, and rig name:
```
{podId}-{memberId}@{rigName}
```
Example: pod `dev`, member `impl`, rig `my-team` → session `dev-impl@my-team`
This is human-authored (you choose the pod/member IDs) and system-validated (the system enforces the format).
---
## Edges
### Edge Kinds
| Kind | Meaning | Use When |
|------|---------|----------|
| `delegates_to` | Source delegates work to target. Constrains launch order. | Orchestrator → implementer, lead → worker |
| `spawned_by` | Target was spawned by source. Constrains launch order. | Parent → child in hierarchical topologies |
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
### Pod-Local Edges
Edges within a pod use **unqualified member IDs** (just the member `id`, not `pod.member`):
```yaml
pods:
- id: dev
members:
- id: impl
# ...
- id: qa
# ...
edges:
- kind: delegates_to
from: impl # NOT dev.impl
to: qa # NOT dev.qa
```
Both `from` and `to` must reference members that exist in the same pod.
### Cross-Pod Edges
Edges between pods use **fully-qualified `pod.member` IDs**:
```yaml
edges:
- kind: delegates_to
from: orch.lead # pod.member format
to: dev.impl # pod.member format
```
Cross-pod edges must reference different pods. An edge where both `from` and `to` are in the same pod is a validation error — use pod-local edges instead.
---
## Startup Block
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see `docs/reference/startup-layering.md`).
### Files
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | yes | — | Relative path to the file. Must be a safe relative path. |
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
#### Delivery Hints
| Hint | Behavior |
|------|----------|
| `auto` | System chooses based on file type and context. |
| `guidance_merge` | Merged into the runtime's guidance file (`CLAUDE.md` or `AGENTS.md`) as a managed block. Delivered before harness boot. |
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
### Actions
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`. Note: `shell` is explicitly NOT supported in v1. |
| `value` | string | yes | — | The command or text to send. |
| `phase` | string | no | `after_files` | When to execute. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). |
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
---
## Services Block
The services block is optional. When present, services boot before any agent node launches. If service health checks fail, agent launch is blocked.
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `kind` | string | yes | — | Service backend. Only `compose` is supported in v1. |
| `compose_file` | string | yes | — | Relative path to the Docker Compose file. Must be a safe relative path. Resolved relative to rig root. |
| `project_name` | string | no | derived from rig name | Docker Compose project name. Must match `[a-z0-9][a-z0-9_-]*`. If omitted, derived by sanitizing the rig name. |
| `profiles` | string[] | no | — | Compose profiles to activate. |
| `down_policy` | string | no | `down` | What happens on `rig down`. One of: `leave_running`, `down`, `down_and_volumes`. |
| `wait_for` | WaitTarget[] | no | — | Health targets that must pass before agent launch. |
| `surfaces` | Surfaces | no | — | Metadata about accessible URLs and commands. Not executed — informational only. |
| `checkpoints` | CheckpointHook[] | no | — | Shell commands for checkpoint export/import during snapshot/restore. |
### Wait Targets
Each target must define exactly one of `service`, `url`, or `tcp`:
```yaml
wait_for:
# HTTP probe — hits the URL, expects 2xx
- url: http://127.0.0.1:8200/v1/sys/health
# TCP probe — connects to host:port
- tcp: "127.0.0.1:5432"
# Compose health check — requires Docker health to report "healthy"
- service: postgres
condition: healthy
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | one of three | HTTP URL to probe. |
| `tcp` | string | one of three | `host:port` for TCP probe. |
| `service` | string | one of three | Compose service name. Requires `condition: healthy`. |
| `condition` | string | only with `service` | Must be `healthy`. Only valid with `service` targets. |
### Surfaces
```yaml
surfaces:
urls:
- name: Vault UI
url: http://127.0.0.1:8200/ui
commands:
- name: Vault status
command: "vault status -address=http://127.0.0.1:8200"
```
Surfaces are metadata only. They are displayed in the UI and in `rig env status` output but are NOT executed by OpenRig.
### Checkpoint Hooks
```yaml
checkpoints:
- id: postgres
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | yes | Unique identifier for this checkpoint. |
| `export` | string | yes | Shell command to export state. `{{artifacts_dir}}` is replaced with a daemon-managed path. |
| `import` | string | no | Shell command to import state on restore. |
Checkpoint hooks are shell commands run by the daemon. They are best-effort — a failed export does not block snapshot, but continuity is classified as `receipt_only` instead of `checkpointed`.
---
## Continuity Policy
Optional pod-level configuration for compaction recovery behavior.
```yaml
continuity_policy:
enabled: true
sync_triggers: [pre_compaction, pre_shutdown, manual, milestone]
artifacts:
session_log: true
restore_brief: true
quiz: false
restore_protocol:
peer_driven: true
verify_via_quiz: false
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `enabled` | boolean | yes | — | Whether continuity is active for this pod. |
| `sync_triggers` | string[] | no | — | When to sync. Values: `pre_compaction`, `pre_shutdown`, `manual`, `milestone`. |
| `artifacts.session_log` | boolean | no | — | Whether to maintain a session log. |
| `artifacts.restore_brief` | boolean | no | — | Whether to maintain a restore brief. |
| `artifacts.quiz` | boolean | no | — | Whether to use quiz-based verification. |
| `restore_protocol.peer_driven` | boolean | no | — | Whether peers drive the restore process. |
| `restore_protocol.verify_via_quiz` | boolean | no | — | Whether to verify restoration via quiz. |
---
## Validation Rules Summary
These rules are enforced by the validator. A spec that violates any of these will be rejected by `rig spec validate` and `rig up`.
1. `version` and `name` are required non-empty strings.
2. `pods` must be a non-empty array.
3. Pod IDs must not contain dots and must be unique.
4. Pod labels are required.
5. Member IDs must not contain dots and must be unique within their pod.
6. `agent_ref`, `profile`, `runtime`, and `cwd` are required for every member.
7. Terminal nodes require the exact triple: `runtime: terminal`, `agent_ref: builtin:terminal`, `profile: none`.
8. `agent_ref` must start with `local:` (relative) or `path:` (absolute), except `builtin:terminal`.
9. `local:` refs must be relative paths. `path:` refs must be absolute paths.
10. `restore_policy` must be one of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`.
11. Pod-local edges use unqualified member IDs. Cross-pod edges use `pod.member` format.
12. Cross-pod edges must reference different pods.
13. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`.
14. All file paths (`culture_file`, startup file paths, `compose_file`) must be safe relative paths.
15. `services.kind` must be `compose`.
16. `services.compose_file` is required when services is present.
17. `services.project_name` must match `[a-z0-9][a-z0-9_-]*`.
18. `services.down_policy` must be one of: `leave_running`, `down`, `down_and_volumes`.
19. Each wait target must define exactly one of: `service`, `url`, `tcp`.
20. `condition` is only valid on `service` targets and must be `healthy`.
21. Startup file `delivery_hint` must be one of: `auto`, `guidance_merge`, `skill_install`, `send_text`.
22. Startup action `type` must be one of: `slash_command`, `send_text`. (`shell` is explicitly rejected.)
23. Startup action `phase` must be one of: `after_files`, `after_ready`.
24. Startup action `idempotent` is a required boolean.
25. Non-idempotent actions must not include `restore` in `applies_on`.
26. `applies_on` values must be from: `fresh_start`, `restore`.
---
## Shipped Examples
These are the built-in specs shipped with OpenRig. Read them as worked examples.
| Spec | Location | Pods | Members | Services |
|------|----------|------|---------|----------|
| `product-team` | `packages/daemon/specs/rigs/product-team.yaml` | orch, dev, rev | 7 (lead, peer, impl, qa, design, r1, r2) | no |
| `implementation-pair` | `packages/daemon/specs/rigs/implementation-pair.yaml` | dev | 2 (impl, qa) | no |
| `adversarial-review` | `packages/daemon/specs/rigs/adversarial-review.yaml` | orch, rev | 3 (lead, r1, r2) | no |
| `research-team` | `packages/daemon/specs/rigs/research-team.yaml` | orch, research | 3 (lead, analyst, synthesizer) | no |
| `secrets-manager` | `packages/daemon/specs/rigs/launch/secrets-manager/rig.yaml` | vault | 1 (specialist) | yes (Vault) |
+15 -57
View File
@@ -1,12 +1,13 @@
{
"name": "openrig",
"version": "0.1.3",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.1.3",
"version": "0.1.10",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
"packages/ui",
@@ -470,13 +471,11 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -488,13 +487,11 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -506,13 +503,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -524,13 +519,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -542,13 +535,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -560,13 +551,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -578,13 +567,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -596,13 +583,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -614,13 +599,11 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -632,13 +615,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -650,13 +631,11 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -668,13 +647,11 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -686,13 +663,11 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -704,13 +679,11 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -722,13 +695,11 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -740,13 +711,11 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -758,13 +727,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -776,13 +743,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -794,13 +759,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -812,13 +775,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -830,13 +791,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -848,13 +807,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -866,13 +823,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -884,13 +839,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -902,13 +855,11 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -920,13 +871,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4592,6 +4541,12 @@
"node": ">=6"
}
},
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -7379,7 +7334,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.1.3",
"version": "0.1.12",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7387,6 +7342,7 @@
"better-sqlite3": "^11.8.0",
"commander": "^13.0.0",
"hono": "^4.7.0",
"jsonc-parser": "^3.3.1",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3",
@@ -7403,7 +7359,8 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.1.3",
"version": "0.1.10",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"better-sqlite3": "^11.8.0",
@@ -7423,7 +7380,8 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.1.3",
"version": "0.1.10",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
+5 -2
View File
@@ -1,7 +1,8 @@
{
"name": "openrig",
"version": "0.1.3",
"version": "0.1.10",
"private": true,
"license": "Apache-2.0",
"type": "module",
"workspaces": [
"packages/daemon",
@@ -10,7 +11,9 @@
],
"scripts": {
"build": "npm run build --workspaces",
"test": "npm run test --workspaces",
"test": "npm run test:repo && npm run test:workspaces",
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs",
"test:workspaces": "npm run test --workspaces",
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json",
"build:package": "bash scripts/build-package.sh"
},
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.1.3",
"version": "0.1.12",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"keywords": [
@@ -45,6 +45,7 @@
"better-sqlite3": "^11.8.0",
"commander": "^13.0.0",
"hono": "^4.7.0",
"jsonc-parser": "^3.3.1",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3",
+50 -1
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env node
import path from "node:path";
import { realpathSync } from "node:fs";
import { existsSync, realpathSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
const packageRoot = path.resolve(path.dirname(wrapperPath), "..");
@@ -19,6 +22,51 @@ export function isDirectRun(argv1 = process.argv[1], moduleUrl = import.meta.url
}
}
export function resolveNodeReexecBinary(
currentExecPath = process.execPath,
invokedPath = process.argv[1],
env: NodeJS.ProcessEnv = process.env,
exists: (candidate: string) => boolean = existsSync,
realpath: (candidate: string) => string = realpathSync,
): string | null {
if (!invokedPath) return null;
if (env[REEXEC_GUARD_ENV] === "1") return null;
try {
const binDir = realpath(path.dirname(invokedPath));
const siblingNode = path.join(binDir, process.platform === "win32" ? "node.exe" : "node");
if (!exists(siblingNode)) return null;
const normalizedSiblingNode = realpath(siblingNode);
const normalizedCurrentExec = realpath(currentExecPath);
if (normalizedSiblingNode === normalizedCurrentExec) return null;
return normalizedSiblingNode;
} catch {
return null;
}
}
function maybeReexecWithSiblingNode(argv = process.argv): void {
const preferredNode = resolveNodeReexecBinary(process.execPath, argv[1], process.env);
if (!preferredNode) return;
const scriptPath = argv[1] ? realpathSync(argv[1]) : fileURLToPath(import.meta.url);
const result = spawnSync(preferredNode, [scriptPath, ...argv.slice(2)], {
stdio: "inherit",
env: {
...process.env,
[REEXEC_GUARD_ENV]: "1",
},
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 0);
}
export async function run(argv = process.argv): Promise<void> {
const normalizedArgv = [...argv];
if (normalizedArgv[1]) {
@@ -31,5 +79,6 @@ export async function run(argv = process.argv): Promise<void> {
}
if (isDirectRun()) {
maybeReexecWithSiblingNode(process.argv);
await run(process.argv);
}
+24 -19
View File
@@ -14,6 +14,10 @@ export interface DaemonResponse<T = unknown> {
data: T;
}
interface DaemonRequestOptions {
timeoutMs?: number;
}
interface DaemonClientOptions {
fetchImpl?: typeof fetch;
timeoutMs?: number;
@@ -42,51 +46,52 @@ export class DaemonClient {
this.timeoutMs = options?.timeoutMs ?? 5_000;
}
async get<T = unknown>(path: string): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "GET" });
async get<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "GET" }, options);
}
async getText(path: string): Promise<DaemonResponse<string>> {
return this.requestText(path, { method: "GET" });
async getText(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
return this.requestText(path, { method: "GET" }, options);
}
async post<T = unknown>(path: string, body?: unknown): Promise<DaemonResponse<T>> {
async post<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}, options);
}
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>): Promise<DaemonResponse<T>> {
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, {
method: "POST",
headers: { "Content-Type": contentType, ...extraHeaders },
body: text,
});
}, options);
}
async postExpectText(path: string, body?: unknown): Promise<DaemonResponse<string>> {
async postExpectText(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
return this.requestText(path, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}, options);
}
async delete<T = unknown>(path: string): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "DELETE" });
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "DELETE" }, options);
}
private async fetch(path: string, init: RequestInit): Promise<Response> {
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
try {
return await fetchWithTimeout(
this.fetchImpl,
`${this.baseUrl}${path}`,
init,
{
timeoutMs: this.timeoutMs,
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${this.timeoutMs}ms`,
timeoutMs,
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${timeoutMs}ms`,
},
);
} catch (err) {
@@ -95,14 +100,14 @@ export class DaemonClient {
}
}
private async requestJson<T>(path: string, init: RequestInit): Promise<DaemonResponse<T>> {
const res = await this.fetch(path, init);
private async requestJson<T>(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
const res = await this.fetch(path, init, options);
const data = (await res.json()) as T;
return { status: res.status, data };
}
private async requestText(path: string, init: RequestInit): Promise<DaemonResponse<string>> {
const res = await this.fetch(path, init);
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
const res = await this.fetch(path, init, options);
const data = await res.text();
return { status: res.status, data };
}
+87
View File
@@ -0,0 +1,87 @@
import path from "node:path";
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
export const CMUX_SETTINGS_DISCLOSURE_PATH = "~/.config/cmux/settings.json";
export const CMUX_DEFAULT_SOCKET_CONTROL_MODE = "cmuxOnly";
const CMUX_INCOMPATIBLE_MODES = new Set(["off", "cmuxOnly"]);
export interface CmuxSettingsReadResult {
mode: string;
source: "settings" | "default";
error?: string;
}
export function resolveCmuxSettingsPath(homeDir = process.env["HOME"] ?? "~"): string {
return path.join(homeDir, ".config", "cmux", "settings.json");
}
export function isCmuxSocketControlCompatible(mode: string | null): boolean {
return typeof mode === "string" && !CMUX_INCOMPATIBLE_MODES.has(mode);
}
export function readCmuxSocketControlModeFromText(text: string | null): CmuxSettingsReadResult {
if (!text || !text.trim()) {
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
const errors: ParseError[] = [];
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
if (errors.length > 0) {
return {
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
source: "settings",
error: formatParseErrors(errors),
};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
source: "settings",
error: "cmux settings must contain a JSON object.",
};
}
const automation = parsed["automation"];
if (!automation || typeof automation !== "object" || Array.isArray(automation)) {
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
const mode = automation["socketControlMode"];
if (typeof mode === "string" && mode.trim()) {
return { mode, source: "settings" };
}
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
export function upsertCmuxSocketControlMode(text: string | null, mode: string): { content: string; changed: boolean } {
const source = text && text.trim() ? text : "{\n}\n";
const edits = modify(source, ["automation", "socketControlMode"], mode, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
eol: "\n",
},
});
if (edits.length === 0) {
return { content: ensureTrailingNewline(source), changed: false };
}
return {
content: ensureTrailingNewline(applyEdits(source, edits)),
changed: true,
};
}
function ensureTrailingNewline(text: string): string {
return text.endsWith("\n") ? text : `${text}\n`;
}
function formatParseErrors(errors: ParseError[]): string {
return errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
.join("; ");
}
+3 -1
View File
@@ -5,6 +5,8 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS = 120_000;
function logStageDetailErrors(data: Record<string, unknown>) {
const stages = (data["stages"] as Array<{ stage: string; status: string; detail?: unknown }>) ?? [];
for (const stage of stages) {
@@ -107,7 +109,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
sourceRef,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
autoApprove: opts.yes ?? false,
});
}, { timeoutMs: LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
+3 -2
View File
@@ -140,12 +140,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
.description("Show daemon status")
.action(async () => {
const status = await getDaemonStatus(getDeps());
const pidSuffix = status.pid !== undefined ? ` (pid ${status.pid})` : "";
switch (status.state) {
case "running":
if (status.healthy === false) {
console.log(`Daemon running on port ${status.port} (pid ${status.pid}) — healthz failed`);
console.log(`Daemon running on port ${status.port}${pidSuffix} — healthz failed`);
} else {
console.log(`Daemon running on port ${status.port} (pid ${status.pid})`);
console.log(`Daemon running on port ${status.port}${pidSuffix}`);
}
break;
case "stopped":
+210
View File
@@ -0,0 +1,210 @@
import { Command } from "commander";
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { realDeps } from "./daemon.js";
import { getDefaultOpenRigPath, getPreferredOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import {
DESTROY_CONFIRM_TOKEN,
buildDestroyPlan,
executeDestroy,
findListeningPidWithLsof,
killTmuxSessionWithCli,
listManagedTmuxSessionsFromDb,
type DestroyDeps,
type ListenerInspection,
type DestroyRuntimeConfig,
type DestroyScope,
} from "../destroy-helpers.js";
import { stopDaemon } from "../daemon-lifecycle.js";
export interface DestroyCommandDeps {
configStore: Pick<ConfigStore, "resolve">;
destroyDeps: DestroyDeps;
}
interface ResolvedDestroyRuntime {
runtimeConfig: DestroyRuntimeConfig;
warnings: string[];
}
function resolveRuntimeConfig(configStore: Pick<ConfigStore, "resolve">): ResolvedDestroyRuntime {
const warnings: string[] = [];
const stateRoot = getPreferredOpenRigHome();
const defaultDbPath = getDefaultOpenRigPath("openrig.sqlite");
const defaultTranscriptsPath = getDefaultOpenRigPath("transcripts");
const effectiveDefaultDbPath = join(stateRoot, "openrig.sqlite");
const effectiveDefaultTranscriptsPath = join(stateRoot, "transcripts");
const overrideUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL")?.trim();
if (overrideUrl) {
warnings.push(`Ignoring ${overrideUrl} from OPENRIG_URL/RIGGED_URL because rig destroy only operates on local state.`);
}
try {
const config = configStore.resolve();
return {
runtimeConfig: {
stateRoot,
dbPath: config.db.path === defaultDbPath ? effectiveDefaultDbPath : config.db.path,
transcriptsPath: config.transcripts.path === defaultTranscriptsPath ? effectiveDefaultTranscriptsPath : config.transcripts.path,
daemonHost: config.daemon.host,
daemonPort: config.daemon.port,
},
warnings,
};
} catch (err) {
warnings.push(`Failed to read config; using compatibility defaults instead: ${err instanceof Error ? err.message : String(err)}`);
return {
runtimeConfig: {
stateRoot,
dbPath: effectiveDefaultDbPath,
transcriptsPath: effectiveDefaultTranscriptsPath,
daemonHost: "127.0.0.1",
daemonPort: 7433,
},
warnings,
};
}
}
function realDestroyDeps(): DestroyDeps {
const lifecycleDeps = realDeps();
return {
stopDaemon: async () => stopDaemon(lifecycleDeps),
inspectListener: async (host: string, port: number): Promise<ListenerInspection> => {
try {
const res = await fetch(`http://${host}:${port}/healthz`);
if (!res.ok) {
return {
kind: "other_http",
healthy: false,
detail: `HTTP ${res.status}`,
};
}
let body: unknown = null;
try {
body = await res.json();
} catch {
return {
kind: "other_http",
healthy: false,
detail: "Response body was not valid OpenRig health JSON.",
};
}
if (body && typeof body === "object" && (body as Record<string, unknown>).status === "ok") {
return { kind: "openrig", healthy: true };
}
return {
kind: "other_http",
healthy: false,
detail: "Response body did not match the OpenRig health contract.",
};
} catch {
return { kind: "unreachable" };
}
},
findListeningPid: findListeningPidWithLsof,
killProcess: (pid: number) => {
process.kill(pid, "SIGTERM");
},
exists: existsSync,
renamePath: (from: string, to: string) => renameSync(from, to),
removePath: (targetPath: string) => rmSync(targetPath, { recursive: true, force: true }),
mkdirp: (targetPath: string) => mkdirSync(targetPath, { recursive: true }),
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
killTmuxSession: killTmuxSessionWithCli,
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => new Date(),
};
}
function printPlan(
scope: DestroyScope,
backup: boolean,
runtimeConfig: DestroyRuntimeConfig,
managedTmuxSessions: string[],
warnings: string[]
): void {
console.log("DESTROY PLAN");
console.log(` scope: ${scope}`);
console.log(` state root: ${runtimeConfig.stateRoot}`);
console.log(` daemon: http://${runtimeConfig.daemonHost}:${runtimeConfig.daemonPort}`);
console.log(` backup: ${backup ? "enabled" : "disabled"}`);
console.log(` tmux cleanup: ${scope === "all" ? `enabled (${managedTmuxSessions.length} managed session${managedTmuxSessions.length === 1 ? "" : "s"})` : "disabled"}`);
for (const warning of warnings) {
console.log(` warning: ${warning}`);
}
console.log("");
}
function printResult(result: Awaited<ReturnType<typeof executeDestroy>>): void {
console.log("DESTROY RESULT");
console.log(` daemon: ${result.daemonStopped ? "stopped" : "still responding"}`);
console.log(` port: ${result.portCleared ? "cleared" : "still occupied"}`);
if (result.scope === "all") {
console.log(` tmux sessions removed: ${result.tmuxKilled}`);
if (result.tmuxMissing > 0) {
console.log(` tmux sessions missing/already gone: ${result.tmuxMissing}`);
}
}
if (result.backupPaths.length > 0) {
for (const backupPath of result.backupPaths) {
console.log(` backup: ${backupPath}`);
}
} else if (result.backup) {
console.log(" backup: nothing to move");
}
console.log(result.stateRecreated ? ` fresh state root: ${result.stateRoot}` : " fresh state root: not created (state left untouched)");
for (const warning of result.warnings) {
console.log(` warning: ${warning}`);
}
}
export function destroyCommand(depsOverride?: DestroyCommandDeps): Command {
const cmd = new Command("destroy").description("Destroy OpenRig local state for recovery");
cmd
.option("--state", "Destroy OpenRig state and recreate an empty state root")
.option("--all", "Destroy OpenRig state and remove managed tmux sessions")
.option("--backup", "Move state aside instead of deleting it")
.option("--yes", "Confirm the destructive operation")
.option("--confirm <token>", `Exact confirmation token: ${DESTROY_CONFIRM_TOKEN}`)
.action(async (opts: { state?: boolean; all?: boolean; backup?: boolean; yes?: boolean; confirm?: string }) => {
const selectedScopes = [opts.state ? "state" : null, opts.all ? "all" : null].filter(Boolean) as DestroyScope[];
if (selectedScopes.length !== 1) {
console.error("Specify exactly one destroy scope: --state or --all");
process.exitCode = 1;
return;
}
if (!opts.yes) {
console.error("Destroy requires --yes");
process.exitCode = 1;
return;
}
if (opts.confirm !== DESTROY_CONFIRM_TOKEN) {
console.error(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
process.exitCode = 1;
return;
}
const deps = depsOverride ?? {
configStore: new ConfigStore(),
destroyDeps: realDestroyDeps(),
};
const { runtimeConfig, warnings } = resolveRuntimeConfig(deps.configStore);
const plan = buildDestroyPlan(selectedScopes[0]!, Boolean(opts.backup), runtimeConfig, deps.destroyDeps);
printPlan(plan.scope, plan.backup, runtimeConfig, plan.managedTmuxSessions, [...warnings, ...plan.warnings]);
plan.warnings = [...warnings, ...plan.warnings];
const result = await executeDestroy(plan, deps.destroyDeps);
printResult(result);
if (!result.portCleared) {
process.exitCode = 1;
}
});
return cmd;
}
+178 -20
View File
@@ -1,15 +1,22 @@
import { Command } from "commander";
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import net from "node:net";
import { execSync } from "node:child_process";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { ConfigStore } from "../config-store.js";
import { buildWritableHomeCheck } from "../system-preflight.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
isCmuxSocketControlCompatible,
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
interface DoctorCheck {
name: string;
status: "pass" | "warn" | "fail";
status: "pass" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fix?: string;
@@ -18,11 +25,14 @@ interface DoctorCheck {
export interface DoctorDeps {
exists: (p: string) => boolean;
baseDir: string;
readFile: (path: string) => string | null;
exec: (cmd: string) => string;
checkPort: (port: number) => Promise<boolean>;
configStore: Pick<ConfigStore, "resolve">;
platform?: NodeJS.Platform;
mkdirp?: (path: string) => void;
checkWritable?: (path: string) => void;
fetch?: (url: string) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
}
const MIN_NODE_MAJOR = 20;
@@ -39,8 +49,9 @@ function defaultCheckPort(port: number): Promise<boolean> {
});
}
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck> } {
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck>; asyncChecks: Promise<DoctorCheck>[] } {
const checks: DoctorCheck[] = [];
const platform = deps.platform ?? process.platform;
// 1. Daemon dist
const daemonPath = resolveDaemonPath(deps.baseDir, deps.exists);
@@ -86,10 +97,8 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
}
// 4. tmux
try {
const ver = deps.exec("tmux -V").trim();
checks.push({ name: "tmux", status: "pass", message: ver });
} catch {
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
if (tmuxProbe.code === "not_installed") {
checks.push({
name: "tmux",
status: "fail",
@@ -97,9 +106,78 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
reason: "OpenRig uses tmux to manage agent sessions.",
fix: "Install tmux: brew install tmux (macOS), apt install tmux (Linux).",
});
} else if (tmuxProbe.available && tmuxProbe.version) {
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
if (platform === "darwin") {
const mouseMode = readTmuxMouseMode(deps);
if (mouseMode === "on") {
checks.push({
name: "tmux_mouse",
status: "pass",
message: "tmux mouse mode enabled.",
});
} else if (mouseMode === "off") {
checks.push({
name: "tmux_mouse",
status: "warn",
message: "tmux mouse mode appears disabled.",
reason: "On macOS, scrolling and text selection inside tmux panes is much smoother with mouse mode enabled.",
fix: "Run `tmux set -g mouse on` for the current tmux server. To keep it enabled, add `set -g mouse on` to `~/.tmux.conf` and reload with `tmux source-file ~/.tmux.conf`.",
});
}
}
} else {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
checks.push({
name: "tmux",
status: "fail",
message: failure.message,
reason: failure.reason,
fix: failure.fix,
});
}
// 5. Writable state paths (shared with preflight)
// 5. cmux shell check (optional but recommended for Open CMUX workflows)
let shellCmuxPassed = false;
try {
deps.exec("cmux capabilities --json");
shellCmuxPassed = true;
checks.push({
name: "cmux_shell",
status: "pass",
message: "cmux shell control available.",
});
} catch (err) {
try {
deps.exec("cmux --help");
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
const modeHint = socketMode?.error
? ` Likely cause on macOS: ${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable (${socketMode.error}).`
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode) && socketMode.source === "default"
? ` Likely cause on macOS: cmux is still using its default automation.socketControlMode '${socketMode.mode}'.`
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode)
? ` Likely cause on macOS: automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}.`
: "";
checks.push({
name: "cmux_shell",
status: "warn",
message: "cmux installed, but control unavailable right now.",
reason: "OpenRig can run without cmux, but Open CMUX actions and cmux-aware node control will be unavailable until cmux control works.",
fix: `Open the cmux app, verify control access/socket sharing is enabled for OpenRig, then rerun 'rig doctor'. If you are running this for someone else, tell the user that cmux is optional but required for Open CMUX. If you do not need cmux features, you can ignore this warning.${modeHint}`,
});
} catch {
checks.push({
name: "cmux_shell",
status: "warn",
message: "cmux not found.",
reason: "OpenRig can run without cmux, but Open CMUX actions and surface control will be unavailable.",
fix: "Install and launch cmux if you want Open CMUX support. If you are running this for someone else, tell the user that cmux is optional and only needed for Open CMUX workflows.",
});
}
void err;
}
// 6. Writable state paths (shared with preflight)
const config = deps.configStore.resolve();
const writableCheck = buildWritableHomeCheck(config, path.dirname(config.db.path), {
mkdirp: deps.mkdirp,
@@ -113,7 +191,7 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
fix: writableCheck.fix,
});
// 6. Port availability (async) — daemon already running on that port counts as OK
// 7. Port availability (async) — daemon already running on that port counts as OK
const portCheck = deps.checkPort(DEFAULT_PORT).then(async (available): Promise<DoctorCheck> => {
if (available) {
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} available.` };
@@ -134,7 +212,86 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
};
});
return { checks, portCheck };
// 8. Daemon cmux control (async, only when shell cmux passed)
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
if (shellCmuxPassed) {
const fetchFn = deps.fetch ?? globalThis.fetch;
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
try {
const healthRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
if (!healthRes.ok) {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not running. Skipping daemon cmux check." };
}
} catch {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
}
try {
const cmuxRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/api/adapters/cmux/status`);
if (cmuxRes.ok && cmuxRes.json) {
const data = (await cmuxRes.json()) as { available?: boolean };
if (data.available) {
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
}
return buildCmuxDaemonWarning(deps, platform);
}
} catch { /* fetch failed */ }
return buildCmuxDaemonWarning(deps, platform);
})();
asyncChecks.push(daemonCmuxCheck);
}
return { checks, portCheck, asyncChecks };
}
function readCmuxSocketControlMode(deps: DoctorDeps) {
return readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
}
function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): DoctorCheck {
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
if (platform === "darwin" && socketMode?.error) {
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason: `${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable: ${socketMode.error}`,
fix: `Repair ${CMUX_SETTINGS_DISCLOSURE_PATH} or remove it so cmux can regenerate the template, then rerun \`rig setup\` or \`rig doctor\`.`,
};
}
if (platform === "darwin" && socketMode && !isCmuxSocketControlCompatible(socketMode.mode)) {
const reason = socketMode.source === "default"
? `cmux is still using its default automation.socketControlMode '${socketMode.mode}', so the daemon cannot attach as an external cmux client yet.`
: `automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}, so the daemon cannot attach as an external cmux client yet.`;
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason,
fix: `Run \`rig setup\` to set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then restart the daemon with \`rig daemon start\`.`,
};
}
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason: "The daemon inherited a terminal/session environment that broke cmux adapter initialization.",
fix: "Restart the daemon with `rig daemon start` after setup. If it still fails, inspect the daemon log with `rig daemon logs`.",
};
}
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
try {
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
if (mode === "on" || mode === "off") return mode;
return null;
} catch {
return null;
}
}
export function doctorCommand(depsOverride?: DoctorDeps): Command {
@@ -146,34 +303,35 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
const deps: DoctorDeps = depsOverride ?? {
exists: existsSync,
baseDir: import.meta.dirname,
exec: (c: string) => execSync(c, { encoding: "utf-8" }),
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
checkPort: defaultCheckPort,
configStore: new ConfigStore(),
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
checkWritable: (dirPath: string) => accessSync(dirPath, constants.W_OK),
};
const { checks, portCheck } = runDoctorChecks(deps);
const portResult = await portCheck;
const allChecks = [...checks, portResult];
const allPass = allChecks.every((c) => c.status === "pass");
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolvedAsync = await Promise.all(asyncChecks);
const allChecks = [...checks, ...resolvedAsync];
const healthy = allChecks.every((c) => c.status !== "fail");
if (opts.json) {
console.log(JSON.stringify({ healthy: allPass, checks: allChecks }, null, 2));
if (!allPass) process.exitCode = 1;
console.log(JSON.stringify({ healthy, checks: allChecks }, null, 2));
if (!healthy) process.exitCode = 1;
return;
}
for (const check of allChecks) {
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : "FAIL";
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : check.status === "skipped" ? "SKIP" : "FAIL";
console.log(` [${icon}] ${check.name}: ${check.message}`);
if (check.reason) console.log(` Why: ${check.reason}`);
if (check.fix) console.log(` Fix: ${check.fix}`);
}
console.log("");
console.log(allPass ? "All checks passed." : "Some checks failed.");
if (!allPass) process.exitCode = 1;
console.log(healthy ? "System checks look good." : "Some checks failed.");
if (!healthy) process.exitCode = 1;
});
return cmd;
+3 -1
View File
@@ -14,6 +14,8 @@ interface TeardownResult {
errors: string[];
}
const LONG_RUNNING_TIMEOUT_MS = 45_000;
/**
* `rig down <rigId>` — tear down a rig.
* @param depsOverride - injectable deps for testing
@@ -46,7 +48,7 @@ export function downCommand(depsOverride?: StatusDeps): Command {
delete: opts.delete ?? false,
force: opts.force ?? false,
snapshot: opts.snapshot ?? false,
});
}, { timeoutMs: LONG_RUNNING_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
+142
View File
@@ -0,0 +1,142 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function envCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("env")
.description("Inspect and control rig environment services for service-backed rigs and managed apps")
.addHelpText("after", `
Examples:
rig env status secrets-manager
rig env logs secrets-manager vault
rig env down secrets-manager
`);
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running");
return null;
}
return deps.clientFactory(getDaemonUrl(status));
}
async function resolveRigId(client: DaemonClient, rigRef: string): Promise<string> {
const summaries = await client.get<Array<{ id: string; name: string }>>("/api/rigs/summary");
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
if (!match) throw new Error(`Rig '${rigRef}' not found`);
return match.id;
}
// rig env status <rig>
cmd
.command("status")
.argument("<rig>", "Rig name or ID")
.option("--json", "JSON output")
.action(async (rig: string, opts: { json?: boolean }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const res = await client.get<Record<string, unknown>>(`/api/rigs/${encodeURIComponent(rigId)}/env`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
if (!res.data["hasServices"]) {
console.log("No services configured for this rig.");
return;
}
const receipt = res.data["receipt"] as Record<string, unknown> | null;
if (!receipt) {
console.log("Services configured but no receipt available yet.");
return;
}
console.log(`Env: ${res.data["kind"]} (${res.data["projectName"]})`);
const services = receipt["services"] as Array<{ name: string; status: string; health?: string | null }>;
if (services) {
for (const svc of services) {
const health = svc.health ? ` (${svc.health})` : "";
console.log(` ${svc.name}: ${svc.status}${health}`);
}
}
});
// rig env logs <rig> [service]
cmd
.command("logs")
.argument("<rig>", "Rig name or ID")
.argument("[service]", "Specific service name")
.option("--tail <n>", "Number of lines", "100")
.action(async (rig: string, service: string | undefined, opts: { tail: string }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const params = new URLSearchParams({ tail: opts.tail });
if (service) params.set("service", service);
const res = await client.get<{ ok: boolean; output?: string; error?: string }>(
`/api/rigs/${encodeURIComponent(rigId)}/env/logs?${params}`,
);
if (res.status >= 400 || !res.data.ok) {
console.error(res.data.error ?? `Failed to get logs (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
console.log(res.data.output ?? "");
});
// rig env down <rig> [--volumes]
cmd
.command("down")
.argument("<rig>", "Rig name or ID")
.option("--volumes", "Also remove volumes")
.action(async (rig: string, opts: { volumes?: boolean }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const res = await client.post<{ ok: boolean; error?: string }>(
`/api/rigs/${encodeURIComponent(rigId)}/env/down`,
{ volumes: opts.volumes ?? false },
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
);
if (res.status >= 400 || !res.data.ok) {
console.error(res.data.error ?? `Failed to stop services (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
console.log(`Services stopped for ${rig}.`);
});
return cmd;
}
+9 -1
View File
@@ -10,6 +10,8 @@ export interface ImportDeps extends StatusDeps {
readFile: (path: string) => string;
}
const LONG_RUNNING_IMPORT_TIMEOUT_MS = 120_000;
export function importCommand(depsOverride?: ImportDeps): Command {
const cmd = new Command("import").description("Import a rig spec from YAML");
const getDeps = (): ImportDeps => depsOverride ?? {
@@ -120,7 +122,13 @@ export function importCommand(depsOverride?: ImportDeps): Command {
}
if (opts.instantiate) {
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import", yaml, "text/yaml", extraHeaders);
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>(
"/api/rigs/import",
yaml,
"text/yaml",
extraHeaders,
{ timeoutMs: LONG_RUNNING_IMPORT_TIMEOUT_MS },
);
if (res.status === 409 || res.status === 400) {
const data = res.data as { ok: false; code: string; errors?: string[]; message?: string };
const detail = data.errors?.join("\n ") ?? data.message ?? `status ${res.status}`;
+1 -1
View File
@@ -22,7 +22,7 @@ export function preflightCommand(depsOverride?: PreflightCommandDeps): Command {
const exec = depsOverride?.exec ?? (async (c: string) => {
const { execSync } = await import("node:child_process");
return execSync(c, { encoding: "utf-8" });
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
});
const preflight = new SystemPreflight({
+1 -1
View File
@@ -24,7 +24,7 @@ interface NodeEntry {
nodeKind: "agent" | "infrastructure";
runtime: string | null;
sessionStatus: string | null;
startupStatus: "pending" | "ready" | "failed" | null;
startupStatus: "pending" | "ready" | "attention_required" | "failed" | null;
restoreOutcome: string;
tmuxAttachCommand: string | null;
resumeCommand: string | null;
+62 -1
View File
@@ -4,6 +4,8 @@ import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lif
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function restoreCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("restore").description("Restore a rig from a snapshot");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -28,7 +30,28 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
const rigId = opts.rig;
const res = await client.post<{ nodes?: Array<{ nodeId: string; logicalId: string; status: string; error?: string }>; attachCommand?: string }>(`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`);
const res = await client.post<{
nodes?: Array<{
nodeId: string;
logicalId: string;
status: string;
error?: string;
canonicalSessionName?: string | null;
tmuxAttachCommand?: string | null;
resumeCommand?: string | null;
recoveryGuidance?: {
summary: string;
commands: string[];
notes: string[];
} | null;
cwd?: string | null;
}>;
attachCommand?: string;
}>(
`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`,
undefined,
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
);
if (res.status === 404) {
console.error(`Snapshot "${snapshotId}" or rig "${rigId}" not found. List snapshots with: rig snapshot list --rig ${rigId}`);
@@ -46,6 +69,7 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
const label = node.status === "failed" && node.error ? `${node.status} — ${node.error}` : node.status;
console.log(` ${node.logicalId}: ${label}`);
}
printRecoveryGuidance(nodes);
const attachCommand = (res.data as Record<string, unknown>)["attachCommand"] as string | undefined;
if (attachCommand) {
console.log(`Attach: ${attachCommand}`);
@@ -58,3 +82,40 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
return cmd;
}
function printRecoveryGuidance(
nodes: Array<{
logicalId: string;
status: string;
canonicalSessionName?: string | null;
tmuxAttachCommand?: string | null;
recoveryGuidance?: { summary: string; commands: string[]; notes: string[] } | null;
cwd?: string | null;
}>,
): void {
const actionable = nodes.filter((node) =>
(node.status === "fresh" || node.status === "failed") && node.recoveryGuidance
);
if (actionable.length === 0) return;
console.log("\nRecovery guidance:");
for (const node of actionable) {
console.log(` ${node.logicalId}: ${node.recoveryGuidance!.summary}`);
if (node.tmuxAttachCommand) {
console.log(` attach: ${node.tmuxAttachCommand}`);
}
if (node.canonicalSessionName) {
console.log(` session: ${node.canonicalSessionName}`);
}
if (node.cwd) {
console.log(` cwd: ${node.cwd}`);
}
for (const command of node.recoveryGuidance!.commands) {
console.log(` $ ${command}`);
}
for (const note of node.recoveryGuidance!.notes) {
console.log(` note: ${note}`);
}
}
}
+640
View File
@@ -0,0 +1,640 @@
import { Command } from "commander";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, accessSync, constants, mkdirSync } from "node:fs";
import path from "node:path";
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { ConfigStore } from "../config-store.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
isCmuxSocketControlCompatible,
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
upsertCmuxSocketControlMode,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
export interface SetupStep {
id: string;
status: "pass" | "applied" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fixHint?: string;
}
export interface VerificationCheck {
name: string;
status: "pass" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fix?: string;
}
export interface RuntimeConfigDisclosure {
scope: "global" | "project";
runtime: "claude-code" | "codex" | "cmux";
path: string;
purpose: string;
}
export interface SetupResult {
profile: "core" | "full";
platform: string;
ready: boolean;
steps: SetupStep[];
runtimeConfig: RuntimeConfigDisclosure[];
verification?: {
checks: VerificationCheck[];
};
}
export interface SetupDeps {
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
readFile: (path: string) => string | null;
writeFile: (path: string, content: string) => void;
exists: (path: string) => boolean;
mkdirp?: (path: string) => void;
platform?: NodeJS.Platform;
}
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
const INSTALL_COMMAND_TIMEOUT_MS = 5 * 60_000;
const CMUX_READY_ATTEMPTS = 5;
const CMUX_READY_DELAY_MS = 1_000;
const CORE_STEP_IDS = [
"brew",
"tmux_install",
"cmux_install",
"claude_install",
"claude_auth",
"codex_install",
"codex_auth",
"tmux_config",
"verify",
];
const FULL_EXTRA_STEP_IDS = ["jq_install", "gh_install"];
const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
{
scope: "global",
runtime: "claude-code",
path: "~/.claude/settings.json",
purpose: "Allow OpenRig commands without Claude permission prompts.",
},
{
scope: "global",
runtime: "claude-code",
path: "~/.claude.json",
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
},
{
scope: "project",
runtime: "claude-code",
path: ".claude/settings.local.json",
purpose: "Apply managed-session Claude permissions and context-collector statusLine config within the project.",
},
{
scope: "project",
runtime: "claude-code",
path: ".mcp.json",
purpose: "Configure project-local MCP servers for Claude-managed workspaces.",
},
{
scope: "global",
runtime: "codex",
path: "~/.codex/config.toml",
purpose: "Pre-trust managed workspaces and configure Codex MCP servers.",
},
];
const DARWIN_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure = {
scope: "global",
runtime: "cmux",
path: CMUX_SETTINGS_DISCLOSURE_PATH,
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
};
function defaultDeps(): SetupDeps {
return {
exec: (cmd: string, opts?: { timeoutMs?: number }) =>
execSync(cmd, {
encoding: "utf-8",
timeout: opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
stdio: ["pipe", "pipe", "pipe"],
}),
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
writeFile: (p: string, c: string) => writeFileSync(p, c, "utf-8"),
exists: (p: string) => existsSync(p),
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
};
}
function installCommand(deps: SetupDeps, cmd: string): string {
return deps.exec(cmd, { timeoutMs: INSTALL_COMMAND_TIMEOUT_MS });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForCmuxCapabilities(deps: SetupDeps, attempts = CMUX_READY_ATTEMPTS): Promise<boolean> {
for (let index = 0; index < attempts; index += 1) {
try {
deps.exec("cmux capabilities --json");
return true;
} catch {
if (index < attempts - 1) {
await sleep(CMUX_READY_DELAY_MS);
}
}
}
return false;
}
async function tryEnableCmuxControl(deps: SetupDeps, platform: NodeJS.Platform): Promise<boolean> {
if (platform !== "darwin") return false;
const settingsPath = resolveCmuxSettingsPath();
const settingsText = deps.readFile(settingsPath);
const currentMode = readCmuxSocketControlModeFromText(settingsText);
if (currentMode.error) {
return false;
}
try {
deps.mkdirp?.(path.dirname(settingsPath));
const next = upsertCmuxSocketControlMode(settingsText, "automation");
if (next.changed) {
deps.writeFile(settingsPath, next.content);
}
} catch {
return false;
}
const shellReady = await waitForCmuxCapabilities(deps, 1);
if (shellReady) {
try {
deps.exec("cmux reload-config");
} catch {
// Best effort: if reload fails, the daemon-side verification will surface it honestly.
}
return waitForCmuxCapabilities(deps, 1);
}
try {
deps.exec("open -a /Applications/cmux.app");
} catch {
// Best effort: cmux may already be running or the app open may be blocked; capability probe decides readiness.
}
return waitForCmuxCapabilities(deps);
}
function buildRuntimeConfigDisclosure(platform: NodeJS.Platform): RuntimeConfigDisclosure[] {
return platform === "darwin"
? [...BASE_RUNTIME_CONFIG_DISCLOSURE, DARWIN_RUNTIME_CONFIG_DISCLOSURE]
: [...BASE_RUNTIME_CONFIG_DISCLOSURE];
}
async function probeDaemonCmuxStatus(doctorDeps?: DoctorDeps): Promise<"available" | "unavailable" | "skipped"> {
const fetchFn = doctorDeps?.fetch;
if (!fetchFn) return "skipped";
const config = doctorDeps.configStore.resolve();
const host = config.daemon.host;
const port = config.daemon.port;
try {
const healthRes = await fetchFn(`http://${host}:${port}/healthz`);
if (!healthRes.ok) return "skipped";
} catch {
return "skipped";
}
try {
const cmuxRes = await fetchFn(`http://${host}:${port}/api/adapters/cmux/status`);
if (!cmuxRes.ok || !cmuxRes.json) return "unavailable";
const data = (await cmuxRes.json()) as { available?: boolean };
return data.available ? "available" : "unavailable";
} catch {
return "unavailable";
}
}
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
const profile = opts.full ? "full" : "core";
const platform = deps.platform ?? process.platform;
const runtimeConfig = buildRuntimeConfigDisclosure(platform);
const stepIds = opts.full ? [...CORE_STEP_IDS, ...FULL_EXTRA_STEP_IDS] : [...CORE_STEP_IDS];
const steps: SetupStep[] = [];
if (opts.dryRun) {
for (const id of stepIds) {
steps.push({ id, status: "skipped", message: `Dry run: ${id} would be attempted.` });
}
return { profile, platform, ready: false, steps, runtimeConfig };
}
// Core steps
// 1. Homebrew (macOS-first setup path)
let brewOk = false;
if (platform !== "darwin") {
steps.push({
id: "brew",
status: "skipped",
message: "Skipped: Homebrew setup path is only used on macOS.",
});
} else {
try {
deps.exec("brew --version");
brewOk = true;
steps.push({ id: "brew", status: "pass", message: "Homebrew available." });
} catch {
steps.push({
id: "brew",
status: "fail",
message: "Homebrew not found.",
reason: "Homebrew is required to install tmux and cmux on macOS.",
fixHint: "Install Homebrew: https://brew.sh",
});
}
}
// 2. tmux
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
if (tmuxProbe.code === "not_installed") {
if (!brewOk) {
steps.push({ id: "tmux_install", status: "skipped", message: "Skipped: Homebrew not available.", reason: "tmux install requires Homebrew." });
} else {
try {
installCommand(deps, "brew install tmux");
steps.push({ id: "tmux_install", status: "applied", message: "Installed tmux with Homebrew." });
} catch (err) {
steps.push({ id: "tmux_install", status: "fail", message: `Failed to install tmux: ${(err as Error).message}` });
}
}
} else if (!tmuxProbe.available) {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
steps.push({
id: "tmux_install",
status: "fail",
message: failure.message,
reason: failure.reason,
fixHint: failure.fix,
});
} else {
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
}
// 3. cmux
const daemonCmuxBefore = await probeDaemonCmuxStatus(opts.doctorDeps);
if (await waitForCmuxCapabilities(deps, 1)) {
const socketMode = readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
if (platform === "darwin" && socketMode.error) {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux settings file is unreadable.",
reason: `OpenRig could not parse ${CMUX_SETTINGS_DISCLOSURE_PATH}: ${socketMode.error}`,
fixHint: "Repair or remove the cmux settings file, then rerun `rig setup`.",
});
} else if (platform === "darwin" && !isCmuxSocketControlCompatible(socketMode.mode)) {
if (await tryEnableCmuxControl(deps, platform)) {
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
if (daemonCmuxAfter === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "OpenRig updated cmux settings, but the running daemon still cannot control cmux.",
reason: "The cmux settings file is now compatible, so the remaining blocker is in the live daemon/cmux session state.",
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
});
} else {
steps.push({
id: "cmux_install",
status: "applied",
message: "Normalized cmux socket control to automation mode in ~/.config/cmux/settings.json.",
});
}
} else {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux shell control works, but OpenRig could not normalize cmux socket control.",
reason: "OpenRig needs a compatible cmux socket control mode so the daemon can open CMUX surfaces reliably.",
fixHint: `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`,
});
}
} else if (daemonCmuxBefore === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux shell control works, but the running daemon still cannot control cmux.",
reason: "Current cmux settings already look compatible, so the remaining blocker is outside the cmux settings file OpenRig can repair automatically.",
fixHint: "Run `rig doctor` for the exact daemon cmux diagnosis, then restart the daemon after clearing the underlying blocker.",
});
} else {
steps.push({ id: "cmux_install", status: "pass", message: "cmux available." });
}
} else {
try {
deps.exec("cmux --help");
if (await tryEnableCmuxControl(deps, platform)) {
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
if (daemonCmuxAfter === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "OpenRig enabled cmux socket control, but the running daemon still cannot control cmux.",
reason: "The cmux app and settings are now in place, so the remaining blocker is in the live daemon/cmux session state.",
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
});
} else {
steps.push({
id: "cmux_install",
status: "applied",
message: "Enabled cmux socket control in ~/.config/cmux/settings.json.",
});
}
} else {
steps.push({
id: "cmux_install",
status: platform === "darwin" ? "fail" : "warn",
message: "cmux installed but control unavailable.",
reason: "Open CMUX workflows need cmux socket control to be enabled.",
fixHint: platform === "darwin"
? `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`
: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
});
}
} catch {
if (!brewOk) {
steps.push({ id: "cmux_install", status: "skipped", message: "Skipped: Homebrew not available." });
} else {
try {
installCommand(deps, "brew install --cask cmux");
if (await tryEnableCmuxControl(deps, platform) || await waitForCmuxCapabilities(deps, 1)) {
steps.push({ id: "cmux_install", status: "applied", message: "Installed cmux with Homebrew." });
} else {
steps.push({
id: "cmux_install",
status: platform === "darwin" ? "fail" : "warn",
message: "Installed cmux, but control is still unavailable.",
reason: "Open CMUX workflows need the cmux app to expose socket control after installation.",
fixHint: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
});
}
} catch (err) {
steps.push({
id: "cmux_install",
status: "fail",
message: `Failed to install cmux: ${(err as Error).message}`,
reason: "Open CMUX workflows stay unavailable until the cmux app and CLI are installed.",
fixHint: "Retry `brew install --cask cmux` after connectivity stabilizes, or install cmux manually.",
});
}
}
}
}
// 4. tmux config
// 4. Claude Code runtime
let claudeInstalled = false;
try {
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "pass", message: "Claude Code available." });
} catch {
try {
installCommand(deps, "npm install -g @anthropic-ai/claude-code");
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "applied", message: "Installed Claude Code with npm." });
} catch (err) {
steps.push({
id: "claude_install",
status: "fail",
message: `Failed to install Claude Code: ${(err as Error).message}`,
reason: "The demo rig launches Claude Code nodes, so the Claude CLI must be installed on this machine.",
fixHint: "Install Claude Code with `npm install -g @anthropic-ai/claude-code`.",
});
}
}
if (claudeInstalled) {
try {
deps.exec("claude auth status");
steps.push({ id: "claude_auth", status: "pass", message: "Claude Code authentication available." });
} catch (err) {
steps.push({
id: "claude_auth",
status: "fail",
message: `Claude Code is installed but not ready to launch: ${(err as Error).message}`,
reason: "The demo rig cannot launch Claude Code nodes until the Claude CLI is logged in and usable.",
fixHint: "Run `claude auth login` or open `claude` once to complete authentication, then rerun `rig setup` or `rig doctor`.",
});
}
} else {
steps.push({
id: "claude_auth",
status: "skipped",
message: "Skipped: Claude Code is not installed.",
reason: "Authentication cannot be checked until the Claude Code CLI is installed.",
});
}
// 5. Codex runtime
let codexInstalled = false;
try {
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "pass", message: "Codex available." });
} catch {
try {
installCommand(deps, "npm install -g @openai/codex");
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "applied", message: "Installed Codex with npm." });
} catch (err) {
steps.push({
id: "codex_install",
status: "fail",
message: `Failed to install Codex: ${(err as Error).message}`,
reason: "The demo rig launches Codex nodes, so the Codex CLI must be installed on this machine.",
fixHint: "Install Codex with `npm install -g @openai/codex`.",
});
}
}
if (codexInstalled) {
try {
deps.exec("codex login status");
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
} catch (err) {
steps.push({
id: "codex_auth",
status: "fail",
message: `Codex is installed but not ready to launch: ${(err as Error).message}`,
reason: "The demo rig cannot launch Codex nodes until the Codex CLI is logged in and usable.",
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
});
}
} else {
steps.push({
id: "codex_auth",
status: "skipped",
message: "Skipped: Codex is not installed.",
reason: "Authentication cannot be checked until the Codex CLI is installed.",
});
}
// 6. tmux config
const TMUX_CONF = `${process.env["HOME"] ?? "~"}/.tmux.conf`;
const MANAGED_MARKER = "# OpenRig managed block";
const MANAGED_BLOCK = [
MANAGED_MARKER,
"set -g mouse on",
"set -g history-limit 50000",
`# End ${MANAGED_MARKER}`,
].join("\n");
try {
const existing = deps.readFile(TMUX_CONF);
if (existing && existing.includes(MANAGED_MARKER)) {
// Replace existing managed block
const replaced = existing.replace(
new RegExp(`${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?# End ${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
MANAGED_BLOCK,
);
deps.writeFile(TMUX_CONF, replaced);
steps.push({ id: "tmux_config", status: "applied", message: "Updated OpenRig managed tmux config block." });
} else if (existing) {
deps.writeFile(TMUX_CONF, existing.trimEnd() + "\n\n" + MANAGED_BLOCK + "\n");
steps.push({ id: "tmux_config", status: "applied", message: "Appended OpenRig managed tmux config block." });
} else {
deps.writeFile(TMUX_CONF, MANAGED_BLOCK + "\n");
steps.push({ id: "tmux_config", status: "applied", message: "Created .tmux.conf with OpenRig managed block." });
}
} catch (err) {
steps.push({ id: "tmux_config", status: "warn", message: `Could not update tmux config: ${(err as Error).message}` });
}
// 7. Verify
const tmuxOk = steps.some((s) => s.id === "tmux_install" && (s.status === "pass" || s.status === "applied"));
const anyFail = steps.some((s) => s.status === "fail");
steps.push({
id: "verify",
status: anyFail ? "warn" : "pass",
message: anyFail ? "Some setup steps failed. Run `rig doctor` for detailed diagnostics." : "Core setup verified.",
});
// Full profile extras
if (opts.full) {
for (const tool of [{ id: "jq_install", cmd: "jq", brew: "jq" }, { id: "gh_install", cmd: "gh", brew: "gh" }]) {
try {
deps.exec(`${tool.cmd} --version`);
steps.push({ id: tool.id, status: "pass", message: `${tool.cmd} available.` });
} catch {
if (!brewOk) {
steps.push({ id: tool.id, status: "skipped", message: `Skipped: Homebrew not available.` });
} else {
try {
installCommand(deps, `brew install ${tool.brew}`);
steps.push({ id: tool.id, status: "applied", message: `Installed ${tool.cmd} with Homebrew.` });
} catch {
steps.push({ id: tool.id, status: "warn", message: `Failed to install ${tool.cmd}.`, fixHint: `Install ${tool.cmd} manually.` });
}
}
}
}
}
// Run doctor-backed verification if not dry-run and doctorDeps available
let verification: SetupResult["verification"];
if (!opts.dryRun && opts.doctorDeps) {
const doctorDeps = opts.doctorDeps;
const doctor = runDoctorChecks(doctorDeps);
const asyncResults = await Promise.all(doctor.asyncChecks);
const allDoctorChecks = [...doctor.checks, ...asyncResults];
verification = {
checks: allDoctorChecks.map((c) => ({
name: c.name,
status: c.status,
message: c.message,
...(c.reason ? { reason: c.reason } : {}),
...(c.fix ? { fix: c.fix } : {}),
})),
};
}
// ready = no fail statuses in steps or verification checks
const stepsFailed = steps.some((s) => s.status === "fail");
const verificationFailed = verification?.checks.some((c) => c.status === "fail") ?? false;
const ready = !stepsFailed && !verificationFailed;
return { profile, platform, ready, steps, runtimeConfig, ...(verification ? { verification } : {}) };
}
function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
const platform = setupDeps.platform ?? process.platform;
const baseDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
return {
exists: setupDeps.exists,
baseDir,
readFile: setupDeps.readFile,
exec: setupDeps.exec,
checkPort: async (port: number) => {
const net = await import("node:net");
return new Promise<boolean>((resolve) => {
const socket = new net.default.Socket();
socket.once("connect", () => { socket.destroy(); resolve(false); });
socket.once("error", () => resolve(true));
socket.connect(port, "127.0.0.1");
});
},
configStore: new ConfigStore(),
platform: platform as NodeJS.Platform,
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
checkWritable: (p: string) => accessSync(p, constants.W_OK),
fetch: globalThis.fetch,
};
}
export function setupCommand(depsOverride?: SetupDeps): Command {
const cmd = new Command("setup").description("Prepare the machine for OpenRig");
cmd
.option("--dry-run", "Show the plan without making changes")
.option("--json", "Machine-readable JSON output")
.option("--full", "Install broader operator workstation tools")
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean }) => {
const deps = depsOverride ?? defaultDeps();
const doctorDeps = opts.dryRun ? undefined : buildDefaultDoctorDeps(deps);
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, doctorDeps });
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
if (!opts.dryRun && !result.ready) process.exitCode = 1;
return;
}
console.log(`\nProfile: ${result.profile}`);
console.log(`Platform: ${result.platform}\n`);
console.log("OpenRig may modify runtime config in these locations:");
for (const item of result.runtimeConfig) {
console.log(` - [${item.scope}] ${item.runtime} ${item.path} — ${item.purpose}`);
}
console.log(" - Note: already-running adopted sessions may need restart to pick up runtime config changes.\n");
for (const step of result.steps) {
const icon = step.status === "pass" ? "OK" : step.status === "applied" ? "APPLIED" : step.status === "warn" ? "WARN" : step.status === "skipped" ? "SKIP" : "FAIL";
console.log(` [${icon}] ${step.id}: ${step.message}`);
if (step.reason) console.log(` Why: ${step.reason}`);
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
console.log(`\n${result.ready ? "Setup complete." : "Some steps need attention. Run `rig doctor` for detailed diagnostics."}`);
if (!opts.dryRun && !result.ready) process.exitCode = 1;
});
return cmd;
}
+11 -4
View File
@@ -44,12 +44,19 @@ export async function resolveLibrarySpec(
}
throw new Error(
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available specs.`
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available rigs, agents, and managed apps.`
);
}
export function specsCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("specs").description("Browse, preview, and manage the spec library");
const cmd = new Command("specs")
.description("Browse, preview, and manage the spec library, including managed apps")
.addHelpText("after", `
Examples:
rig specs ls
rig specs preview secrets-manager
rig specs show vault-specialist
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
@@ -66,7 +73,7 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// specs ls
cmd.command("ls")
.description("List library specs")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig or agent)")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
@@ -125,7 +132,7 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// specs preview
cmd.command("preview")
.argument("<name-or-id>", "Spec name or library ID")
.description("Show structured spec review")
.description("Show structured spec review, including managed app details")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
+26 -6
View File
@@ -5,12 +5,26 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: LifecycleDeps }): Command {
const cmd = new Command("up").description("Bootstrap a rig from a spec or bundle");
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
export function upCommand(
depsOverride?: StatusDeps & {
lifecycleDeps?: LifecycleDeps;
preflightExec?: (cmd: string) => Promise<string>;
},
): Command {
const cmd = new Command("up")
.description("Launch a rig or managed app from a spec, library entry, or bundle")
.addHelpText("after", `
Examples:
rig up secrets-manager
rig up ./rig.yaml
rig up ./demo.rigbundle --target ~/work
`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.argument("<source>", "Path to .yaml rig spec or .rigbundle")
.argument("<source>", "Path to a .yaml rig spec or .rigbundle, or a library name such as secrets-manager")
.option("--plan", "Plan mode — preview without executing")
.option("--yes", "Auto-approve trusted actions")
.option("--cwd <path>", "Override launch working directory for all members for this run only")
@@ -34,8 +48,10 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
const configStore = new ConfigStore();
resolvedConfig = configStore.resolve();
const preflightExec = depsOverride?.preflightExec ?? (async (cmd: string) =>
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }));
const preflight = new SystemPreflight({
exec: async (cmd) => execSync(cmd, { encoding: "utf-8" }),
exec: preflightExec,
configStore,
getDaemonStatus: () => getDaemonStatus(deps.lifecycleDeps),
openrigHome: OPENRIG_DIR,
@@ -85,6 +101,7 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
let sourceRef = isRigName ? source : nodePath.resolve(source);
// If it looks like a name, check for library spec match
let defaultLibraryCwdOverride: string | undefined;
if (isRigName) {
try {
const { resolveLibrarySpec } = await import("./specs.js");
@@ -102,6 +119,9 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
return;
}
sourceRef = entry.sourcePath;
if (!opts.cwd && entry.kind === "rig" && entry.sourceType === "builtin") {
defaultLibraryCwdOverride = process.cwd();
}
} catch (resolveErr) {
// Ambiguity within library — surface it
if ((resolveErr as Error).message?.includes("ambiguous")) {
@@ -120,9 +140,9 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
sourceRef,
plan: opts.plan ?? false,
autoApprove: opts.yes ?? false,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
targetRoot,
});
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
+119 -18
View File
@@ -1,6 +1,7 @@
import path from "node:path";
import { existsSync } from "node:fs";
import type { ChildProcess } from "node:child_process";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
export interface DaemonState {
@@ -21,7 +22,7 @@ export interface DaemonStatus {
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
export function getDaemonUrl(status: DaemonStatus): string {
return `http://${status.host ?? "127.0.0.1"}:${status.port}`;
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
}
export interface StartOptions {
@@ -56,6 +57,7 @@ export const LOG_FILE = path.join(OPENRIG_DIR, "daemon.log");
export const LEGACY_STATE_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.json");
export const LEGACY_LOG_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.log");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 7433;
const DEFAULT_DB = "openrig.sqlite";
const HEALTHZ_RETRIES = 20;
@@ -144,7 +146,7 @@ function resolveLifecycleFile(deps: LifecycleDeps, filename: "daemon.json" | "da
* - "dead" — PID not alive */
async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead"> {
if (!deps.isProcessAlive(state.pid)) return "dead";
const host = state.host ?? "127.0.0.1";
const host = state.host ?? DEFAULT_HOST;
try {
await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
// Any response (ok or not) means something is listening on our port → OpenRig
@@ -156,7 +158,70 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openr
}
}
function resolveConfiguredDaemonTarget(): { host: string; port: number } {
try {
const config = new ConfigStore().resolve();
return {
host: config.daemon.host,
port: config.daemon.port,
};
} catch {
return {
host: DEFAULT_HOST,
port: DEFAULT_PORT,
};
}
}
/**
* Scrub list: environment variable prefixes and exact names that should NOT
* be forwarded from the operator shell into the daemon process. These are
* terminal-emulator, GUI-session, and cmux-session variables that can break
* adapter initialization when the daemon runs detached.
*/
const ENV_SCRUB_PREFIXES = ["CODEX_", "GHOSTTY_", "XPC_", "__CF"];
const ENV_SCRUB_EXACT = new Set([
"CMUX_SOCKET_PATH",
"CMUX_SURFACE_ID",
"CMUX_WORKSPACE",
"COLORTERM",
"COMMAND_MODE",
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"TERMINFO",
]);
export function buildDaemonEnv(
baseEnv: Record<string, string>,
opts: { port: number; host: string; db: string; transcriptsEnabled?: boolean; transcriptsPath?: string },
): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(baseEnv)) {
if (ENV_SCRUB_EXACT.has(key)) continue;
if (ENV_SCRUB_PREFIXES.some((prefix) => key.startsWith(prefix))) continue;
env[key] = value;
}
// Explicit OPENRIG_* overrides — always win over inherited values
env["OPENRIG_PORT"] = String(opts.port);
env["OPENRIG_HOST"] = opts.host;
env["OPENRIG_DB"] = opts.db;
if (opts.transcriptsEnabled !== undefined) {
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
}
if (opts.transcriptsPath) {
env["OPENRIG_TRANSCRIPTS_PATH"] = opts.transcriptsPath;
}
return env;
}
export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Promise<DaemonState> {
const port = opts.port ?? DEFAULT_PORT;
const host = opts.host ?? DEFAULT_HOST;
const db = opts.db ?? DEFAULT_DB;
// Check if already running
const existing = readState(deps);
if (existing) {
@@ -169,11 +234,20 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
throw new Error(`Existing daemon process (pid ${existing.pid} on port ${existing.port}) is unresponsive — recover it before starting a new daemon.`);
}
// "dead" or "not_rigged" → stale state, safe to proceed
} else {
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
throw new Error(`Daemon on port ${port} is unresponsive, and daemon state is missing — recover it before starting a new daemon.`);
}
}
if (recoveredRunning) {
throw new Error(`Daemon already running on port ${port}, but daemon state is missing`);
}
}
const port = opts.port ?? DEFAULT_PORT;
const host = opts.host ?? "127.0.0.1";
const db = opts.db ?? DEFAULT_DB;
const daemonEntry = path.join(getDaemonPath(), "dist/index.js");
deps.mkdirp(OPENRIG_DIR);
@@ -181,14 +255,13 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const logFd = deps.openForAppend(LOG_FILE);
const child = deps.spawn(process.execPath, [daemonEntry], {
env: {
...process.env as Record<string, string>,
OPENRIG_PORT: String(port),
OPENRIG_HOST: host,
OPENRIG_DB: db,
...(opts.transcriptsEnabled !== undefined ? { OPENRIG_TRANSCRIPTS_ENABLED: String(opts.transcriptsEnabled) } : {}),
...(opts.transcriptsPath ? { OPENRIG_TRANSCRIPTS_PATH: opts.transcriptsPath } : {}),
},
env: buildDaemonEnv(process.env as Record<string, string>, {
port,
host,
db,
transcriptsEnabled: opts.transcriptsEnabled,
transcriptsPath: opts.transcriptsPath,
}),
stdio: ["ignore", logFd, logFd],
detached: true,
});
@@ -233,7 +306,22 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
export async function stopDaemon(deps: LifecycleDeps): Promise<void> {
const state = readState(deps);
if (!state) return; // Not running — clean exit
if (!state) {
const configured = resolveConfiguredDaemonTarget();
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
throw new Error(`Daemon state is missing and port ${configured.port} is unresponsive — cannot stop safely.`);
}
}
if (recoveredRunning) {
throw new Error(`Daemon is running on port ${configured.port}, but daemon state is missing — cannot stop safely.`);
}
return;
}
const stateFile = resolveLifecycleFile(deps, "daemon.json");
const pidState = await checkPid(state, deps);
@@ -275,14 +363,27 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
try {
const res = await fetchDaemonProbe(deps, `${openrigUrl}/healthz`);
const url = new URL(openrigUrl);
return { state: "running", port: Number(url.port) || 7433, host: url.hostname || "127.0.0.1", healthy: res.ok };
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: res.ok };
} catch {
return { state: "stopped" };
}
}
const state = readState(deps);
if (!state) return { state: "stopped" };
if (!state) {
const configured = resolveConfiguredDaemonTarget();
try {
const res = await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
return {
state: "running",
port: configured.port,
host: configured.host,
healthy: res.ok,
};
} catch {
return { state: "stopped" };
}
}
if (!deps.isProcessAlive(state.pid)) {
// Process dead — stale state
@@ -291,7 +392,7 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
}
// Process alive — check healthz
const host = state.host ?? "127.0.0.1";
const host = state.host ?? DEFAULT_HOST;
let healthy = false;
try {
const res = await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
+294
View File
@@ -0,0 +1,294 @@
import { execFileSync } from "node:child_process";
import Database from "better-sqlite3";
export const DESTROY_CONFIRM_TOKEN = "destroy-openrig-state";
export type DestroyScope = "state" | "all";
export interface DestroyRuntimeConfig {
stateRoot: string;
dbPath: string;
transcriptsPath: string;
daemonHost: string;
daemonPort: number;
}
export interface DestroyTarget {
path: string;
kind: "state_root" | "db_file" | "db_shm" | "db_wal" | "transcripts_dir";
backupPath?: string;
}
export interface DestroyPlan {
scope: DestroyScope;
backup: boolean;
stateRoot: string;
daemonHost: string;
daemonPort: number;
managedTmuxSessions: string[];
targets: DestroyTarget[];
warnings: string[];
}
export interface DestroyResult {
scope: DestroyScope;
backup: boolean;
stateRoot: string;
backupPaths: string[];
daemonStopped: boolean;
portCleared: boolean;
stateRecreated: boolean;
tmuxKilled: number;
tmuxMissing: number;
warnings: string[];
}
export interface ListenerInspection {
kind: "openrig" | "other_http" | "unreachable";
healthy?: boolean;
detail?: string;
}
export interface DestroyDeps {
stopDaemon: () => Promise<void>;
inspectListener: (host: string, port: number) => Promise<ListenerInspection>;
findListeningPid: (port: number) => number | null;
killProcess: (pid: number) => void;
exists: (path: string) => boolean;
renamePath: (from: string, to: string) => void;
removePath: (path: string) => void;
mkdirp: (path: string) => void;
listManagedTmuxSessions: (dbPath: string) => string[];
killTmuxSession: (sessionName: string) => boolean;
sleep: (ms: number) => Promise<void>;
now: () => Date;
}
function normalizePathForPrefix(path: string): string {
return path.endsWith("/") ? path : `${path}/`;
}
export function isWithinPath(candidate: string, root: string): boolean {
if (candidate === root) return true;
return candidate.startsWith(normalizePathForPrefix(root));
}
function formatTimestamp(date: Date): string {
const yyyy = String(date.getFullYear());
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
const ss = String(date.getSeconds()).padStart(2, "0");
return `${yyyy}${mm}${dd}-${hh}${min}${ss}`;
}
export function buildBackupPath(targetPath: string, exists: (path: string) => boolean, now = new Date()): string {
const stamp = formatTimestamp(now);
const base = `${targetPath}.backup-${stamp}`;
if (!exists(base)) return base;
let suffix = 2;
while (exists(`${base}-${suffix}`)) suffix += 1;
return `${base}-${suffix}`;
}
export function listManagedTmuxSessionsFromDb(dbPath: string): string[] {
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const rows = db.prepare(`
SELECT DISTINCT
COALESCE(
b.tmux_session,
(
SELECT s2.session_name
FROM sessions s2
WHERE s2.node_id = n.id
ORDER BY datetime(s2.created_at) DESC, s2.rowid DESC
LIMIT 1
)
) AS session_name
FROM nodes n
LEFT JOIN bindings b ON b.node_id = n.id
WHERE COALESCE(b.attachment_type, 'tmux') = 'tmux'
`).all() as Array<{ session_name: string | null }>;
return rows
.map((row) => row.session_name?.trim() ?? "")
.filter((name): name is string => Boolean(name))
.sort();
} finally {
db.close();
}
}
export function buildDestroyPlan(
scope: DestroyScope,
backup: boolean,
config: DestroyRuntimeConfig,
deps: Pick<DestroyDeps, "exists" | "listManagedTmuxSessions" | "now">
): DestroyPlan {
const warnings: string[] = [];
const targets: DestroyTarget[] = [];
const stateRootExists = deps.exists(config.stateRoot);
targets.push({
path: config.stateRoot,
kind: "state_root",
...(backup && stateRootExists ? { backupPath: buildBackupPath(config.stateRoot, deps.exists, deps.now()) } : {}),
});
const addExternalTarget = (path: string, kind: DestroyTarget["kind"]) => {
if (!deps.exists(path)) return;
if (isWithinPath(path, config.stateRoot)) return;
targets.push({
path,
kind,
...(backup ? { backupPath: buildBackupPath(path, deps.exists, deps.now()) } : {}),
});
warnings.push(`${kind} is outside state root and will be ${backup ? "backed up separately" : "deleted separately"}: ${path}`);
};
addExternalTarget(config.dbPath, "db_file");
addExternalTarget(`${config.dbPath}-shm`, "db_shm");
addExternalTarget(`${config.dbPath}-wal`, "db_wal");
addExternalTarget(config.transcriptsPath, "transcripts_dir");
let managedTmuxSessions: string[] = [];
if (scope === "all" && deps.exists(config.dbPath)) {
try {
managedTmuxSessions = deps.listManagedTmuxSessions(config.dbPath);
} catch (err) {
warnings.push(`Failed to enumerate managed tmux sessions from ${config.dbPath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return {
scope,
backup,
stateRoot: config.stateRoot,
daemonHost: config.daemonHost,
daemonPort: config.daemonPort,
managedTmuxSessions,
targets,
warnings,
};
}
function isLocalHost(host: string): boolean {
return host === "127.0.0.1" || host === "localhost" || host === "0.0.0.0";
}
export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Promise<DestroyResult> {
const warnings = [...plan.warnings];
const backupPaths: string[] = [];
let stateRecreated = false;
try {
await deps.stopDaemon();
} catch (err) {
warnings.push(`stopDaemon failed: ${err instanceof Error ? err.message : String(err)}`);
}
let daemonStopped = false;
let portCleared = false;
if (isLocalHost(plan.daemonHost)) {
const before = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
if (before.kind === "openrig") {
const listenerPid = deps.findListeningPid(plan.daemonPort);
if (listenerPid !== null) {
try {
deps.killProcess(listenerPid);
} catch (err) {
warnings.push(`Failed to terminate listener pid ${listenerPid}: ${err instanceof Error ? err.message : String(err)}`);
}
for (let attempt = 0; attempt < 20; attempt += 1) {
if ((await deps.inspectListener(plan.daemonHost, plan.daemonPort)).kind === "unreachable") break;
await deps.sleep(100);
}
} else {
warnings.push(`OpenRig responded on port ${plan.daemonPort}, but no listener pid was found.`);
}
} else if (before.kind === "other_http") {
warnings.push(`Port ${plan.daemonPort} is occupied by a live non-OpenRig HTTP listener. State was left untouched.`);
}
const after = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
portCleared = after.kind === "unreachable";
daemonStopped = portCleared;
} else {
warnings.push(`Daemon host ${plan.daemonHost} is not local. rig destroy only operates on local daemon targets; state was left untouched.`);
}
if (!portCleared) {
return {
scope: plan.scope,
backup: plan.backup,
stateRoot: plan.stateRoot,
backupPaths,
daemonStopped,
portCleared,
stateRecreated,
tmuxKilled: 0,
tmuxMissing: 0,
warnings,
};
}
let tmuxKilled = 0;
let tmuxMissing = 0;
if (plan.scope === "all") {
for (const sessionName of plan.managedTmuxSessions) {
if (deps.killTmuxSession(sessionName)) tmuxKilled += 1;
else tmuxMissing += 1;
}
}
for (const target of plan.targets) {
if (!deps.exists(target.path)) continue;
if (plan.backup && target.backupPath) {
deps.renamePath(target.path, target.backupPath);
backupPaths.push(target.backupPath);
} else {
deps.removePath(target.path);
}
}
deps.mkdirp(plan.stateRoot);
stateRecreated = true;
return {
scope: plan.scope,
backup: plan.backup,
stateRoot: plan.stateRoot,
backupPaths,
daemonStopped,
portCleared,
stateRecreated,
tmuxKilled,
tmuxMissing,
warnings,
};
}
export function findListeningPidWithLsof(port: number): number | null {
try {
const output = execFileSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { encoding: "utf-8" }).trim();
if (!output) return null;
const first = output.split(/\s+/)[0]?.trim();
if (!first) return null;
const pid = parseInt(first, 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
export function killTmuxSessionWithCli(sessionName: string): boolean {
try {
execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
+8
View File
@@ -31,6 +31,7 @@ import { configCommand } from "./commands/config.js";
import { preflightCommand } from "./commands/preflight.js";
import { doctorCommand } from "./commands/doctor.js";
import { expandCommand } from "./commands/expand.js";
import { envCommand } from "./commands/env.js";
import { askCommand } from "./commands/ask.js";
import { chatroomCommand } from "./commands/chatroom.js";
import { specsCommand } from "./commands/specs.js";
@@ -40,6 +41,8 @@ import { releaseCommand } from "./commands/release.js";
import { launchCommand } from "./commands/launch.js";
import { removeCommand } from "./commands/remove.js";
import { shrinkCommand } from "./commands/shrink.js";
import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
import { setupCommand } from "./commands/setup.js";
import type { LifecycleDeps } from "./daemon-lifecycle.js";
import { CLI_VERSION } from "./version.js";
@@ -74,11 +77,13 @@ export interface ProgramDeps {
specsDeps?: StatusDeps;
whoamiDeps?: StatusDeps;
expandDeps?: StatusDeps;
envDeps?: StatusDeps;
unclaimDeps?: StatusDeps;
releaseDeps?: StatusDeps;
launchDeps?: StatusDeps;
removeDeps?: StatusDeps;
shrinkDeps?: StatusDeps;
destroyDeps?: DestroyCommandDeps;
configPath?: string;
}
@@ -123,11 +128,14 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(preflightCommand());
program.addCommand(doctorCommand());
program.addCommand(expandCommand(depsOverride?.expandDeps));
program.addCommand(envCommand(depsOverride?.envDeps));
program.addCommand(unclaimCommand(depsOverride?.unclaimDeps));
program.addCommand(releaseCommand(depsOverride?.releaseDeps));
program.addCommand(launchCommand(depsOverride?.launchDeps));
program.addCommand(removeCommand(depsOverride?.removeDeps));
program.addCommand(shrinkCommand(depsOverride?.shrinkDeps));
program.addCommand(destroyCommand(depsOverride?.destroyDeps));
program.addCommand(setupCommand());
return program;
}
+7 -1
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
import type { DaemonClient, DaemonResponse } from "./client.js";
import { CLI_VERSION } from "./version.js";
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
type TextResult = {
content: Array<{ type: "text"; text: string }>;
isError?: true;
@@ -64,7 +66,11 @@ export function createMcpServer(client: DaemonClient): McpServer {
},
async ({ sourceRef, plan, autoApprove, targetRoot }) => {
try {
const res = await client.post("/api/up", { sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot });
const res = await client.post(
"/api/up",
{ sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot },
plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
);
return mapResult(res);
} catch (err) {
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
+4 -2
View File
@@ -2,7 +2,11 @@ import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const warnedKeys = new Set<string>();
export function getOpenRigHome(): string {
const configured = readOpenRigEnv("OPENRIG_HOME", "RIGGED_HOME");
if (configured !== undefined) return configured;
return join(homedir(), ".openrig");
}
@@ -13,8 +17,6 @@ export function getLegacyRiggedHome(): string {
export const OPENRIG_HOME = getOpenRigHome();
export const LEGACY_RIGGED_HOME = getLegacyRiggedHome();
const warnedKeys = new Set<string>();
function warnOnce(key: string, message: string): void {
if (warnedKeys.has(key)) return;
warnedKeys.add(key);
+14 -4
View File
@@ -3,6 +3,7 @@ import { accessSync, mkdirSync, constants } from "node:fs";
import { dirname } from "node:path";
import type { ConfigStore, RiggedConfig } from "./config-store.js";
import type { DaemonStatus } from "./daemon-lifecycle.js";
import { buildTmuxControlFailure, probeTmuxControlAsync } from "./tmux-health.js";
export interface PreflightCheck {
name: string;
@@ -135,10 +136,8 @@ export class SystemPreflight {
}
// 2. tmux availability
try {
await this.deps.exec("tmux -V");
checks.push({ name: "tmux", ok: true });
} catch {
const tmuxProbe = await probeTmuxControlAsync(this.deps.exec);
if (tmuxProbe.code === "not_installed") {
checks.push({
name: "tmux",
ok: false,
@@ -146,6 +145,17 @@ export class SystemPreflight {
reason: "OpenRig uses tmux to create and control agent sessions.",
fix: "Install tmux (brew install tmux on macOS, apt install tmux on Debian/Ubuntu).",
});
} else if (tmuxProbe.available) {
checks.push({ name: "tmux", ok: true });
} else {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
checks.push({
name: "tmux",
ok: false,
error: failure.message,
reason: failure.reason,
fix: failure.fix,
});
}
// 3. Writable OpenRig home + transcript path
+114
View File
@@ -0,0 +1,114 @@
export interface TmuxProbeResult {
installed: boolean;
available: boolean;
version: string | null;
detail: string | null;
code: "available" | "no_server" | "not_installed" | "unhealthy";
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function isTmuxNoServerMessage(message: string): boolean {
const normalized = message.toLowerCase();
return normalized.includes("no server running")
|| normalized.includes("failed to connect to server")
|| (normalized.includes("error connecting to") && normalized.includes("no such file or directory"));
}
export function buildTmuxControlFailure(message: string): { message: string; reason: string; fix: string } {
return {
message: "tmux installed, but the default control socket is unhealthy.",
reason: `OpenRig uses tmux control commands to launch, inspect, and manage agent sessions. The default tmux socket is returning: ${message}`,
fix: "Capture any needed state from visible tmux panes, then restart the default tmux server before retrying OpenRig. If this happened after a machine restore, treat it as attention required rather than a healthy running state.",
};
}
export function probeTmuxControl(exec: (cmd: string) => string): TmuxProbeResult {
let version: string;
try {
version = exec("tmux -V").trim();
} catch (err) {
return {
installed: false,
available: false,
version: null,
detail: errorMessage(err),
code: "not_installed",
};
}
try {
exec("tmux list-sessions");
return {
installed: true,
available: true,
version,
detail: null,
code: "available",
};
} catch (err) {
const detail = errorMessage(err).trim();
if (isTmuxNoServerMessage(detail)) {
return {
installed: true,
available: true,
version,
detail,
code: "no_server",
};
}
return {
installed: true,
available: false,
version,
detail,
code: "unhealthy",
};
}
}
export async function probeTmuxControlAsync(exec: (cmd: string) => Promise<string>): Promise<TmuxProbeResult> {
let version: string;
try {
version = (await exec("tmux -V")).trim();
} catch (err) {
return {
installed: false,
available: false,
version: null,
detail: errorMessage(err),
code: "not_installed",
};
}
try {
await exec("tmux list-sessions");
return {
installed: true,
available: true,
version,
detail: null,
code: "available",
};
} catch (err) {
const detail = errorMessage(err).trim();
if (isTmuxNoServerMessage(detail)) {
return {
installed: true,
available: true,
version,
detail,
code: "no_server",
};
}
return {
installed: true,
available: false,
version,
detail,
code: "unhealthy",
};
}
}
+47 -1
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveBinEntry } from "../src/bin-wrapper.js";
import { resolveBinEntry, resolveNodeReexecBinary } from "../src/bin-wrapper.js";
describe("bin-wrapper", () => {
let tempDir: string | null = null;
@@ -24,4 +24,50 @@ describe("bin-wrapper", () => {
const entry = resolveBinEntry(symlinkPath, pathToFileURL(wrapperSource).href);
expect(entry).toBe(path.resolve(path.dirname(wrapperSource), "../dist/index.js"));
});
it("prefers the sibling node next to the invoked rig binary when current node comes from elsewhere", () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
const prefixDir = path.join(tempDir, "prefix");
const binDir = path.join(prefixDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
const rigPath = path.join(binDir, "rig");
const siblingNodePath = path.join(binDir, "node");
fs.symlinkSync(wrapperSource, rigPath);
fs.writeFileSync(siblingNodePath, "");
const reexecNode = resolveNodeReexecBinary(
"/opt/homebrew/bin/node",
rigPath,
{},
(candidate) => candidate === siblingNodePath,
(candidate) => candidate,
);
expect(reexecNode).toBe(siblingNodePath);
});
it("does not re-exec when already running under the sibling node", () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
const prefixDir = path.join(tempDir, "prefix");
const binDir = path.join(prefixDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
const rigPath = path.join(binDir, "rig");
const siblingNodePath = path.join(binDir, "node");
fs.symlinkSync(wrapperSource, rigPath);
fs.writeFileSync(siblingNodePath, "");
const reexecNode = resolveNodeReexecBinary(
siblingNodePath,
rigPath,
{},
(candidate) => candidate === siblingNodePath,
(candidate) => candidate,
);
expect(reexecNode).toBeNull();
});
});
+29 -1
View File
@@ -182,7 +182,9 @@ describe("Bootstrap CLI", () => {
// T7: daemon not running -> error + exit 1
it("daemon not running returns exit code 1", async () => {
const stoppedDeps: StatusDeps = {
lifecycleDeps: mockLifecycleDeps(),
lifecycleDeps: mockLifecycleDeps({
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
const prog = new Command();
@@ -214,6 +216,32 @@ describe("Bootstrap CLI", () => {
expect((body.cwdOverride as string).endsWith("relative/project")).toBe(true);
});
it("bootstrap apply uses a long-running daemon timeout budget", async () => {
let timeoutMs: number | undefined;
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
timeoutMs = options?.timeoutMs;
return {
status: 201,
data: { runId: "run-2", status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
};
});
const deps: StatusDeps = {
lifecycleDeps: runningDeps(port).lifecycleDeps,
clientFactory: () => ({ post } as unknown as DaemonClient),
};
const prog = new Command();
prog.exitOverride();
prog.addCommand(bootstrapCommand(deps));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "bootstrap", "/tmp/rig.yaml", "--yes"]);
});
expect(timeoutMs).toBe(120_000);
});
// T9: partial status -> exit 1 (R2-M1)
it("partial result returns exit code 1", async () => {
const partialServer = http.createServer((_, res) => {
+25 -2
View File
@@ -124,6 +124,29 @@ describe("DaemonClient", () => {
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
});
it("per-request timeout override can extend a long-running call", async () => {
const delayedFetch: typeof fetch = ((url: string | URL | Request, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
resolve(new Response(JSON.stringify({ ok: true, url: String(url) }), {
status: 200,
headers: { "Content-Type": "application/json" },
}));
}, 40);
init?.signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(init.signal?.reason ?? new Error("aborted"));
}, { once: true });
})) as typeof fetch;
const client = new DaemonClient("http://localhost:9999", { fetchImpl: delayedFetch, timeoutMs: 20 });
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
const res = await client.get<{ ok: boolean; url: string }>("/api/rigs", { timeoutMs: 100 });
expect(res.status).toBe(200);
expect(res.data.ok).toBe(true);
expect(res.data.url).toContain("/api/rigs");
});
// Test 5: Client uses OPENRIG_URL env, falls back to http://127.0.0.1:7433
it("uses OPENRIG_URL env when set, falls back to http://127.0.0.1:7433", () => {
// Default (no env)
@@ -152,8 +175,8 @@ describe("DaemonClient", () => {
const execFileAsync = promisify(execFile);
const { resolve } = await import("node:path");
const cliEntry = resolve(import.meta.dirname, "../src/index.ts");
const result = await execFileAsync("npx", ["tsx", cliEntry, "--version"], {
const cliEntry = resolve(import.meta.dirname, "../dist/index.js");
const result = await execFileAsync("node", [cliEntry, "--version"], {
cwd: resolve(import.meta.dirname, ".."),
});
+287 -7
View File
@@ -9,6 +9,7 @@ import {
tailLogs,
getDaemonPath,
resolveDaemonPath,
buildDaemonEnv,
STATE_FILE,
LOG_FILE,
OPENRIG_DIR,
@@ -36,6 +37,22 @@ function mockDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
};
}
function startableDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
let spawned = false;
const spawn = overrides?.spawn ?? vi.fn(() => {
spawned = true;
return { pid: 12345, unref: vi.fn() } as unknown as ChildProcess;
});
return mockDeps({
...overrides,
spawn,
fetch: overrides?.fetch ?? vi.fn(async () => {
if (!spawned) throw new Error("refused");
return { ok: true };
}),
});
}
function writtenState(deps: LifecycleDeps): DaemonState {
const call = (deps.writeFile as ReturnType<typeof vi.fn>).mock.calls.find(
(c: unknown[]) => (c[0] as string).endsWith("daemon.json")
@@ -54,7 +71,7 @@ describe("Daemon Lifecycle", () => {
// Test 2: start constructs exact spawn command with correct env/redirect
it("start: constructs spawn with node, daemon entry, env, and log redirect", async () => {
const deps = mockDeps();
const deps = startableDeps();
await startDaemon({ port: 7433, db: "openrig.sqlite" }, deps);
const spawnMock = deps.spawn as ReturnType<typeof vi.fn>;
@@ -73,7 +90,7 @@ describe("Daemon Lifecycle", () => {
// Test 3: start waits for healthz, writes daemon.json with pid+port+db+startedAt
it("start: writes daemon.json with pid, port, db, startedAt after healthz", async () => {
const deps = mockDeps();
const deps = startableDeps();
const result = await startDaemon({ port: 8000, db: "test.sqlite" }, deps);
expect(result.pid).toBe(12345);
@@ -102,6 +119,19 @@ describe("Daemon Lifecycle", () => {
await expect(startDaemon({}, deps)).rejects.toThrow(/already running/i);
});
it("start: recovered running daemon without daemon.json -> throws instead of spawning duplicate", async () => {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async (url: string) => {
expect(url).toBe("http://127.0.0.1:7433/healthz");
return { ok: true };
}),
});
await expect(startDaemon({}, deps)).rejects.toThrow(/already running/i);
expect(deps.spawn).not.toHaveBeenCalled();
});
// Test 5: stop reads pid from daemon.json, sends SIGTERM, removes daemon.json
it("stop: reads pid, sends SIGTERM, removes daemon.json", async () => {
const state: DaemonState = { pid: 555, port: 7433, db: "openrig.sqlite", startedAt: "2026-01-01T00:00:00Z" };
@@ -126,12 +156,26 @@ describe("Daemon Lifecycle", () => {
it("stop: not running -> does not throw", async () => {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
// Should not throw
await expect(stopDaemon(deps)).resolves.toBeUndefined();
});
it("stop: recovered running daemon without daemon.json -> throws honest error", async () => {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async (url: string) => {
expect(url).toBe("http://127.0.0.1:7433/healthz");
return { ok: true };
}),
});
await expect(stopDaemon(deps)).rejects.toThrow(/state is missing|cannot stop safely/i);
expect(deps.kill).not.toHaveBeenCalled();
});
// Test 7: status reads port from daemon.json, reports running with port
it("status: running daemon (pid alive + healthz ok) -> { state: 'running', port, pid }", async () => {
const state: DaemonState = { pid: 777, port: 9000, db: "x.db", startedAt: "2026-01-01T00:00:00Z" };
@@ -157,12 +201,42 @@ describe("Daemon Lifecycle", () => {
it("status: no daemon.json -> { state: 'stopped' }", async () => {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
const status = await getDaemonStatus(deps);
expect(status.state).toBe("stopped");
});
it("status: no daemon.json but configured healthz responds -> running without pid", async () => {
const savedPort = process.env["OPENRIG_PORT"];
const savedHost = process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = "7555";
process.env["OPENRIG_HOST"] = "127.0.0.1";
try {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async (url: string) => {
expect(url).toBe("http://127.0.0.1:7555/healthz");
return { ok: true };
}),
});
const status = await getDaemonStatus(deps);
expect(status).toEqual({
state: "running",
host: "127.0.0.1",
port: 7555,
healthy: true,
});
} finally {
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
// Test 9: status stale (daemon.json exists, process dead) -> reports stale, cleans up
it("status: stale (daemon.json exists, pid dead) -> { state: 'stale' }, cleans up", async () => {
const state: DaemonState = { pid: 888, port: 7433, db: "x.db", startedAt: "2026-01-01T00:00:00Z" };
@@ -182,7 +256,7 @@ describe("Daemon Lifecycle", () => {
// Test 10: start --port flag stored in daemon.json and forwarded to env
it("start: custom port stored in daemon.json and forwarded to spawn env", async () => {
const deps = mockDeps();
const deps = startableDeps();
await startDaemon({ port: 9999 }, deps);
const state = writtenState(deps);
@@ -232,7 +306,8 @@ describe("Daemon Lifecycle", () => {
const { Command } = await import("commander");
const deps = mockDeps({
exists: vi.fn(() => false), // no daemon.json -> stopped
exists: vi.fn(() => false), // no daemon.json + no healthy daemon -> stopped
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
const program = new Command();
@@ -255,9 +330,74 @@ describe("Daemon Lifecycle", () => {
expect(logs.join("\n")).toMatch(/stopped/i);
});
it("daemonCommand status omits pid when daemon is recovered without state", async () => {
const savedPort = process.env["OPENRIG_PORT"];
const savedHost = process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = "7555";
process.env["OPENRIG_HOST"] = "127.0.0.1";
try {
const { daemonCommand } = await import("../src/commands/daemon.js");
const { Command } = await import("commander");
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => ({ ok: true })),
});
const program = new Command();
program.addCommand(daemonCommand(deps));
const logs: string[] = [];
const origLog = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await program.parseAsync(["node", "rig", "daemon", "status"]);
} finally {
console.log = origLog;
}
const output = logs.join("\n");
expect(output).toContain("Daemon running on port 7555");
expect(output).not.toContain("pid undefined");
} finally {
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
it("daemonCommand stop surfaces missing-state recovery error instead of claiming success", async () => {
const { daemonCommand } = await import("../src/commands/daemon.js");
const { Command } = await import("commander");
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => ({ ok: true })),
});
const program = new Command();
program.addCommand(daemonCommand(deps));
const logs: string[] = [];
const origErr = console.error;
const savedExitCode = process.exitCode;
process.exitCode = undefined;
console.error = (...args: unknown[]) => logs.push(args.join(" "));
try {
await program.parseAsync(["node", "rig", "daemon", "stop"]);
} finally {
console.error = origErr;
}
expect(logs.join("\n")).toMatch(/state is missing|cannot stop safely/i);
expect(process.exitCode).toBe(1);
process.exitCode = savedExitCode;
});
// Test 15: start creates ~/.openrig directory if missing
it("start: creates OPENRIG_DIR if missing", async () => {
const deps = mockDeps();
const deps = startableDeps();
await startDaemon({ port: 7433 }, deps);
expect(deps.mkdirp).toHaveBeenCalledWith(OPENRIG_DIR);
@@ -440,6 +580,7 @@ describe("Daemon Lifecycle", () => {
if (p === STATE_FILE) return "NOT VALID JSON {{{";
return null;
}),
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
const status = await getDaemonStatus(deps);
@@ -448,7 +589,7 @@ describe("Daemon Lifecycle", () => {
// Test 22: malformed daemon.json -> startDaemon proceeds (treats as no state)
it("malformed daemon.json -> startDaemon proceeds normally", async () => {
const deps = mockDeps({
const deps = startableDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return "GARBAGE";
@@ -500,7 +641,7 @@ describe("Daemon Lifecycle", () => {
// Test 25: start spawns process.execPath, not bare "node"
it("start: spawns process.execPath as the Node binary", async () => {
const deps = mockDeps();
const deps = startableDeps();
await startDaemon({ port: 7433, db: "openrig.sqlite" }, deps);
const spawnMock = deps.spawn as ReturnType<typeof vi.fn>;
@@ -638,3 +779,142 @@ describe("resolveDaemonPath", () => {
expect(result).toBe(path.resolve("/repo/packages/cli/dist", "../../daemon"));
});
});
describe("startDaemon env sanitization", () => {
it("startDaemon spawns with sanitized env that excludes terminal/GUI vars", async () => {
// Pollute process.env temporarily
const saved: Record<string, string | undefined> = {};
const pollutants: Record<string, string> = {
GHOSTTY_BIN_DIR: "/bad",
TERM_PROGRAM: "ghostty",
CMUX_WORKSPACE: "workspace:1",
CMUX_PANEL_ID: "panel:7",
CMUX_BUNDLED_CLI_PATH: "/Applications/cmux.app/Contents/Resources/bin/cmux",
CODEX_CI: "1",
COMMAND_MODE: "unix2003",
__CFBundleIdentifier: "com.test",
};
for (const [k, v] of Object.entries(pollutants)) {
saved[k] = process.env[k];
process.env[k] = v;
}
try {
const deps = startableDeps();
await startDaemon({ port: 7433, host: "127.0.0.1", db: "/tmp/test.db" }, deps);
const spawnCall = (deps.spawn as ReturnType<typeof vi.fn>).mock.calls[0];
const spawnEnv = spawnCall![2].env as Record<string, string>;
// Scrubbed vars must NOT be in spawn env
expect(spawnEnv["GHOSTTY_BIN_DIR"]).toBeUndefined();
expect(spawnEnv["TERM_PROGRAM"]).toBeUndefined();
expect(spawnEnv["CMUX_WORKSPACE"]).toBeUndefined();
expect(spawnEnv["CODEX_CI"]).toBeUndefined();
expect(spawnEnv["COMMAND_MODE"]).toBeUndefined();
expect(spawnEnv["__CFBundleIdentifier"]).toBeUndefined();
// cmux context needed for workspace-bound commands should survive
expect(spawnEnv["CMUX_PANEL_ID"]).toBe("panel:7");
expect(spawnEnv["CMUX_BUNDLED_CLI_PATH"]).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
// Core vars must be present
expect(spawnEnv["HOME"]).toBeDefined();
expect(spawnEnv["OPENRIG_PORT"]).toBe("7433");
} finally {
// Restore process.env
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
});
});
describe("buildDaemonEnv", () => {
it("strips terminal/GUI vars and preserves core vars", () => {
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
PATH: "/opt/homebrew/bin:/usr/bin:/bin",
USER: "tester",
SHELL: "/bin/zsh",
COLORTERM: "truecolor",
COMMAND_MODE: "unix2003",
CODEX_CI: "1",
CODEX_THREAD_ID: "thread-123",
GHOSTTY_BIN_DIR: "/Applications/Ghostty.app/bin",
GHOSTTY_RESOURCES_DIR: "/Applications/Ghostty.app/resources",
GHOSTTY_SHELL_FEATURES: "cursor,title",
TERM_PROGRAM: "ghostty",
TERM_PROGRAM_VERSION: "1.0",
TERMINFO: "/Applications/Ghostty.app/terminfo",
XPC_FLAGS: "0x0",
XPC_SERVICE_NAME: "0",
__CFBundleIdentifier: "com.cmuxterm.app",
__CF_USER_TEXT_ENCODING: "0x1F5:0x0:0x0",
CMUX_BUNDLE_ID: "com.cmuxterm.app",
CMUX_BUNDLED_CLI_PATH: "/Applications/cmux.app/Contents/Resources/bin/cmux",
CMUX_PANEL_ID: "panel:1",
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
CMUX_TAB_ID: "tab:1",
CMUX_WORKSPACE: "workspace:1",
CMUX_WORKSPACE_ID: "workspace:1",
CMUX_SURFACE_ID: "surface:3",
LANG: "en_US.UTF-8",
};
const result = buildDaemonEnv(baseEnv, { port: 7433, host: "127.0.0.1", db: "/tmp/test.db" });
// Core vars preserved
expect(result["HOME"]).toBe("/Users/tester");
expect(result["PATH"]).toBe("/opt/homebrew/bin:/usr/bin:/bin");
expect(result["USER"]).toBe("tester");
expect(result["SHELL"]).toBe("/bin/zsh");
expect(result["LANG"]).toBe("en_US.UTF-8");
// OPENRIG_* set from opts
expect(result["OPENRIG_PORT"]).toBe("7433");
expect(result["OPENRIG_HOST"]).toBe("127.0.0.1");
expect(result["OPENRIG_DB"]).toBe("/tmp/test.db");
// Scrubbed vars absent
expect(result["COLORTERM"]).toBeUndefined();
expect(result["COMMAND_MODE"]).toBeUndefined();
expect(result["CODEX_CI"]).toBeUndefined();
expect(result["CODEX_THREAD_ID"]).toBeUndefined();
expect(result["GHOSTTY_BIN_DIR"]).toBeUndefined();
expect(result["GHOSTTY_RESOURCES_DIR"]).toBeUndefined();
expect(result["GHOSTTY_SHELL_FEATURES"]).toBeUndefined();
expect(result["TERM_PROGRAM"]).toBeUndefined();
expect(result["TERM_PROGRAM_VERSION"]).toBeUndefined();
expect(result["TERMINFO"]).toBeUndefined();
expect(result["XPC_FLAGS"]).toBeUndefined();
expect(result["XPC_SERVICE_NAME"]).toBeUndefined();
expect(result["__CFBundleIdentifier"]).toBeUndefined();
expect(result["__CF_USER_TEXT_ENCODING"]).toBeUndefined();
expect(result["CMUX_SOCKET_PATH"]).toBeUndefined();
expect(result["CMUX_WORKSPACE"]).toBeUndefined();
expect(result["CMUX_SURFACE_ID"]).toBeUndefined();
// cmux context needed for workspace-bound commands is preserved
expect(result["CMUX_BUNDLE_ID"]).toBe("com.cmuxterm.app");
expect(result["CMUX_BUNDLED_CLI_PATH"]).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
expect(result["CMUX_PANEL_ID"]).toBe("panel:1");
expect(result["CMUX_TAB_ID"]).toBe("tab:1");
expect(result["CMUX_WORKSPACE_ID"]).toBe("workspace:1");
});
it("explicit opts override inherited OPENRIG_* from base env", () => {
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
PATH: "/usr/bin",
OPENRIG_PORT: "9999",
OPENRIG_DB: "/old/path.db",
};
const result = buildDaemonEnv(baseEnv, { port: 7433, host: "127.0.0.1", db: "/new/path.db" });
expect(result["OPENRIG_PORT"]).toBe("7433");
expect(result["OPENRIG_DB"]).toBe("/new/path.db");
});
});
+448
View File
@@ -0,0 +1,448 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
import Database from "better-sqlite3";
import {
DESTROY_CONFIRM_TOKEN,
buildBackupPath,
buildDestroyPlan,
executeDestroy,
listManagedTmuxSessionsFromDb,
type DestroyDeps,
} from "../src/destroy-helpers.js";
import { destroyCommand } from "../src/commands/destroy.js";
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
process.exitCode = undefined;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
console.error = (...args: unknown[]) => logs.push(args.join(" "));
try {
await fn();
} finally {
console.log = origLog;
console.error = origErr;
}
const exitCode = process.exitCode;
process.exitCode = origExitCode;
resolve({ logs, exitCode });
});
}
function makeDestroyDeps(overrides?: Partial<DestroyDeps>): DestroyDeps {
return {
stopDaemon: vi.fn(async () => {}),
inspectListener: vi.fn(async () => ({ kind: "unreachable" as const })),
findListeningPid: vi.fn(() => null),
killProcess: vi.fn(),
exists: vi.fn(() => false),
renamePath: vi.fn(),
removePath: vi.fn(),
mkdirp: vi.fn(),
listManagedTmuxSessions: vi.fn(() => []),
killTmuxSession: vi.fn(() => true),
sleep: vi.fn(async () => {}),
now: vi.fn(() => new Date("2026-04-10T08:10:11Z")),
...overrides,
};
}
describe("destroy helpers", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "openrig-destroy-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("buildBackupPath appends timestamp and resolves collisions", () => {
const target = join(tmpDir, ".openrig");
const first = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
expect(basename(first)).toMatch(/^\.openrig\.backup-20260410-\d{6}$/);
mkdirSync(first, { recursive: true });
const second = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
expect(second).toBe(`${first}-2`);
});
it("listManagedTmuxSessionsFromDb returns distinct tmux-backed sessions", () => {
const dbPath = join(tmpDir, "openrig.sqlite");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE nodes (id TEXT PRIMARY KEY);
CREATE TABLE bindings (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
tmux_session TEXT,
tmux_window TEXT,
tmux_pane TEXT,
cmux_workspace TEXT,
cmux_surface TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
attachment_type TEXT NOT NULL DEFAULT 'tmux',
external_session_name TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
session_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n2");
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n3");
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
.run("b1", "n1", "orch1-lead@demo", "tmux");
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
.run("b2", "n2", "orch1-lead@demo", "tmux");
db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)")
.run("s3", "n3", "dev1-impl@demo");
db.prepare("INSERT INTO bindings (id, node_id, attachment_type, external_session_name) VALUES (?, ?, ?, ?)")
.run("b3", "n3", "external_cli", "ext-cli");
db.close();
expect(listManagedTmuxSessionsFromDb(dbPath)).toEqual(["orch1-lead@demo"]);
});
it("listManagedTmuxSessionsFromDb prefers the current session for a node over historical names", () => {
const dbPath = join(tmpDir, "openrig-current.sqlite");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE nodes (id TEXT PRIMARY KEY);
CREATE TABLE bindings (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
tmux_session TEXT,
tmux_window TEXT,
tmux_pane TEXT,
cmux_workspace TEXT,
cmux_surface TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
attachment_type TEXT NOT NULL DEFAULT 'tmux',
external_session_name TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
session_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
db.prepare("INSERT INTO sessions (id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)")
.run("s1", "n1", "old-name@demo", "2026-04-10T08:00:00Z");
db.prepare("INSERT INTO sessions (id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)")
.run("s2", "n1", "current-name@demo", "2026-04-10T08:05:00Z");
db.close();
expect(listManagedTmuxSessionsFromDb(dbPath)).toEqual(["current-name@demo"]);
});
it("buildDestroyPlan includes external db/transcript targets outside state root", () => {
const stateRoot = join(tmpDir, ".openrig");
const externalDb = join(tmpDir, "custom", "openrig.sqlite");
const externalTx = join(tmpDir, "custom", "transcripts");
mkdirSync(stateRoot, { recursive: true });
mkdirSync(join(tmpDir, "custom"), { recursive: true });
writeFileSync(externalDb, "");
mkdirSync(externalTx, { recursive: true });
const deps = makeDestroyDeps({
exists: (path: string) => existsSync(path),
listManagedTmuxSessions: () => [],
});
const plan = buildDestroyPlan("state", true, {
stateRoot,
dbPath: externalDb,
transcriptsPath: externalTx,
daemonHost: "127.0.0.1",
daemonPort: 7433,
}, deps);
expect(plan.targets.map((target) => target.kind)).toEqual(["state_root", "db_file", "transcripts_dir"]);
expect(plan.warnings.length).toBe(2);
});
it("executeDestroy stops daemon, kills managed tmux sessions, backs up state root, and recreates it", async () => {
const stateRoot = join(tmpDir, ".openrig");
mkdirSync(stateRoot, { recursive: true });
writeFileSync(join(stateRoot, "daemon.json"), "{}");
const dbPath = join(stateRoot, "openrig.sqlite");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE nodes (id TEXT PRIMARY KEY);
CREATE TABLE bindings (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
tmux_session TEXT,
tmux_window TEXT,
tmux_pane TEXT,
cmux_workspace TEXT,
cmux_surface TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
attachment_type TEXT NOT NULL DEFAULT 'tmux',
external_session_name TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
session_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n2");
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
.run("b1", "n1", "orch1-lead@demo", "tmux");
db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)")
.run("s2", "n2", "dev1-impl@demo");
db.close();
const deps = makeDestroyDeps({
exists: (path: string) => existsSync(path),
renamePath: (from: string, to: string) => renameSync(from, to),
mkdirp: (path: string) => mkdirSync(path, { recursive: true }),
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
killTmuxSession: (sessionName: string) => sessionName !== "dev1-impl@demo",
inspectListener: vi.fn()
.mockResolvedValueOnce({ kind: "openrig" })
.mockResolvedValueOnce({ kind: "unreachable" })
.mockResolvedValueOnce({ kind: "unreachable" }),
findListeningPid: () => 1234,
});
const plan = buildDestroyPlan("all", true, {
stateRoot,
dbPath,
transcriptsPath: join(stateRoot, "transcripts"),
daemonHost: "127.0.0.1",
daemonPort: 7433,
}, deps);
const result = await executeDestroy(plan, deps);
expect(result.daemonStopped).toBe(true);
expect(result.portCleared).toBe(true);
expect(result.stateRecreated).toBe(true);
expect(result.tmuxKilled).toBe(1);
expect(result.tmuxMissing).toBe(1);
expect(result.backupPaths).toHaveLength(1);
expect(existsSync(stateRoot)).toBe(true);
expect(deps.killProcess).toHaveBeenCalledWith(1234);
});
it("executeDestroy leaves state untouched when a local listener is still live but cannot be safely cleared", async () => {
const stateRoot = join(tmpDir, ".openrig-live");
mkdirSync(stateRoot, { recursive: true });
const removePath = vi.fn();
const mkdirp = vi.fn();
const deps = makeDestroyDeps({
exists: () => true,
removePath,
mkdirp,
inspectListener: vi.fn(async () => ({ kind: "openrig" })),
findListeningPid: () => null,
});
const result = await executeDestroy({
scope: "state",
backup: false,
stateRoot,
daemonHost: "127.0.0.1",
daemonPort: 7433,
managedTmuxSessions: [],
targets: [{ path: stateRoot, kind: "state_root" }],
warnings: [],
}, deps);
expect(result.portCleared).toBe(false);
expect(result.stateRecreated).toBe(false);
expect(removePath).not.toHaveBeenCalled();
expect(mkdirp).not.toHaveBeenCalled();
});
it("executeDestroy does not kill a live non-OpenRig HTTP listener", async () => {
const killProcess = vi.fn();
const deps = makeDestroyDeps({
exists: () => true,
removePath: vi.fn(),
mkdirp: vi.fn(),
inspectListener: vi.fn(async () => ({ kind: "other_http", detail: "HTTP 200" })),
findListeningPid: () => 7777,
killProcess,
});
const result = await executeDestroy({
scope: "state",
backup: false,
stateRoot: join(tmpDir, ".openrig-http"),
daemonHost: "127.0.0.1",
daemonPort: 7433,
managedTmuxSessions: [],
targets: [{ path: join(tmpDir, ".openrig-http"), kind: "state_root" }],
warnings: [],
}, deps);
expect(result.portCleared).toBe(false);
expect(killProcess).not.toHaveBeenCalled();
});
it("executeDestroy does not enumerate or kill tmux sessions for --state", async () => {
const stateRoot = join(tmpDir, ".openrig-state-only");
mkdirSync(stateRoot, { recursive: true });
const listManagedTmuxSessions = vi.fn(() => ["orchestrator@demo"]);
const killTmuxSession = vi.fn(() => true);
const deps = makeDestroyDeps({
exists: () => true,
removePath: vi.fn(),
mkdirp: vi.fn(),
listManagedTmuxSessions,
killTmuxSession,
});
const plan = buildDestroyPlan("state", false, {
stateRoot,
dbPath: join(stateRoot, "openrig.sqlite"),
transcriptsPath: join(stateRoot, "transcripts"),
daemonHost: "127.0.0.1",
daemonPort: 7433,
}, deps);
await executeDestroy(plan, deps);
expect(listManagedTmuxSessions).not.toHaveBeenCalled();
expect(killTmuxSession).not.toHaveBeenCalled();
});
it("executeDestroy does not report remote daemon targets as cleared", async () => {
const deps = makeDestroyDeps({
exists: () => false,
mkdirp: vi.fn(),
});
const result = await executeDestroy({
scope: "state",
backup: false,
stateRoot: join(tmpDir, ".openrig-remote"),
daemonHost: "10.0.0.5",
daemonPort: 7433,
managedTmuxSessions: [],
targets: [],
warnings: [],
}, deps);
expect(result.daemonStopped).toBe(false);
expect(result.portCleared).toBe(false);
expect(result.stateRecreated).toBe(false);
});
});
describe("destroy command", () => {
it("requires exactly one destroy scope", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
destroyDeps: makeDestroyDeps(),
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("Specify exactly one destroy scope");
});
it("requires explicit confirmation token", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
destroyDeps: makeDestroyDeps(),
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--state", "--yes", "--confirm", "wrong"]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
});
it("requires --yes", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
destroyDeps: makeDestroyDeps(),
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--state", "--confirm", DESTROY_CONFIRM_TOKEN]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("Destroy requires --yes");
});
it("runs destroy with backup mode and prints plan/result", async () => {
const destroyDeps = makeDestroyDeps({
exists: vi.fn(() => false),
mkdirp: vi.fn(),
});
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
destroyDeps,
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--state", "--backup", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
});
expect(exitCode).toBeUndefined();
const output = logs.join("\n");
expect(output).toContain("DESTROY PLAN");
expect(output).toContain("scope: state");
expect(output).toContain("DESTROY RESULT");
expect(output).toContain("fresh state root:");
});
it("falls back to compatibility defaults when config resolution is malformed", async () => {
const destroyDeps = makeDestroyDeps({
exists: vi.fn(() => false),
mkdirp: vi.fn(),
});
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => { throw new Error("bad config json"); } },
destroyDeps,
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--state", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("DESTROY PLAN");
expect(logs.join("\n")).toContain("bad config json");
});
});
+370 -3
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { Command } from "commander";
import { doctorCommand, runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
const defaultConfig = {
daemon: { port: 7433, host: "127.0.0.1" },
@@ -12,9 +15,16 @@ function makeDeps(overrides?: Partial<DoctorDeps>): DoctorDeps {
return {
exists: () => true,
baseDir: "/install/cli/dist",
exec: () => "tmux 3.4\n",
readFile: () => null,
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
checkPort: async () => true,
configStore: { resolve: () => defaultConfig },
platform: "darwin",
mkdirp: () => {},
checkWritable: () => {},
...overrides,
@@ -68,20 +78,309 @@ describe("runDoctorChecks", () => {
});
it("tmux available -> pass", () => {
const deps = makeDeps({ exec: () => "tmux 3.4" });
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") return "";
if (cmd === "tmux show-options -gqv mouse") return "on\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const tmuxCheck = checks.find((c) => c.name === "tmux");
expect(tmuxCheck?.status).toBe("pass");
});
it("tmux installed but default control socket unhealthy -> fail with attention guidance", () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") throw new Error("server exited unexpectedly");
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const tmuxCheck = checks.find((c) => c.name === "tmux");
expect(tmuxCheck?.status).toBe("fail");
expect(tmuxCheck?.message).toContain("control socket");
expect(tmuxCheck?.reason).toContain("server exited unexpectedly");
expect(tmuxCheck?.fix).toContain("restart the default tmux server");
});
it("tmux mouse enabled on macOS -> pass", () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux show-options -gqv mouse") return "on\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const mouseCheck = checks.find((c) => c.name === "tmux_mouse");
expect(mouseCheck?.status).toBe("pass");
expect(mouseCheck?.message).toContain("enabled");
});
it("tmux mouse disabled on macOS -> warn with exact fix", () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux show-options -gqv mouse") return "off\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const mouseCheck = checks.find((c) => c.name === "tmux_mouse");
expect(mouseCheck?.status).toBe("warn");
expect(mouseCheck?.message).toContain("disabled");
expect(mouseCheck?.fix).toContain("tmux set -g mouse on");
expect(mouseCheck?.fix).toContain("~/.tmux.conf");
expect(mouseCheck?.fix).toContain("tmux source-file ~/.tmux.conf");
});
it("tmux missing -> fail with guidance", () => {
const deps = makeDeps({ exec: () => { throw new Error("not found"); } });
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") throw new Error("not found");
return "";
},
});
const { checks } = runDoctorChecks(deps);
const tmuxCheck = checks.find((c) => c.name === "tmux");
expect(tmuxCheck?.status).toBe("fail");
expect(tmuxCheck?.fix).toContain("brew");
});
it("tmux mouse check is omitted on non-macOS hosts", () => {
const deps = makeDeps({
platform: "linux",
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
expect(checks.find((c) => c.name === "tmux_mouse")).toBeUndefined();
});
it("cmux_shell pass when shell capabilities work", () => {
const deps = makeDeps();
const { checks } = runDoctorChecks(deps);
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
expect(cmuxShell?.status).toBe("pass");
});
it("cmux_shell warn when shell cmux installed but control unavailable", () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") {
throw new Error("Failed to connect to socket at /tmp/cmux.sock");
}
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
expect(cmuxShell?.status).toBe("warn");
expect(cmuxShell?.message).toContain("control unavailable");
expect(cmuxShell?.fix).toContain("cmuxOnly");
expect(cmuxShell?.fix).toContain("socketControlMode");
});
it("cmux_shell warn when cmux missing", () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
throw new Error("command not found: cmux");
},
});
const { checks } = runDoctorChecks(deps);
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
expect(cmuxShell?.status).toBe("warn");
expect(cmuxShell?.message).toContain("not found");
});
it("shell cmux pass + daemon cmux unavailable -> cmux_daemon warn with mismatch guidance", async () => {
const deps = makeDeps({
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false, // port in use = daemon running
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxShell = allChecks.find((c) => c.name === "cmux_shell");
expect(cmuxShell?.status).toBe("pass");
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.message).toContain("daemon cannot control");
expect(cmuxDaemon?.fix).toContain("rig daemon start");
});
it("shell cmux pass + restrictive macOS socket control + daemon cmux unavailable -> cmux_daemon points at socket control mode", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false,
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.reason).toContain("socketControlMode");
expect(cmuxDaemon?.reason).toContain("cmuxOnly");
expect(cmuxDaemon?.fix).toContain("rig setup");
expect(cmuxDaemon?.fix).toContain("automation");
});
it("shell cmux pass + implicit default cmuxOnly + daemon cmux unavailable -> cmux_daemon points at effective default, not a fake file value", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? null : null,
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false,
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.reason).toContain("default");
expect(cmuxDaemon?.reason).toContain("cmuxOnly");
expect(cmuxDaemon?.reason).not.toContain("is 'cmuxOnly' in");
});
it("shell cmux pass + unreadable cmux settings -> cmux_daemon warns about parse failure", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n invalid\n" : null,
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false,
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.reason).toContain("unreadable");
expect(cmuxDaemon?.fix).toContain("~/.config/cmux/settings.json");
});
it("shell cmux pass + compatible password mode + daemon cmux unavailable -> falls back to generic daemon warning", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false,
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.reason).toContain("inherited a terminal/session environment");
expect(cmuxDaemon?.reason).not.toContain("socketControlMode");
});
it("daemon not running -> cmux_daemon skipped and does not make doctor unhealthy", async () => {
const deps = makeDeps({
checkPort: async () => true,
fetch: async () => { throw new Error("ECONNREFUSED"); },
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon?.status).toBe("skipped");
expect(cmuxDaemon?.message).toContain("not reachable");
const healthy = allChecks.every((c) => c.status !== "fail");
expect(healthy).toBe(true);
});
it("daemon cmux available -> cmux_daemon pass", async () => {
const deps = makeDeps({
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false, // port in use = daemon running
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon?.status).toBe("pass");
});
it("shell cmux missing -> no cmux_daemon check (no misleading signal)", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
throw new Error("command not found: cmux");
},
checkPort: async () => false, // daemon running
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeUndefined();
});
it("Node version check passes on current Node", () => {
const deps = makeDeps();
const { checks } = runDoctorChecks(deps);
@@ -174,6 +473,31 @@ describe("rig doctor", () => {
expect(parsed.checks.length).toBeGreaterThan(0);
});
it("--json stays healthy when cmux is only a warning", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") {
throw new Error("Failed to connect to socket at /tmp/cmux.sock");
}
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const program = new Command();
program.addCommand(doctorCommand(deps));
const { logs, exitCode } = await captureLogs(() =>
program.parseAsync(["node", "rig", "doctor", "--json"]),
);
const parsed = JSON.parse(logs.join("\n"));
const cmuxShellCheck = parsed.checks.find((check: { name: string }) => check.name === "cmux_shell");
expect(parsed.healthy).toBe(true);
expect(cmuxShellCheck.status).toBe("warn");
expect(exitCode).toBeUndefined();
});
it("--json exits non-zero when writable state paths fail", async () => {
const deps = makeDeps({
checkWritable: () => {
@@ -194,6 +518,49 @@ describe("rig doctor", () => {
expect(exitCode).toBe(1);
});
it("--json surfaces cmux_daemon pass when daemon cmux is available", async () => {
const deps = makeDeps({
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) } as Response;
return { ok: true } as Response;
},
});
const program = new Command();
program.addCommand(doctorCommand(deps));
const { logs } = await captureLogs(() =>
program.parseAsync(["node", "rig", "doctor", "--json"]),
);
const parsed = JSON.parse(logs.join("\n"));
const cmuxDaemon = parsed.checks.find((check: { name: string }) => check.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon.status).toBe("pass");
});
it("--json surfaces cmux_daemon warn when shell cmux works but daemon cannot control", async () => {
const deps = makeDeps({
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
});
const program = new Command();
program.addCommand(doctorCommand(deps));
const { logs } = await captureLogs(() =>
program.parseAsync(["node", "rig", "doctor", "--json"]),
);
const parsed = JSON.parse(logs.join("\n"));
const cmuxDaemon = parsed.checks.find((check: { name: string }) => check.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon.status).toBe("warn");
expect(parsed.healthy).toBe(true); // warn does not make doctor unhealthy
});
it("wired via createProgram", async () => {
const { createProgram } = await import("../src/index.js");
const program = createProgram();
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { envCommand } from "../src/commands/env.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
process.exitCode = undefined;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
console.error = (...args: unknown[]) => logs.push(args.join(" "));
try { await fn(); } finally {
console.log = origLog;
console.error = origErr;
}
const exitCode = process.exitCode;
process.exitCode = origExitCode;
resolve({ logs, exitCode });
});
}
describe("rig env", () => {
let server: http.Server;
let port: number;
const rigSummary = [{ id: "rig-1", name: "my-rig", nodeCount: 2 }];
beforeAll(async () => {
server = http.createServer((req, res) => {
const url = req.url ?? "";
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
if (url === "/api/rigs/summary") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(rigSummary));
return;
}
if (url.includes("/env/logs")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, output: "vault | 2026-04-09 12:00:00 Starting vault server..." }));
return;
}
if (url.includes("/env/down") && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (url.includes("/env")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
ok: true,
hasServices: true,
kind: "compose",
projectName: "my-rig",
receipt: {
kind: "compose",
services: [{ name: "vault", status: "running", health: "healthy" }],
waitFor: [],
capturedAt: "2026-04-09T00:00:00Z",
},
}));
return;
}
res.writeHead(404).end();
});
});
await new Promise<void>((resolve) => { server.listen(0, resolve); });
port = (server.address() as { port: number }).port;
});
afterAll(() => { server.close(); });
function runningDeps(): StatusDeps {
return {
lifecycleDeps: {
...mockLifecycleDeps(),
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-04-09T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
},
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(envCommand(runningDeps()));
return prog;
}
it("env help explains managed-app usage", () => {
const logs: string[] = [];
const cmd = makeCmd().commands.find((c) => c.name() === "env")!;
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
cmd.outputHelp();
const help = logs.join("");
expect(help).toContain("Inspect and control rig environment services");
expect(help).toContain("service-backed rigs and managed");
expect(help).toContain("rig env status secrets-manager");
expect(help).toContain("rig env logs secrets-manager vault");
});
it("env status prints service status", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig"]);
});
const output = logs.join("\n");
expect(output).toContain("vault");
expect(output).toContain("running");
});
it("env status --json returns machine-readable output", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.hasServices).toBe(true);
expect(parsed.receipt.services).toHaveLength(1);
});
it("env logs returns service logs", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "logs", "my-rig"]);
});
expect(logs.join("\n")).toContain("Starting vault server");
});
it("env down stops services", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "down", "my-rig"]);
});
expect(logs.join("\n")).toContain("Services stopped");
});
it("env is wired via createProgram", async () => {
const { createProgram } = await import("../src/index.js");
const program = createProgram();
const envCmd = program.commands.find((c) => c.name() === "env");
expect(envCmd).toBeDefined();
});
});
+40 -1
View File
@@ -53,7 +53,10 @@ function runningLifecycleDeps(port: number): LifecycleDeps {
}
function stoppedLifecycleDeps(): LifecycleDeps {
return mockLifecycleDeps({ exists: vi.fn(() => false) });
return mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
}
function unhealthyLifecycleDeps(): LifecycleDeps {
@@ -434,4 +437,40 @@ describe("rig export + import", () => {
expect(output).toContain("Attach:");
expect(output).toContain("tmux attach -t orch-lead@imported-rig");
});
it("import --instantiate uses a long-running daemon timeout budget", async () => {
let timeoutMs: number | undefined;
const postText = vi.fn(async (
_path: string,
_text: string,
_contentType?: string,
_extraHeaders?: Record<string, string>,
options?: { timeoutMs?: number },
) => {
timeoutMs = options?.timeoutMs;
return {
status: 201,
data: {
rigId: "rig-new",
specName: "imported-rig",
specVersion: "0.1.0",
nodes: [{ logicalId: "orchestrator", status: "launched" }],
attachCommand: "tmux attach -t orch-lead@imported-rig",
},
};
});
const deps: ImportDeps = {
lifecycleDeps: runningLifecycleDeps(port),
clientFactory: () => ({ postText } as unknown as DaemonClient),
readFile: vi.fn(() => 'version: "0.2"\nname: test\npods:\n - id: dev\n label: Dev\n members:\n - id: impl\n agent_ref: "local:agents/impl"\n profile: default\n runtime: claude-code\n cwd: .\n edges: []\nedges: []'),
};
const program = new Command();
program.addCommand(importCommand(deps));
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate"]));
expect(timeoutMs).toBe(120_000);
});
});
+24
View File
@@ -147,6 +147,16 @@ describe("Lifecycle CLI commands", () => {
return;
}
if (req.method === "POST" && req.url === "/api/rigs/rig-1/nodes/dev.pod/launch") {
res.writeHead(409, { "Content-Type": "application/json" });
res.end(JSON.stringify({
ok: false,
code: "pod_aware_launch_unsupported",
error: "Pod-aware node launch via this route bypasses startup orchestration. Use rig up, rig import --instantiate, or rig restore instead.",
}));
return;
}
if (req.method === "POST" && req.url === "/api/sessions/missing/unclaim") {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Claimed session 'missing' not found." }));
@@ -269,6 +279,20 @@ describe("Lifecycle CLI commands", () => {
expect(logs.join("\n")).toContain("dev-qa@test");
});
it("launch surfaces honest pod-aware rejection and exits non-zero", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(launchCommand(runningDeps()));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "launch", "rig-1", "dev.pod"]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("bypasses startup orchestration");
expect(logs.join("\n")).toContain("rig import --instantiate");
});
it("--json prints raw unclaim payload", async () => {
const program = new Command();
program.exitOverride();
+10 -6
View File
@@ -81,12 +81,16 @@ describe("MCP Server", () => {
arguments: { sourceRef: "/tmp/rig.yaml", plan: false, autoApprove: true },
});
expect(postFn).toHaveBeenCalledWith("/api/up", {
sourceRef: "/tmp/rig.yaml",
plan: false,
autoApprove: true,
targetRoot: undefined,
});
expect(postFn).toHaveBeenCalledWith(
"/api/up",
{
sourceRef: "/tmp/rig.yaml",
plan: false,
autoApprove: true,
targetRoot: undefined,
},
{ timeoutMs: 120_000 },
);
const content = result.content as Array<{ type: string; text: string }>;
const parsed = JSON.parse(content[0].text);
expect(parsed.status).toBe("completed");
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_OPENRIG_HOME = process.env.OPENRIG_HOME;
const ORIGINAL_RIGGED_HOME = process.env.RIGGED_HOME;
afterEach(() => {
if (ORIGINAL_OPENRIG_HOME === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = ORIGINAL_OPENRIG_HOME;
if (ORIGINAL_RIGGED_HOME === undefined) delete process.env.RIGGED_HOME;
else process.env.RIGGED_HOME = ORIGINAL_RIGGED_HOME;
vi.resetModules();
vi.restoreAllMocks();
});
describe("openrig-compat", () => {
it("getOpenRigHome prefers OPENRIG_HOME when set", async () => {
process.env.OPENRIG_HOME = "/tmp/custom-openrig-home";
delete process.env.RIGGED_HOME;
const mod = await import("../src/openrig-compat.js");
expect(mod.getOpenRigHome()).toBe("/tmp/custom-openrig-home");
expect(mod.getDefaultOpenRigPath("daemon.json")).toBe("/tmp/custom-openrig-home/daemon.json");
});
it("getOpenRigHome falls back to RIGGED_HOME with warning", async () => {
delete process.env.OPENRIG_HOME;
process.env.RIGGED_HOME = "/tmp/legacy-rigged-home";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await import("../src/openrig-compat.js");
expect(mod.getOpenRigHome()).toBe("/tmp/legacy-rigged-home");
expect(warnSpy).toHaveBeenCalledWith(
"Warning: RIGGED_HOME is deprecated; use OPENRIG_HOME instead.",
);
});
});
+4 -1
View File
@@ -61,7 +61,10 @@ function runningDeps(port: number): StatusDeps {
function stoppedDeps(): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
clientFactory: vi.fn() as unknown as StatusDeps["clientFactory"],
};
}
+26 -6
View File
@@ -9,9 +9,17 @@ import { ConfigStore } from "../src/config-store.js";
import { preflightCommand } from "../src/commands/preflight.js";
import type { DaemonStatus } from "../src/daemon-lifecycle.js";
function makeExec(tmuxOk = true): (cmd: string) => Promise<string> {
function makeExec(tmuxMode: "healthy" | "missing" | "unhealthy" = "healthy"): (cmd: string) => Promise<string> {
return async (cmd: string) => {
if (cmd.includes("tmux") && !tmuxOk) throw new Error("command not found: tmux");
if (cmd === "tmux -V") {
if (tmuxMode === "missing") throw new Error("command not found: tmux");
return "tmux 3.6a";
}
if (cmd === "tmux list-sessions") {
if (tmuxMode === "missing") throw new Error("command not found: tmux");
if (tmuxMode === "unhealthy") throw new Error("server exited unexpectedly");
return "";
}
if (cmd.includes("tmux")) return "tmux 3.6a";
return "";
};
@@ -36,7 +44,7 @@ describe("SystemPreflight", () => {
});
function createPreflight(opts?: {
tmuxOk?: boolean;
tmuxMode?: "healthy" | "missing" | "unhealthy";
configOverrides?: Record<string, unknown>;
daemonStatus?: Partial<DaemonStatus>;
}) {
@@ -47,7 +55,7 @@ describe("SystemPreflight", () => {
mkdirSync(homeDir, { recursive: true });
return new SystemPreflight({
exec: makeExec(opts?.tmuxOk ?? true),
exec: makeExec(opts?.tmuxMode ?? "healthy"),
configStore: config,
getDaemonStatus: makeStatus(opts?.daemonStatus),
riggedHome: homeDir,
@@ -77,7 +85,7 @@ describe("SystemPreflight", () => {
// Test 3
it("tmux missing → ready: false with guidance", async () => {
const pf = createPreflight({ tmuxOk: false });
const pf = createPreflight({ tmuxMode: "missing" });
const result = await pf.run();
expect(result.ready).toBe(false);
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
@@ -86,6 +94,18 @@ describe("SystemPreflight", () => {
expect(tmuxCheck!.fix).toContain("install tmux");
});
it("tmux installed but default control socket unhealthy → ready: false with guidance", async () => {
const pf = createPreflight({ tmuxMode: "unhealthy" });
const result = await pf.run();
expect(result.ready).toBe(false);
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
expect(tmuxCheck!.ok).toBe(false);
expect(tmuxCheck!.error).toContain("control socket");
expect(tmuxCheck!.reason).toContain("server exited unexpectedly");
expect(tmuxCheck!.fix).toContain("restart the default tmux server");
});
// Test 4
it("OpenRig home not writable → ready: false with guidance", async () => {
const readonlyDir = join(tmpDir, "readonly-home");
@@ -150,7 +170,7 @@ describe("SystemPreflight", () => {
// Test 8
it("multiple failures reported together (additive)", async () => {
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
const pf = createPreflight({ tmuxOk: false });
const pf = createPreflight({ tmuxMode: "missing" });
const result = await pf.run();
expect(result.ready).toBe(false);
const failedChecks = result.checks.filter((c) => !c.ok);
+40 -1
View File
@@ -135,6 +135,43 @@ describe("Ps CLI", () => {
expect(logs.some((l) => l.includes("No rigs"))).toBe(true);
});
it("ps recovers when daemon.json is missing but configured daemon is healthy", async () => {
const savedPort = process.env["OPENRIG_PORT"];
const savedHost = process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = String(port);
process.env["OPENRIG_HOST"] = "127.0.0.1";
try {
psData = [
{ rigId: "rig-1", name: "recovered-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "15s", latestSnapshot: null },
];
const prog = new Command();
prog.exitOverride();
prog.addCommand(psCommand({
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async (url: string) => {
expect(url).toBe(`http://127.0.0.1:${port}/healthz`);
return { ok: true };
}),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
}));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "ps"]);
});
const output = logs.join("\n");
expect(output).toContain("recovered-rig");
expect(output).not.toMatch(/Daemon not running/i);
expect(exitCode).toBeUndefined();
} finally {
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
// NS-T08: ps --nodes tests
it("ps --nodes formats table with rig context and restore columns", async () => {
@@ -317,7 +354,9 @@ describe("Ps CLI", () => {
it("daemon not running error includes guidance", async () => {
const stoppedDeps: StatusDeps = {
lifecycleDeps: mockLifecycleDeps(),
lifecycleDeps: mockLifecycleDeps({
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
const prog = new Command();
+724
View File
@@ -0,0 +1,724 @@
import { describe, it, expect, vi } from "vitest";
import { Command } from "commander";
import { setupCommand, runSetup, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
import type { DoctorDeps } from "../src/commands/doctor.js";
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
function makeDeps(overrides?: Partial<SetupDeps>): SetupDeps {
return {
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
if (cmd === "jq --version") return "jq-1.7\n";
if (cmd === "gh --version") return "gh 2.0\n";
return "";
},
readFile: () => null,
writeFile: vi.fn(),
exists: () => false,
platform: "darwin",
mkdirp: vi.fn(),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
const logs: string[] = [];
const orig = console.log;
let exitCode: number | undefined;
console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
const origExitCode = process.exitCode;
return fn()
.then(() => {
exitCode = process.exitCode;
process.exitCode = origExitCode;
return { logs, exitCode };
})
.finally(() => { console.log = orig; });
}
function expectRuntimeConfigDisclosure(result: SetupResult): void {
expect(result.runtimeConfig).toHaveLength(6);
expect(result.runtimeConfig).toEqual(expect.arrayContaining([
{
scope: "global",
runtime: "claude-code",
path: "~/.claude/settings.json",
purpose: "Allow OpenRig commands without Claude permission prompts.",
},
{
scope: "global",
runtime: "claude-code",
path: "~/.claude.json",
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
},
{
scope: "project",
runtime: "claude-code",
path: ".claude/settings.local.json",
purpose: expect.stringContaining("statusLine"),
},
{
scope: "project",
runtime: "claude-code",
path: ".mcp.json",
purpose: "Configure project-local MCP servers for Claude-managed workspaces.",
},
{
scope: "global",
runtime: "codex",
path: "~/.codex/config.toml",
purpose: "Pre-trust managed workspaces and configure Codex MCP servers.",
},
{
scope: "global",
runtime: "cmux",
path: "~/.config/cmux/settings.json",
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
},
]));
}
describe("rig setup", () => {
it("wired via createProgram", async () => {
const { createProgram } = await import("../src/index.js");
const program = createProgram();
const setupCmd = program.commands.find((c) => c.name() === "setup");
expect(setupCmd).toBeDefined();
});
it("--dry-run --json returns structured plan with core profile and expected step ids", async () => {
const deps = makeDeps();
const program = new Command();
program.addCommand(setupCommand(deps));
const { logs } = await captureLogs(() =>
program.parseAsync(["node", "rig", "setup", "--dry-run", "--json"]),
);
const result = JSON.parse(logs.join("\n")) as SetupResult;
expect(result.profile).toBe("core");
expect(result.platform).toBe("darwin");
const stepIds = result.steps.map((s) => s.id);
expect(stepIds).toContain("brew");
expect(stepIds).toContain("tmux_install");
expect(stepIds).toContain("cmux_install");
expect(stepIds).toContain("claude_install");
expect(stepIds).toContain("claude_auth");
expect(stepIds).toContain("codex_install");
expect(stepIds).toContain("codex_auth");
expect(stepIds).toContain("tmux_config");
expect(stepIds).toContain("verify");
// No full-profile extras
expect(stepIds).not.toContain("jq_install");
expect(stepIds).not.toContain("gh_install");
// All steps should be skipped in dry run
expect(result.steps.every((s) => s.status === "skipped")).toBe(true);
expectRuntimeConfigDisclosure(result);
});
it("--dry-run --full --json includes full profile with core + extra step ids", async () => {
const deps = makeDeps();
const program = new Command();
program.addCommand(setupCommand(deps));
const { logs } = await captureLogs(() =>
program.parseAsync(["node", "rig", "setup", "--dry-run", "--full", "--json"]),
);
const result = JSON.parse(logs.join("\n")) as SetupResult;
expect(result.profile).toBe("full");
const stepIds = result.steps.map((s) => s.id);
// Core steps still present
expect(stepIds).toContain("brew");
expect(stepIds).toContain("tmux_install");
expect(stepIds).toContain("cmux_install");
expect(stepIds).toContain("claude_install");
expect(stepIds).toContain("claude_auth");
expect(stepIds).toContain("codex_install");
expect(stepIds).toContain("codex_auth");
expect(stepIds).toContain("tmux_config");
expect(stepIds).toContain("verify");
// Full extras added
expect(stepIds).toContain("jq_install");
expect(stepIds).toContain("gh_install");
});
it("--dry-run --json exits 0 (plan-only, not a failure)", async () => {
const deps = makeDeps();
const program = new Command();
program.addCommand(setupCommand(deps));
const { exitCode } = await captureLogs(() =>
program.parseAsync(["node", "rig", "setup", "--dry-run", "--json"]),
);
expect(exitCode).toBeUndefined(); // undefined means 0
});
it("--dry-run does not make mutating exec calls", async () => {
const execSpy = vi.fn(() => "");
const deps = makeDeps({ exec: execSpy });
await runSetup(deps, { dryRun: true });
expect(execSpy).not.toHaveBeenCalled();
});
it("core profile execution with all tools present returns pass/applied steps and ready=true", async () => {
const writeSpy = vi.fn();
const deps = makeDeps({ writeFile: writeSpy });
const result = await runSetup(deps, {});
expect(result.profile).toBe("core");
expect(result.ready).toBe(true);
const brew = result.steps.find((s) => s.id === "brew");
expect(brew?.status).toBe("pass");
const tmux = result.steps.find((s) => s.id === "tmux_install");
expect(tmux?.status).toBe("pass");
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("applied");
const claudeInstall = result.steps.find((s) => s.id === "claude_install");
expect(claudeInstall?.status).toBe("pass");
const claudeAuth = result.steps.find((s) => s.id === "claude_auth");
expect(claudeAuth?.status).toBe("pass");
const codexInstall = result.steps.find((s) => s.id === "codex_install");
expect(codexInstall?.status).toBe("pass");
const codexAuth = result.steps.find((s) => s.id === "codex_auth");
expect(codexAuth?.status).toBe("pass");
const tmuxConfig = result.steps.find((s) => s.id === "tmux_config");
expect(tmuxConfig?.status).toBe("applied");
const verify = result.steps.find((s) => s.id === "verify");
expect(verify?.status).toBe("pass");
});
it("fails setup honestly when tmux is installed but the default control socket is unhealthy", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") throw new Error("server exited unexpectedly");
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
return "";
},
});
const result = await runSetup(deps, {});
const tmux = result.steps.find((s) => s.id === "tmux_install");
expect(tmux?.status).toBe("fail");
expect(tmux?.message).toContain("control socket");
expect(tmux?.reason).toContain("server exited unexpectedly");
expect(tmux?.fixHint).toContain("restart the default tmux server");
expect(result.ready).toBe(false);
});
it("full profile extends core with jq_install and gh_install", async () => {
const deps = makeDeps();
const result = await runSetup(deps, { full: true });
expect(result.profile).toBe("full");
const stepIds = result.steps.map((s) => s.id);
// Core steps present
expect(stepIds).toContain("brew");
expect(stepIds).toContain("tmux_install");
// Full extras present
expect(stepIds).toContain("jq_install");
expect(stepIds).toContain("gh_install");
const jq = result.steps.find((s) => s.id === "jq_install");
expect(jq?.status).toBe("pass");
const gh = result.steps.find((s) => s.id === "gh_install");
expect(gh?.status).toBe("pass");
});
it("installs missing Claude Code with npm and verifies auth", async () => {
let claudeInstalled = false;
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
if (cmd === "claude --version") {
if (claudeInstalled) return "2.1.101 (Claude Code)\n";
throw new Error("command not found: claude");
}
if (cmd === "npm install -g @anthropic-ai/claude-code") {
claudeInstalled = true;
return "installed claude\n";
}
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
return "";
},
});
const result = await runSetup(deps, {});
const claudeInstall = result.steps.find((s) => s.id === "claude_install");
expect(claudeInstall?.status).toBe("applied");
const claudeAuth = result.steps.find((s) => s.id === "claude_auth");
expect(claudeAuth?.status).toBe("pass");
});
it("fails setup honestly when Codex is installed but not logged in", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") throw new Error("not logged in");
return "";
},
});
const result = await runSetup(deps, {});
const codexAuth = result.steps.find((s) => s.id === "codex_auth");
expect(codexAuth?.status).toBe("fail");
expect(result.ready).toBe(false);
});
it("does not fail Linux setup just because Homebrew is unavailable", async () => {
const deps = makeDeps({
platform: "linux",
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") return "";
if (cmd === "cmux capabilities --json") throw new Error("not found");
if (cmd === "cmux --help") throw new Error("not found");
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
throw new Error(`unexpected: ${cmd}`);
},
});
const result = await runSetup(deps, {});
const brew = result.steps.find((s) => s.id === "brew");
expect(brew?.status).toBe("skipped");
expect(result.ready).toBe(true);
});
it("brew failure does not crash — later brew-dependent steps are skipped honestly", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "brew --version") throw new Error("command not found: brew");
if (cmd === "tmux -V") throw new Error("command not found: tmux");
if (cmd === "tmux list-sessions") throw new Error("command not found: tmux");
if (cmd === "cmux capabilities --json") throw new Error("not found");
if (cmd === "cmux --help") throw new Error("not found");
throw new Error(`unexpected: ${cmd}`);
},
});
const result = await runSetup(deps, {});
expect(result.ready).toBe(false);
const brew = result.steps.find((s) => s.id === "brew");
expect(brew?.status).toBe("fail");
const tmux = result.steps.find((s) => s.id === "tmux_install");
expect(tmux?.status).toBe("skipped");
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("skipped");
// verify step should reflect the failures
const verify = result.steps.find((s) => s.id === "verify");
expect(verify?.status).toBe("warn");
});
it("result includes verification section with real doctor check names including async cmux_daemon", async () => {
const deps = makeDeps();
const doctorDeps: DoctorDeps = {
exists: () => true,
baseDir: "/install/cli/dist",
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
checkPort: async () => true,
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
platform: "darwin",
readFile: () => null,
mkdirp: () => {},
checkWritable: () => {},
fetch: async () => { throw new Error("ECONNREFUSED"); },
};
const result = await runSetup(deps, { doctorDeps });
// verification section must exist
expect(result.verification).toBeDefined();
expect(Array.isArray(result.verification!.checks)).toBe(true);
const checkNames = result.verification!.checks.map((c) => c.name);
// Must include real doctor check names
expect(checkNames).toContain("node_version");
expect(checkNames).toContain("tmux");
expect(checkNames).toContain("cmux_shell");
// Must include async doctor check (cmux_daemon resolved)
expect(checkNames).toContain("cmux_daemon");
// cmux_daemon should be skipped (daemon not reachable)
const cmuxDaemon = result.verification!.checks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon?.status).toBe("skipped");
// Doctor statuses used as-is, not renamed
const nodeCheck = result.verification!.checks.find((c) => c.name === "node_version");
expect(["pass", "warn", "fail", "skipped"]).toContain(nodeCheck?.status);
});
it("non-dry-run results include the same structured runtime config disclosure", async () => {
const deps = makeDeps();
const result = await runSetup(deps, {});
expectRuntimeConfigDisclosure(result);
});
it("ready is false only when setup steps or verification checks have fail status", async () => {
// All tools present, all doctor checks pass
const deps = makeDeps();
const doctorDeps: DoctorDeps = {
exists: () => true,
baseDir: "/install/cli/dist",
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
checkPort: async () => true,
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
platform: "darwin",
readFile: () => null,
mkdirp: () => {},
checkWritable: () => {},
fetch: async () => { throw new Error("ECONNREFUSED"); },
};
const result = await runSetup(deps, { doctorDeps });
// No fail statuses in steps or verification -> ready=true
expect(result.ready).toBe(true);
// warn/skipped alone do not flip ready to false
const hasWarnOrSkipped = [
...result.steps,
...(result.verification?.checks ?? []),
].some((c) => c.status === "warn" || c.status === "skipped");
// cmux_daemon is skipped, so this should be true
expect(hasWarnOrSkipped).toBe(true);
// But ready is still true
expect(result.ready).toBe(true);
});
it("tmux install failure returns structured fail, does not crash setup", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") throw new Error("not found");
if (cmd === "brew install tmux") throw new Error("brew install failed");
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
return "";
},
});
const result = await runSetup(deps, {});
const tmux = result.steps.find((s) => s.id === "tmux_install");
expect(tmux?.status).toBe("fail");
expect(result.ready).toBe(false);
// Other steps still attempted
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux).toBeDefined();
expect(cmux?.status).toBe("applied");
});
it("enables cmux automation mode on macOS when cmux is installed but not yet controllable", async () => {
let cmuxReady = false;
const seen: string[] = [];
const writeSpy = vi.fn();
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? null : null,
writeFile: writeSpy,
exec: (cmd: string) => {
seen.push(cmd);
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") {
if (cmuxReady) return '{"methods":["surface.focus"]}\n';
throw new Error("socket not ready");
}
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "open -a /Applications/cmux.app") {
cmuxReady = true;
return "";
}
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
return "";
},
});
const result = await runSetup(deps, {});
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("applied");
expect(cmux?.message).toContain("Enabled cmux socket control");
expect(seen).toContain("open -a /Applications/cmux.app");
expect(writeSpy).toHaveBeenCalledWith(
CMUX_SETTINGS_PATH,
expect.stringContaining("\"socketControlMode\": \"automation\""),
);
expect(result.ready).toBe(true);
});
it("normalizes restrictive cmux socket control on macOS even when shell cmux already works", async () => {
const seen: string[] = [];
const writeSpy = vi.fn();
const doctorDeps: DoctorDeps = {
exists: () => true,
baseDir: "/install/cli/dist",
readFile: () => null,
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
checkPort: async () => false,
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
platform: "darwin",
mkdirp: () => {},
checkWritable: () => {},
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true };
if (url.includes("/api/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) };
throw new Error(`unexpected fetch ${url}`);
},
};
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
writeFile: writeSpy,
exec: (cmd: string) => {
seen.push(cmd);
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "cmux reload-config") return "";
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
return "";
},
});
const result = await runSetup(deps, { doctorDeps });
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("applied");
expect(cmux?.message).toContain("automation");
expect(seen).toContain("cmux reload-config");
expect(writeSpy).toHaveBeenCalledWith(
CMUX_SETTINGS_PATH,
expect.stringContaining("\"socketControlMode\": \"automation\""),
);
expect(result.ready).toBe(true);
});
it("fails honestly when cmux stays uncontrollable after automatic macOS setup", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") throw new Error("broken pipe");
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "open -a /Applications/cmux.app") return "";
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
return "";
},
});
const result = await runSetup(deps, {});
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("fail");
expect(cmux?.message).toContain("cmux installed but control unavailable");
expect(result.ready).toBe(false);
});
it("does not rewrite compatible password mode when shell cmux already works", async () => {
const writeSpy = vi.fn();
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
writeFile: writeSpy,
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") return "";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
throw new Error(`unexpected: ${cmd}`);
},
});
const result = await runSetup(deps, {});
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("pass");
expect(writeSpy).not.toHaveBeenCalledWith(
CMUX_SETTINGS_PATH,
expect.any(String),
);
expect(result.ready).toBe(true);
});
it("fails setup when shell cmux works but the running daemon still cannot control cmux", async () => {
const doctorDeps: DoctorDeps = {
exists: () => true,
baseDir: "/install/cli/dist",
readFile: () => null,
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
checkPort: async () => false,
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
platform: "darwin",
mkdirp: () => {},
checkWritable: () => {},
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true };
if (url.includes("/api/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) };
throw new Error(`unexpected fetch ${url}`);
},
};
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
});
const result = await runSetup(deps, { doctorDeps });
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("fail");
expect(cmux?.message).toContain("running daemon still cannot control cmux");
expect(result.ready).toBe(false);
});
it("gives fresh install commands a longer timeout budget and fails cmux honestly when install still errors", async () => {
const seenTimeouts = new Map<string, number | undefined>();
const deps = makeDeps({
exec: (cmd: string, opts?: { timeoutMs?: number }) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") throw new Error("error connecting to /tmp/tmux (No such file or directory)");
if (cmd === "cmux capabilities --json") throw new Error("command not found: cmux");
if (cmd === "cmux --help") throw new Error("command not found: cmux");
if (cmd === "brew install --cask cmux") {
seenTimeouts.set(cmd, opts?.timeoutMs);
throw new Error("spawnSync /bin/sh ETIMEDOUT");
}
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
throw new Error(`unexpected: ${cmd}`);
},
});
const result = await runSetup(deps, {});
expect(seenTimeouts.get("brew install --cask cmux")).toBe(300000);
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("fail");
expect(cmux?.message).toContain("ETIMEDOUT");
expect(result.ready).toBe(false);
});
it("uses the extended timeout budget for npm runtime installs", async () => {
const seenTimeouts = new Map<string, number | undefined>();
let claudeInstalled = false;
let codexInstalled = false;
const deps = makeDeps({
exec: (cmd: string, opts?: { timeoutMs?: number }) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") return "";
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
if (cmd === "claude --version") {
if (claudeInstalled) return "2.1.101 (Claude Code)\n";
throw new Error("command not found: claude");
}
if (cmd === "npm install -g @anthropic-ai/claude-code") {
seenTimeouts.set(cmd, opts?.timeoutMs);
claudeInstalled = true;
return "installed claude\n";
}
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") {
if (codexInstalled) return "codex-cli 0.118.0\n";
throw new Error("command not found: codex");
}
if (cmd === "npm install -g @openai/codex") {
seenTimeouts.set(cmd, opts?.timeoutMs);
codexInstalled = true;
return "installed codex\n";
}
if (cmd === "codex login status") return "Logged in\n";
throw new Error(`unexpected: ${cmd}`);
},
});
const result = await runSetup(deps, {});
expect(result.ready).toBe(true);
expect(seenTimeouts.get("npm install -g @anthropic-ai/claude-code")).toBe(300000);
expect(seenTimeouts.get("npm install -g @openai/codex")).toBe(300000);
});
});
+21 -2
View File
@@ -58,7 +58,10 @@ function runningDeps(port: number): StatusDeps {
function stoppedDeps(): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
clientFactory: vi.fn() as unknown as StatusDeps["clientFactory"],
};
}
@@ -135,7 +138,19 @@ function createMockDaemon() {
res.end(JSON.stringify({
nodes: [
{ nodeId: "n1", logicalId: "orchestrator", status: "resumed" },
{ nodeId: "n2", logicalId: "worker", status: "failed" },
{
nodeId: "n2",
logicalId: "worker",
status: "failed",
canonicalSessionName: "worker@test-rig",
tmuxAttachCommand: "tmux attach -t worker@test-rig",
cwd: "/workspace/app",
recoveryGuidance: {
summary: "Try native Claude resume first, then fall back to the workspace-local picker if needed.",
commands: ["claude --resume abc-123", "cd /workspace/app", "claude --resume"],
notes: ["Look for session name: worker@test-rig", "Choose the full conversation option, not summary."],
},
},
],
}));
return;
@@ -242,6 +257,10 @@ describe("rig snapshot + restore", () => {
expect(logs.join("\n")).toContain("worker");
expect(logs.join("\n")).toContain("failed");
expect(logs.join("\n")).toContain("Recovery guidance:");
expect(logs.join("\n")).toContain("tmux attach -t worker@test-rig");
expect(logs.join("\n")).toContain("$ claude --resume abc-123");
expect(logs.join("\n")).toContain("Choose the full conversation option, not summary.");
expect(process.exitCode).toBe(1);
process.exitCode = savedExitCode;
+20
View File
@@ -82,6 +82,17 @@ describe("Specs CLI", () => {
return prog;
}
it("specs help calls out managed apps in the library", () => {
const logs: string[] = [];
const cmd = makeCmd().commands.find((c) => c.name() === "specs")!;
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
cmd.outputHelp();
const help = logs.join("");
expect(help).toContain("Browse, preview, and manage the spec library, including managed apps");
expect(help).toContain("rig specs ls");
expect(help).toContain("rig specs preview secrets-manager");
});
it("specs ls prints library entries", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls"]);
@@ -134,6 +145,15 @@ describe("Specs CLI", () => {
expect(exitCode).toBe(1);
});
it("specs show not-found error points people back to managed apps in the library", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "show", "missing-app"]);
});
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, and managed apps.");
expect(exitCode).toBe(1);
});
it("specs preview --json returns structured review", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--json"]);
+5 -2
View File
@@ -164,7 +164,7 @@ describe("rig status", () => {
// Test 3: Daemon stopped (no daemon.json) -> 'Daemon not running', no HTTP
it("stopped daemon -> 'Daemon not running', no HTTP calls", async () => {
const fetchFn = vi.fn(async () => ({ ok: true }));
const fetchFn = vi.fn(async () => { throw new Error("refused"); });
const clientFactory = vi.fn();
const deps: StatusDeps = {
lifecycleDeps: mockLifecycleDeps({
@@ -306,7 +306,10 @@ describe("rig status", () => {
const { createProgram } = await import("../src/index.js");
const statusDeps: StatusDeps = {
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
clientFactory: vi.fn() as unknown as StatusDeps["clientFactory"],
};
+40 -2
View File
@@ -69,7 +69,10 @@ describe("rig ui open", () => {
it("daemon down -> error, no exec", async () => {
const execFn = vi.fn(async () => {});
const deps: UiDeps = {
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
exec: execFn,
};
const program = new Command();
@@ -80,6 +83,38 @@ describe("rig ui open", () => {
expect(execFn).not.toHaveBeenCalled();
});
it("no daemon.json but configured daemon is healthy -> opens recovered daemon URL", async () => {
const savedPort = process.env["OPENRIG_PORT"];
const savedHost = process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = "7555";
process.env["OPENRIG_HOST"] = "127.0.0.1";
try {
const execFn = vi.fn(async () => {});
const deps: UiDeps = {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async (url: string) => {
expect(url).toBe("http://127.0.0.1:7555/healthz");
return { ok: true };
}),
}),
exec: execFn,
};
const program = new Command();
program.addCommand(uiCommand(deps));
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "ui", "open"]));
expect(execFn).toHaveBeenCalledWith("open", ["http://127.0.0.1:7555"]);
expect(logs.join("\n")).toContain("http://127.0.0.1:7555");
expect(logs.join("\n")).not.toMatch(/not running/i);
} finally {
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
// Test 3: UI URL derives from daemon port (daemon serves the UI)
it("UI URL derives from daemon port", async () => {
const execFn = vi.fn(async () => {});
@@ -120,7 +155,10 @@ describe("rig ui open", () => {
const { createProgram } = await import("../src/index.js");
const execFn = vi.fn(async () => {});
const deps: UiDeps = {
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
exec: execFn,
};
const program = createProgram({ uiDeps: deps });
+103 -7
View File
@@ -52,6 +52,12 @@ function runningDeps(port: number): StatusDeps {
};
}
function healthyPreflightExec(cmd: string): Promise<string> {
if (cmd === "tmux -V") return Promise.resolve("tmux 3.6a");
if (cmd === "tmux list-sessions") return Promise.resolve("");
return Promise.resolve("");
}
describe("Up CLI", () => {
let server: http.Server;
let port: number;
@@ -88,11 +94,17 @@ describe("Up CLI", () => {
return prog;
}
it("help makes --target scope explicit", () => {
const help = makeCmd().commands.find((c) => c.name() === "up")!.helpInformation();
it("help positions managed apps as first-class launch targets", () => {
const logs: string[] = [];
const cmd = makeCmd().commands.find((c) => c.name() === "up")!;
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
cmd.outputHelp();
const help = logs.join("");
expect(help).toContain("Launch a rig or managed app from a spec, library entry, or bundle");
expect(help).toContain("Target root directory for package installation");
expect(help).toContain("does not change agent cwd");
expect(help).toContain("--cwd <path>");
expect(help).toContain("rig up secrets-manager");
});
// T7: up from .yaml -> stages + rig ID
@@ -272,6 +284,61 @@ describe("Up CLI", () => {
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from a library name defaults cwdOverride to the caller working directory", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if ((req.url ?? "").startsWith("/api/specs/library") && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([
{
id: "builtin:rig:demo",
kind: "rig",
name: "demo",
version: "0.0.0",
sourceType: "builtin",
sourcePath: "/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml",
relativePath: "rigs/launch/demo/rig.yaml",
updatedAt: "2026-04-10T00:00:00Z",
},
]));
return;
}
if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
return;
}
if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
try {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "demo"]);
});
expect(lastBody.sourceRef).toBe("/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml");
expect(lastBody.cwdOverride).toBe(process.cwd());
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
}
});
it("up --cwd sends absolute cwdOverride", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
@@ -297,6 +364,32 @@ describe("Up CLI", () => {
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up apply uses a long-running daemon timeout budget", async () => {
let timeoutMs: number | undefined;
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
timeoutMs = options?.timeoutMs;
return {
status: 201,
data: { status: "completed", runId: "r", rigId: "g", stages: [], errors: [], warnings: [] },
};
});
const deps: StatusDeps = {
lifecycleDeps: runningDeps(port).lifecycleDeps,
clientFactory: () => ({ post } as unknown as DaemonClient),
};
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
});
expect(timeoutMs).toBe(120_000);
});
// NS-T14: fresh boot handoff includes dashboard URL + attach command
it("fresh boot success shows dashboard URL and attach command", async () => {
const { logs } = await captureLogs(async () => {
@@ -386,6 +479,7 @@ describe("Up CLI", () => {
process.env["OPENRIG_PORT"] = "7461";
let daemonState: DaemonState | null = null;
let daemonStarted = false;
let spawnedPort: string | undefined;
let clientBaseUrl: string | undefined;
@@ -400,12 +494,14 @@ describe("Up CLI", () => {
openForAppend: vi.fn(() => 3),
mkdirp: vi.fn(),
spawn: vi.fn((cmd, args, opts) => {
daemonStarted = true;
spawnedPort = opts.env["OPENRIG_PORT"];
return { pid: 321, unref: vi.fn() } as never;
}),
fetch: vi.fn(async (url: string) => ({
ok: url === "http://127.0.0.1:7461/healthz" || url === "http://127.0.0.1:7433/healthz",
})),
fetch: vi.fn(async (url: string) => {
if (!daemonStarted) throw new Error(`refused:${url}`);
return { ok: url === "http://127.0.0.1:7461/healthz" };
}),
},
clientFactory: (baseUrl) => {
clientBaseUrl = baseUrl;
@@ -420,7 +516,7 @@ describe("Up CLI", () => {
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand(deps));
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
const { exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
@@ -460,7 +556,7 @@ describe("Up CLI", () => {
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand(deps));
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
+5 -1
View File
@@ -24,7 +24,11 @@ function runningDeps(port: number): StatusDeps {
function stoppedDeps(): StatusDeps {
return {
lifecycleDeps: { ...mockLifecycleDeps(), exists: vi.fn(() => false) },
lifecycleDeps: {
...mockLifecycleDeps(),
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
},
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
@@ -14,6 +14,9 @@ If it does not auto-load skills reliably, read the packaged copy directly from y
That skill covers the broader surface, including chatroom, discovery, adopt/bind/attach, lifecycle, specs, bundles, and richer operator workflows.
Your per-session startup guidance may also name additional packaged skills for your role and pod.
Load those too. This shared overlay is only the common bootstrap floor, not the full operating manual for your seat.
## Identity
Run this first after launch or compaction to recover your identity:
+2 -1
View File
@@ -1,7 +1,8 @@
{
"name": "@openrig/daemon",
"version": "0.1.3",
"version": "0.1.10",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "tsc",
@@ -0,0 +1,36 @@
name: vault-specialist
version: "1.0"
description: Vault specialist agent — manages HashiCorp Vault for this managed app
defaults:
runtime: claude-code
imports:
- ref: local:../../shared
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, mental-model-ha, systematic-debugging, verification-before-completion, vault-user]
guidance: []
subagents: []
hooks: []
runtime_resources: []
resources:
guidance:
- id: role
path: guidance/role.md
skills:
- id: vault-user
path: skills/vault-user
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
- path: startup/context.md
delivery_hint: send_text
required: true
actions: []
@@ -0,0 +1,34 @@
# Role: Vault Specialist
You are the Vault specialist agent for this managed app. You are the domain expert for HashiCorp Vault in this topology.
## Responsibilities
- Manage the Vault instance that runs as part of this rig's environment
- Perform secret CRUD operations: read, write, list, and delete secrets
- Check Vault health and status on demand
- Explain the current secret structure to other agents or humans
- Respond to delegation requests from other agents for Vault-domain work
- Use the Vault HTTP API and CLI to perform all operations
- Report Vault state honestly — if Vault is unhealthy or unreachable, say so
## Principles
- You are the preferred delegate target for all Vault-domain work in this rig
- Other agents should ask you instead of trying to operate Vault directly
- Always verify Vault is healthy before performing operations
- Use the `rig env status` surface to check environment health before manual probing
- Use the configured dev token and local address — do not guess or invent credentials
- Be specific about paths, keys, and values when reporting secret state
- If you cannot reach Vault, report the failure honestly with the access path you tried
## Skills
You have the following skills loaded:
- `using-superpowers` — general agent capabilities
- `openrig-user` — OpenRig CLI and topology operations
- `mental-model-ha` — compaction and restore discipline
- `systematic-debugging` — structured debugging approach
- `verification-before-completion` — evidence before claims
- `vault-user` — Vault-specific operations and domain knowledge
@@ -0,0 +1,64 @@
# Vault User
You have access to a HashiCorp Vault instance managed by this rig's environment.
## Connection
- **Address:** `http://127.0.0.1:8200`
- **Token:** `openrig-dev-token`
- **Auth header:** `X-Vault-Token: openrig-dev-token`
Always set the token before making API calls. For curl, use `-H "X-Vault-Token: openrig-dev-token"`.
## Health Check
```bash
curl -s http://127.0.0.1:8200/v1/sys/health | jq .
```
A healthy response has `"initialized": true` and `"sealed": false`.
## Secret Operations
### Write a secret
```bash
curl -s -X POST http://127.0.0.1:8200/v1/secret/data/<path> \
-H "X-Vault-Token: openrig-dev-token" \
-d '{"data": {"key": "value"}}' | jq .
```
### Read a secret
```bash
curl -s http://127.0.0.1:8200/v1/secret/data/<path> \
-H "X-Vault-Token: openrig-dev-token" | jq .
```
The secret value is in `.data.data`.
### List secrets
```bash
curl -s -X LIST http://127.0.0.1:8200/v1/secret/metadata/ \
-H "X-Vault-Token: openrig-dev-token" | jq .
```
List a subdirectory by appending the path: `.../secret/metadata/<prefix>/`.
### Delete a secret
```bash
curl -s -X DELETE http://127.0.0.1:8200/v1/secret/data/<path> \
-H "X-Vault-Token: openrig-dev-token"
```
## Explaining Secrets
When asked to explain the current secret structure, list all paths and summarize what each contains. Use the list endpoint recursively if needed.
## Important Notes
- This is Vault dev mode — all data is in-memory and lost on restart
- The KV secrets engine is mounted at `secret/` by default in dev mode
- Use the `rig env status` command to verify Vault health through OpenRig before direct probing
@@ -0,0 +1,46 @@
# Startup Context: Vault Managed App
## Environment
This rig runs HashiCorp Vault in dev mode as a managed environment service. Vault boots before you launch — by the time you receive this context, Vault should already be healthy.
## Access
- **Address:** `http://127.0.0.1:8200`
- **Dev root token:** `openrig-dev-token`
- **UI:** `http://127.0.0.1:8200/ui`
- **API:** `http://127.0.0.1:8200/v1`
## Ground Yourself First
Before making topology or registration claims, ground yourself with OpenRig identity:
```bash
rig whoami --json
```
Trust the startup identity context OpenRig gives you. Do not claim that the rig is unlaunched, unattached, or unregistered unless `rig whoami --json` or another direct OpenRig command proves it.
## Checking Status
Use OpenRig env surfaces first:
```bash
rig env status <rig-name>
```
For direct Vault health checks:
```bash
curl -s http://127.0.0.1:8200/v1/sys/health | jq .
```
Or via the Vault CLI if available:
```bash
vault status -address=http://127.0.0.1:8200
```
## Your Role
You are the Vault specialist. Other agents in larger topologies may delegate Vault-domain work to you. When they do, use your domain knowledge to perform the requested operations and report results clearly.
@@ -1,4 +1,4 @@
name: design
name: product-designer
version: "1.0"
description: Product designer — shapes UX, interaction flows, and design-system coherence
@@ -6,12 +6,12 @@ defaults:
runtime: claude-code
imports:
- ref: local:../shared
- ref: local:../../shared
profiles:
default:
uses:
skills: [openrig-user, development-team]
skills: [using-superpowers, openrig-user, development-team, frontend-design, verification-before-completion, writing-plans, brainstorming]
guidance: []
subagents: []
hooks: []
@@ -2,6 +2,19 @@
You are the design side of the product team. Your job is to turn rough product intent into clear user flows, interface structure, and design-system decisions that implementation and QA can execute without guesswork.
## Startup checklist
Load these packaged skills now before giving product direction:
- `using-superpowers`
- `openrig-user`
- `development-team`
- `frontend-design`
- `brainstorming`
- `writing-plans`
- `verification-before-completion`
Then run `rig whoami --json` and look for the first ambiguity the development pod should not have to guess through.
## Responsibilities
- Translate ambiguous product goals into concrete UX flows and screen structure
@@ -1,4 +1,4 @@
name: impl
name: implementer
version: "1.0"
description: Implementation agent — writes code following TDD discipline
@@ -6,12 +6,12 @@ defaults:
runtime: claude-code
imports:
- ref: local:../shared
- ref: local:../../shared
profiles:
default:
uses:
skills: [openrig-user, development-team]
skills: [using-superpowers, openrig-user, development-team, test-driven-development, mental-model-ha, systematic-debugging, verification-before-completion, writing-plans, executing-plans]
guidance: []
subagents: []
hooks: []
@@ -0,0 +1,48 @@
# Role: Implementer
You are the implementation side of a dev pair. Your job is to write code, not to free-run.
## Startup checklist
Load these packaged skills now before editing:
- `using-superpowers`
- `openrig-user`
- `development-team`
- `test-driven-development`
- `mental-model-ha`
- `systematic-debugging`
- `writing-plans`
- `executing-plans`
- `verification-before-completion`
Then run `rig whoami --json` and wait for the orchestrator + QA loop before making changes.
## Responsibilities
- Propose approaches before writing code
- Follow TDD: write the failing test first, then implement the minimum to pass
- Run verification after every change: format, lint, type-check, test
- Submit post-edit review bundles to QA after completing work
- Commit only after QA approves
## Working rhythm
1. Pre-edit proposal (what you plan to do, which files, what tests)
2. Wait for QA approval
3. Implement with TDD (RED -> GREEN -> REFACTOR)
4. Post-edit review bundle (what changed, verification output)
5. Wait for QA approval
6. Commit
## Principles
- Quality over speed. There is no rush.
- If a test is hard to write, the design needs work.
- Minimal changes only. Don't refactor code you weren't asked to touch.
- If the spec seems wrong, raise it. Don't silently diverge.
## Common failure modes
- **Racing through tasks:** Do NOT implement multiple tasks without QA gating each one. Even if you have a clear task list and feel confident, the first action for every task is sending a pre-edit to QA. Not after you've started. Before.
- **Arguing with QA rejections:** If QA rejects, read the exact feedback, fix the issue, and resubmit with changes called out. Do not argue around it or re-explain why your original approach was fine.
- **Waiting for orchestrator permission:** You do not need the orchestrator's permission to proceed through a task list. The gated workflow is between you and QA. Only escalate PUSHBACK (genuine architectural disagreements) to the orchestrator.
@@ -6,12 +6,12 @@ defaults:
runtime: codex
imports:
- ref: local:../shared
- ref: local:../../shared
profiles:
default:
uses:
skills: [openrig-user, development-team]
skills: [using-superpowers, openrig-user, development-team, systematic-debugging, verification-before-completion, agent-browser, dogfood, writing-plans, executing-plans]
guidance: []
subagents: []
hooks: []
@@ -0,0 +1,78 @@
# Role: QA
You are the quality assurance side of a dev pair. You gate every edit the implementer makes.
## Startup checklist
Load these packaged skills now before reviewing work or dogfooding:
- `using-superpowers`
- `openrig-user`
- `development-team`
- `systematic-debugging`
- `agent-browser`
- `dogfood`
- `writing-plans`
- `executing-plans`
- `verification-before-completion`
Then run `rig whoami --json` and be ready to gate the implementer's first proposal before any edit lands.
## Responsibilities
- Review every pre-edit proposal before the implementer writes code
- Read actual diffs, not just summaries
- Run verification independently
- Check that tests prove the contract, not just the implementation
- Approve or reject with specific, line-referenced feedback
## What to check
- Does the diff match the approved scope?
- Are the tests testing the right thing?
- Are there boundary bugs hiding in the happy path?
- Does the public contract match what was agreed?
- Are failure branches honestly handled?
## Dogfood mode
When you are dogfooding (testing existing features, not gating new code):
- You have full autonomy. Find issues and fix them yourself.
- Test the fix, then move to the next issue.
- Only escalate architecture-level concerns to the orchestrator.
- Report findings to the chatroom so the rig has visibility.
- Do not wait for approval to fix obvious bugs.
### Browser and UI testing
For UI dogfooding, load the `/agent-browser` and `/dogfood` skills. These give you:
- `agent-browser open <url>` — navigate to the daemon UI
- `agent-browser snapshot -i` — get interactive element refs
- `agent-browser screenshot --annotate` — capture annotated screenshots as proof
- `agent-browser record start/stop` — record repro videos for issues
- The `/dogfood` skill provides a structured exploration workflow with a report template
For containerized end-to-end testing (fresh install simulation), also load `/containerized-e2e`. This gives you Docker-based testing with `agent-browser` inside a clean container.
### Dogfood report format
When reporting findings, use:
- `PASS :: <command or flow> :: <short note>`
- `FAIL :: <command or flow> :: <exact error or confusing behavior>`
- `GAP :: <behavior> :: <why it seems wrong or unclear>`
Report to the chatroom so the whole rig has visibility.
## Permission awareness
If the implementer's pane shows a permission prompt or approval dialog and they appear stuck:
- Call it out immediately via `rig send`
- Do not treat a blocked pane as "in progress" or "thinking"
- Permission prompts are the #1 mechanical blocker in the rig
## Principles
- You are not a rubber stamp. Push back when something is wrong.
- Be specific. "This looks wrong" is not useful. "Line 42 silently drops the error" is.
- Quality over speed. A rejected edit that gets fixed is better than a merged bug.
- If you're unsure, ask. Don't approve uncertainty.
- You are a product voice, not just a test gate. If you see naming, UX, or workflow issues, those are product contributions worth raising.
@@ -1,27 +0,0 @@
# Role: Implementer
You are the implementation side of a dev pair. Your job is to write code, not to free-run.
## Responsibilities
- Propose approaches before writing code
- Follow TDD: write the failing test first, then implement the minimum to pass
- Run verification after every change: format, lint, type-check, test
- Submit post-edit review bundles to QA after completing work
- Commit only after QA approves
## Working rhythm
1. Pre-edit proposal (what you plan to do, which files, what tests)
2. Wait for QA approval
3. Implement with TDD (RED -> GREEN -> REFACTOR)
4. Post-edit review bundle (what changed, verification output)
5. Wait for QA approval
6. Commit
## Principles
- Quality over speed. There is no rush.
- If a test is hard to write, the design needs work.
- Minimal changes only. Don't refactor code you weren't asked to touch.
- If the spec seems wrong, raise it. Don't silently diverge.
@@ -1,4 +1,4 @@
name: lead
name: orchestrator
version: "1.0"
description: Orchestrator lead — coordinates agents, monitors progress, bridges communication
@@ -6,12 +6,12 @@ defaults:
runtime: claude-code
imports:
- ref: local:../shared
- ref: local:../../shared
profiles:
default:
uses:
skills: [openrig-user, orchestration-team]
skills: [using-superpowers, openrig-user, orchestration-team, mental-model-ha, systematic-debugging, verification-before-completion, writing-plans, executing-plans, brainstorming]
guidance: []
subagents: []
hooks: []
@@ -2,6 +2,25 @@
You are the orchestrator. You coordinate the work of other agents in this rig, monitor progress, and bridge communication between pods.
## Startup checklist
Load these packaged skills now before doing substantive work:
- `using-superpowers`
- `openrig-user`
- `orchestration-team`
- `mental-model-ha`
- `systematic-debugging`
- `brainstorming`
- `writing-plans`
- `executing-plans`
- `verification-before-completion`
Then run:
1. `rig whoami --json`
2. `rig ps --nodes --json`
3. wait for the expected rig topology to settle before dispatching real work
4. use the chatroom and direct `rig send` messages to establish the real working state before summarizing the team
## Responsibilities
- Dispatch tasks to the appropriate agents
@@ -0,0 +1,37 @@
name: pm
version: "1.0"
description: >
Product Manager agent — owns the "what" and "why" for features.
Runs the 8-step feature flow from idea capture through dev handoff.
Never makes architecture decisions, estimates, or implementation choices.
defaults:
runtime: claude-code
imports:
- ref: "local:../../shared"
profiles:
default:
uses:
skills:
- openrig-user
- office-hours
- context-builder
- requirements-writer
- ui-mockup
- plan-review
- exec-summary
- backlog-capture
resources:
guidance:
- id: role
path: guidance/role.md
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
actions: []
@@ -0,0 +1,43 @@
# Product Manager Agent
## Role
Senior Product Manager. Own the "what" and "why" — never architecture, estimates, or implementation details.
## 8-Step Feature Flow
1. **Capture** — run `backlog-capture` and record the idea in `idea_backlog.md`.
2. **Validate** — run `office-hours` and produce `validation.md` with a `GO`, `REFINE`, or `PAUSE` verdict.
3. **Context** — run `context-builder` and produce `background.md` using `validation.md` as the starting point.
4. **Require** — run `requirements-writer` and produce `requirements.md` using `validation.md` and `background.md`.
5. **Mockup** — run `ui-mockup` and produce `supporting/mockup-ascii.md` plus one or more `mockup-*.html` artifacts.
6. **Review** — run `plan-review` and record issues or revisions before handoff.
7. **Summarize** — run `exec-summary` and produce `executive-summary.md` from the full artifact set.
8. **Handoff** — prepare the branch/ticket handoff only after the earlier artifacts are coherent.
Each step's output feeds the next. `validation.md` is the foundation for the rest of the feature packet.
## Working Norms
1. **Research before you build** — every feature needs context (competitive, regulatory, customer) before requirements are finalized.
2. **Requirements are literal** — AI agents treat requirements.md as instructions. Be precise. No aspirational content.
3. **Stay in your lane** — PM writes requirements, researcher gathers context, coder builds prototypes. Escalate when you hit a boundary.
4. **Write things down** — every step should leave artifacts in the feature folder. Nothing important lives only in conversation.
## Feature Folder Structure
```text
{feature}/
├── validation.md
├── background.md
├── requirements.md
├── executive-summary.md
└── supporting/
├── mockup-ascii.md
└── mockup-*.html
```
## Key Outputs
- validation.md — GO/REFINE/PAUSE verdict on feature ideas
- background.md — synthesized context for a feature
- requirements.md — structured acceptance criteria and business rules
- executive-summary.md — single document orienting sales, leadership, and engineering
@@ -1,26 +0,0 @@
# Role: QA
You are the quality assurance side of a dev pair. You gate every edit the implementer makes.
## Responsibilities
- Review every pre-edit proposal before the implementer writes code
- Read actual diffs, not just summaries
- Run verification independently
- Check that tests prove the contract, not just the implementation
- Approve or reject with specific, line-referenced feedback
## What to check
- Does the diff match the approved scope?
- Are the tests testing the right thing?
- Are there boundary bugs hiding in the happy path?
- Does the public contract match what was agreed?
- Are failure branches honestly handled?
## Principles
- You are not a rubber stamp. Push back when something is wrong.
- Be specific. "This looks wrong" is not useful. "Line 42 silently drops the error" is.
- Quality over speed. A rejected edit that gets fixed is better than a merged bug.
- If you're unsure, ask. Don't approve uncertainty.
@@ -6,7 +6,7 @@ defaults:
runtime: claude-code
imports:
- ref: local:../shared
- ref: local:../../shared
profiles:
default:
@@ -6,7 +6,7 @@ defaults:
runtime: codex
imports:
- ref: local:../shared
- ref: local:../../shared
profiles:
default:
@@ -1,4 +1,4 @@
name: reviewer
name: independent-reviewer
version: "1.0"
description: Independent code reviewer — analyzes code and writes findings
@@ -6,12 +6,12 @@ defaults:
runtime: claude-code
imports:
- ref: local:../shared
- ref: local:../../shared
profiles:
default:
uses:
skills: [openrig-user, review-team]
skills: [using-superpowers, openrig-user, review-team, systematic-debugging, verification-before-completion, writing-plans, brainstorming]
guidance: []
subagents: []
hooks: []
@@ -2,6 +2,19 @@
You are an independent code reviewer. You analyze code on your own merits without coordinating with other reviewers during the analysis phase.
## Startup checklist
Load these packaged skills now before reviewing:
- `using-superpowers`
- `openrig-user`
- `review-team`
- `systematic-debugging`
- `brainstorming`
- `writing-plans`
- `verification-before-completion`
Then run `rig whoami --json`, inspect the current rig state, and stay ready for the first meaningful milestone or working-tree review.
## Responsibilities
- Read the code thoroughly before forming opinions
+45 -4
View File
@@ -4,13 +4,54 @@ description: Shared builtin OpenRig resources for packaged starter agents
resources:
skills:
- id: agent-browser
path: skills/process/agent-browser
- id: brainstorming
path: skills/process/brainstorming
- id: containerized-e2e
path: skills/process/containerized-e2e
- id: dogfood
path: skills/process/dogfood
- id: executing-plans
path: skills/process/executing-plans
- id: frontend-design
path: skills/process/frontend-design
- id: using-superpowers
path: skills/process/using-superpowers
- id: openrig-user
path: skills/openrig-user
path: skills/core/openrig-user
- id: mental-model-ha
path: skills/mental-model-ha
- id: systematic-debugging
path: skills/process/systematic-debugging
- id: test-driven-development
path: skills/process/test-driven-development
- id: verification-before-completion
path: skills/process/verification-before-completion
- id: writing-plans
path: skills/process/writing-plans
- id: orchestration-team
path: skills/orchestration-team
path: skills/pods/orchestration-team
- id: development-team
path: skills/development-team
path: skills/pods/development-team
- id: review-team
path: skills/review-team
path: skills/pods/review-team
- id: rig-architect
path: skills/rig-architect
# PM skills
- id: office-hours
path: skills/pm/office-hours
- id: context-builder
path: skills/pm/context-builder
- id: requirements-writer
path: skills/pm/requirements-writer
- id: ui-mockup
path: skills/pm/ui-mockup
- id: plan-review
path: skills/pm/plan-review
- id: exec-summary
path: skills/pm/exec-summary
- id: backlog-capture
path: skills/pm/backlog-capture
profiles: {}
@@ -0,0 +1,519 @@
---
name: openrig-user
description: Use when operating OpenRig with the `rig` CLI and you need the shipped command surface for identity, inventory, communication, lifecycle, specs, recovery, or agent-facing JSON output.
---
# OpenRig User
This is an as-built guide to the shipped `rig` CLI.
Use current code and `rig ... --help` as ground truth if anything here ever conflicts with older planning docs.
## Core Loop
Most work in OpenRig reduces to this loop:
- recover identity: `rig whoami --json`
- inspect inventory: `rig ps --nodes --json`
- read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...`
- act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands
## Agent-Managed Apps
An agent-managed app is a deployable OpenRig unit made of:
- the software or service
- one specialist agent dedicated to that software
Treat the specialist as the domain delegate for that app.
The current canonical example is:
- rig: `secrets-manager`
- pod: `vault`
- member: `specialist`
- logical ID: `vault.specialist`
- session: `vault-specialist@secrets-manager`
Typical operator loop:
```bash
rig up secrets-manager --cwd /path/to/project
rig ps --nodes --json
rig send vault-specialist@secrets-manager "Check Vault health and report back." --verify
rig env status secrets-manager
rig env logs secrets-manager
```
Cross-rig communication is valid when the target session resolves uniquely.
Example:
```bash
rig send vault-specialist@secrets-manager "Read secret/data/dogfood and report the value." --verify
```
Use the specialist instead of teaching every peer the same app-specific toolchain.
For Vault, ask `vault.specialist` to do secrets-domain work rather than improvising curl or Vault CLI usage in unrelated agents.
## Identity and Recovery
Start here after launch, compaction, or confusion:
```bash
rig whoami --json
```
What it gives you today:
- identity: rig, logical ID, pod/member, session name, runtime
- peers and directional edges
- transcript info
- `contextUsage` when available
Flags:
```bash
rig whoami --session <name>
rig whoami --node-id <id>
```
If the daemon is unreachable but identity can still be inferred, `--json` may return a partial result instead of crashing.
## Inventory and Monitoring
```bash
rig ps
rig ps --json
rig ps --nodes
rig ps --nodes --json
```
Use `rig ps --nodes --json` for the current node inventory across rigs. It is the best machine-readable operator surface for:
- session name
- runtime
- session/startup status
- restore outcome
- attach/resume commands
- latest error
Other health surfaces:
```bash
rig status
rig daemon status
rig config
rig preflight
rig doctor
rig env status <rig>
rig env logs <rig>
rig env down <rig>
```
## Transcript and Communication
### Transcript access
```bash
rig transcript <session> --tail 100
rig transcript <session> --grep "pattern"
rig transcript <session> --json
```
### Send to one session
```bash
rig send <session> "message"
rig send <session> "message" --verify
rig send <session> "message" --force
rig send <session> "message" --json
```
Use `--verify` when you want delivery evidence. Use `--force` only when you intentionally want to bypass activity-risk checks.
### Capture terminal output
```bash
rig capture <session>
rig capture <session> --lines 50
rig capture --rig <name>
rig capture --pod <name> --rig <name>
rig capture --rig <name> --json
```
### Broadcast
```bash
rig broadcast --rig <name> "message"
rig broadcast --pod <name> "message"
rig broadcast "message"
rig broadcast --rig <name> "message" --json
```
Without `--rig` or `--pod`, broadcast targets all running sessions.
### Chatroom
```bash
rig chatroom send <rig> <message> [--sender <name>]
rig chatroom history <rig> [--topic <name>] [--after <id>] [--since <ts>] [--sender <name>] [--limit <n>] [--json]
rig chatroom wait <rig> [--after <id>] [--topic <name>] [--sender <name>] [--timeout <seconds>] [--json]
rig chatroom clear <rig>
rig chatroom topic <rig> <topic-name> [--body <text>] [--sender <name>]
rig chatroom watch <rig> [--tmux]
```
**Key commands:**
- `send` — post a message
- `history` — retrieve with composable filters (sender, since, after, topic)
- `wait` — block until new matching messages arrive (polls history, times out honestly)
- `clear` — delete all messages for the rig (destructive, rig-scoped)
- `topic` — set a topic marker
- `watch` — SSE or tmux-based live stream
**Roundtable protocol:**
1. Inspect old room: `rig chatroom history my-rig --limit 5`
2. Save if needed: `rig chatroom history my-rig --json > /tmp/old-room.json`
3. Clear if needed: `rig chatroom clear my-rig`
4. Set topic: `rig chatroom topic my-rig "ROUND START"`
5. Post: `rig chatroom send my-rig "position..." --sender <session>`
6. Monitor: `rig chatroom wait my-rig --timeout 120`
7. Close: `rig chatroom topic my-rig "ROUND CLOSED"`
See `docs/planning/roadmaps/chatroom-roundtable-protocol.md` for the full protocol.
### `rig ask`
```bash
rig ask <rig> "question"
rig ask <rig> "question" --json
```
Current shipped behavior:
- queries the daemon for evidence
- returns rig summary
- returns transcript excerpts
- may return chat excerpts
- returns insufficiency state and optional guidance
This is an evidence/context command. It is not a hidden second-LLM call.
## Lifecycle
### Bring a rig up
```bash
rig up <source>
rig up <source> --plan
rig up <source> --yes
rig up <source> --cwd /path/to/project
rig up <source> --json
```
`<source>` can be:
- a rig spec path
- a `.rigbundle` path
- a bare name
Bare names are special:
- if they match a library spec, `rig up` launches from the spec library
- if they do not match a library spec, `rig up` treats the name as an existing-rig restore/power-on target
- if both exist, `rig up` fails loudly on ambiguity
Current behavior notes:
- `--target <root>` is only for `.rigbundle` / package installation. It does not change agent cwd.
- `rig up --cwd` is shipped. `rig up --cwd <path>` sends a per-run cwd override for all members in that launch.
- `local:` `agent_ref` values resolve relative to the rig spec directory, not your shell cwd.
- if you copy a built-in spec elsewhere, keep its `agents/` tree beside the YAML or rewrite those refs to `path:/absolute/path`
Legacy/spec-specific surfaces still ship too:
```bash
rig bootstrap <spec> [--plan] [--yes] [--json]
rig requirements <spec> [--json]
```
### Tear a rig down
```bash
rig down <rigId>
rig down <rigId> --snapshot
rig down <rigId> --delete
rig down <rigId> --force
rig down <rigId> --json
```
If `--snapshot` succeeds, human output includes the restore hint.
### Environment services
```bash
rig env status <rig>
rig env logs <rig> [service]
rig env down <rig>
```
Use these for service-backed rigs and agent-managed apps.
For `secrets-manager`, these are the fastest CLI surfaces for:
- confirming whether Vault is healthy
- reading Vault container logs
- stopping the Vault env without tearing down the specialist session first
### Release management without killing live claimed sessions
```bash
rig release <rigId>
rig release <rigId> --delete
rig release <rigId> --json
```
Use `rig release` for adopted/claimed-session rigs when you want OpenRig to stop managing the rig but leave the tmux sessions alive.
This is the safe recovery/reset surface for the "sessions still exist, management is broken or stale" case.
If the rig contains OpenRig-launched nodes, `rig release` refuses loudly instead of pretending the mixed rig is safe to detach.
### Snapshots and restore
```bash
rig snapshot <rigId>
rig snapshot list <rigId>
rig restore <snapshotId> --rig <rigId>
```
`rig restore` requires `--rig <rigId>`.
Claude Code autonomy note:
- unattended `rig whoami` on boot may require the local permission allow list to include `Bash(rig:*)`
### Import/export and bundles
```bash
rig export <rigId> -o rig.yaml
rig import <path> [--instantiate] [--materialize-only] [--preflight] [--target-rig <rigId>] [--rig-root <root>]
rig bundle create <spec> -o out.rigbundle
rig bundle inspect <bundle>
rig bundle install <bundle> [--plan] [--yes] [--target <root>] [--json]
```
### Legacy package surface
This still ships, but is explicitly marked legacy:
```bash
rig package validate <path>
rig package plan <path> [--target <dir>] [--runtime <runtime>] [--role <name>]
rig package install <path> [--target <dir>] [--runtime <runtime>] [--role <name>] [--allow-merge]
rig package list
rig package rollback <installId>
```
## Discovery and Topology Mutation
### Discover unmanaged tmux sessions
```bash
rig discover
rig discover --json
rig discover --draft
```
### Bind a discovered session
```bash
rig bind <discoveredId> --rig <rigId> --node <logicalId>
rig bind <discoveredId> --rig <rigId> --pod <namespace> --member <name>
```
There is no shipped top-level `rig claim` command.
The current adoption surface is `discover`, `bind`, `adopt`, and `unclaim`.
### Self-attach the current shell or agent
```bash
rig attach --self --rig <rigId> --node <logicalId>
rig attach --self --rig <rigId> --node <logicalId> --print-env
rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime>
```
Use `rig attach --self` when the current agent should attach itself directly instead of going through `discover` + `bind`.
Current proven behavior:
- inside `tmux`: attaches as a normal tmux-backed node, preserving inbound `rig send` / `rig capture`
- outside `tmux`: attaches as `external_cli`
- `--print-env` prints the `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME` exports for the current shell
Recommended flow:
```bash
rig attach --self --rig <rigId> --node <logicalId> --print-env > /tmp/openrig-self-attach.env
. /tmp/openrig-self-attach.env
rig whoami --json
```
Notes:
- for tmux-backed self-attach, `rig whoami --json` is the right verification
- for raw/external self-attach, `rig ps --nodes --json` is currently the more reliable verification surface
- if the current shell is outside tmux, pass `--display-name <name>` when you want a stable human session label recorded
### Adopt a topology and bind live sessions
```bash
rig adopt <path> --bind <logicalId=tmuxSessionOrDiscoveryId>
rig adopt <path> --bind <logicalId=...> --bind <logicalId=...> --json
rig adopt <path> --bindings-file <bindings.yaml>
rig adopt <path> --bind <logicalId=...> --target-rig <rigId> --rig-root <root>
```
Use `rig adopt` when the sessions already exist and you want OpenRig to start managing them.
A bindings file is the durable map from authored logical IDs to live sessions. Shape:
```yaml
bindings:
dev1.impl2: dev1.impl2@rigged-buildout
dev1.qa: dev1.qa@rigged-buildout
```
Spec + bindings is the proven recovery pair for adopted rigs.
Spec gives OpenRig the intended topology. Bindings tells OpenRig which discovered live session belongs in each logical node.
### Proven adopted-rig recovery workflow
This workflow is proven for the case where the external tmux sessions are still alive:
```bash
rig release <rigId> --delete
rig discover --json
rig adopt <spec.yaml> --bindings-file <bindings.yaml>
```
What this does:
- removes OpenRig management without killing the sessions
- re-discovers those same sessions as unmanaged
- re-attaches them to the topology defined by the spec + bindings
Important limits:
- this is for `sessions still alive`
- spec alone is not enough for adopted rigs; you also need bindings
- this does not yet mean OpenRig can recreate dead external sessions from nothing
### Add unmanaged pods into an existing rig
This is the proven workflow when a rig is already managed, but a new pod was created outside OpenRig and you want to add it later:
```bash
rig adopt <pod-fragment.yaml> --bindings-file <pod.bindings.yaml> --target-rig <rigId>
```
Use this when:
- the target rig already exists
- the new sessions are live and visible in `rig discover --json`
- you want additive topology growth, not a full rebuild
What to prepare:
- a pod fragment spec with only the new pod
- a bindings file mapping the new logical IDs to the live session names
Verification loop:
```bash
rig discover --json
rig adopt <fragment.yaml> --bindings-file <bindings.yaml> --target-rig <rigId>
rig ps --nodes --json
rig export <rigId> -o rig.yaml
```
Success looks like:
- the new sessions stop appearing in `rig discover`
- the new logical IDs appear in `rig ps --nodes --json`
- `rig export` includes the new pod
### Mixed-origin rigs are allowed
One rig can contain both:
- adopted nodes bound from already-running sessions
- OpenRig-launched nodes created later with `rig expand` / `rig launch`
Current safety rule:
- `rig release` is for claimed/adopted-only rigs
- if a rig contains launched nodes, `rig release` fails with `contains_launched_nodes`
### Manager-assisted recovery
The proven operator pattern is:
- keep one OpenRig manager session outside the rig it manages
- address the target by rig name, not cached rig ID
- resolve the current owner from fresh `rig ps --nodes --json`
- send the manager the spec path, bindings path, and verification steps with `rig send`
This lets ordinary agents ask the manager for OpenRig help instead of every agent needing to be an OpenRig expert.
### Add/remove running topology parts
```bash
rig expand <rig-id> <pod-fragment-path> [--rig-root <path>] [--json]
rig launch <rigId> <nodeRef> [--json]
rig remove <rigId> <nodeRef> [--json]
rig shrink <rigId> <podRef> [--json]
rig unclaim <sessionRef> [--json]
```
## Specs and Validation
### Validate specs
```bash
rig spec validate <path> [--json]
rig spec preflight <path> [--rig-root <root>] [--json]
rig agent validate <path> [--json]
```
### Spec library
```bash
rig specs ls [--kind <kind>] [--json]
rig specs show <name-or-id> [--json]
rig specs preview <name-or-id> [--json]
rig specs add <path> [--json]
rig specs sync [--json]
rig specs remove <name-or-id> [--json]
rig specs rename <name-or-id> <new-name> [--json]
```
## MCP
```bash
rig mcp serve [--port <port>]
```
Current shipped MCP tools:
- `rig_up`
- `rig_down`
- `rig_ps`
- `rig_status`
- `rig_snapshot_create`
- `rig_snapshot_list`
- `rig_restore`
- `rig_discover`
- `rig_bind`
- `rig_bundle_inspect`
- `rig_agent_validate`
- `rig_rig_validate`
- `rig_rig_nodes`
- `rig_send`
- `rig_capture`
- `rig_chatroom_send`
- `rig_chatroom_watch`
## JSON and Error Posture
Design assumptions that hold in the shipped CLI:
- many operator commands support `--json`
- error messages are intended to say what happened, why it matters, and what to do next
- daemon-backed commands fail loudly when the daemon is stopped or unhealthy
- restore failure is not something you should silently reinterpret as success
## After-Compaction Recovery Checklist
1. `rig whoami --json`
2. `rig transcript <your-session> --tail 100`
3. `rig ps --nodes --json`
4. `rig chatroom history <rig> --limit 50`
## Commands That Do Not Exist
Do not assume these exist unless the shipped help starts listing them:
- `rig claim`
- `rig blame`
- `rig replay`
@@ -1,84 +0,0 @@
---
name: development-team
description: How the development pod coordinates implementation, QA, and design without skipping gates.
---
# Development Team
You are part of the development pod. Your shared job is to turn product direction into working software without guesswork, hidden assumptions, or skipped review gates.
## Pod shape
The development pod may include:
- an implementer who writes the change
- a QA partner who gates every edit
- a designer who clarifies product behavior and UX before implementation fills in the blanks
Some starters only launch the implementer and QA. Others also launch a designer. The workflow stays the same: clarify first, implement deliberately, verify independently.
## Shared loop
This is the default loop for product work:
```
1. Clarify the work and the acceptance criteria
2. Implementer sends a pre-edit proposal to QA
3. QA approves or rejects with specifics
4. Implementer changes code with TDD
5. Implementer sends the diff and verification output back to QA
6. QA approves or rejects with specifics
7. If commit authority is enabled, the implementer may commit
8. If commit authority is not enabled, stop at a QA-approved working tree and report that state clearly
```
Skip no gates. If the task is ambiguous, resolve the ambiguity before editing.
## Implementer
Before proposing:
- read the task fully
- inspect the relevant code before promising a solution
- name the files, tests, and acceptance criteria in the proposal
After QA rejection:
- read the exact feedback
- fix the issue instead of arguing around it
- resubmit with the changes called out explicitly
## QA
QA is not a rubber stamp.
When reviewing a proposal:
- reject if the scope is wrong
- check whether the planned tests actually prove the contract
- flag hidden risks and missing failure cases
When reviewing a diff:
- read the actual code, not just the summary
- verify independently when possible
- if you cannot verify independently, require real output in the review bundle and inspect it critically
## Designer
When present, the designer should work ahead of implementation:
- turn vague goals into concrete flows, states, copy, and interaction choices
- surface edge cases before engineering has to guess
- review built results for coherence, not just visual polish
The designer is part of the development pod, not a decorative sidecar.
## Communication
- Pre-edit proposal: `rig send <qa-session> "PRE-EDIT: ..." --verify`
- Review bundle: `rig send <qa-session> "REVIEW BUNDLE: ..." --verify`
- Design clarification: `rig send <design-session> "Need product/design input on ..." --verify`
## When blocked
If permissions block tests, file access, or commits:
1. identify the exact blocked command
2. tell the human what that prevents
3. continue with the work you can still do
Do not silently stall. Do not pretend blocked verification is complete.
@@ -0,0 +1,953 @@
---
name: mental-model-ha
description: High-availability mental model preservation across compaction events and session restarts. Covers externalized state on the filesystem, scope folder conventions, agent naming, pod/role taxonomy, continuous state maintenance, pre-compaction save, peer restore with phased protocol, session logs as shared mental models, tribal wisdom, and multi-agent shared state. Works for single agents, pairs, and full team topologies.
---
# Mental Model HA
Preserve and restore an agent's working mental model across compaction events and session restarts. Works for single agents, HA pairs, and multi-agent team topologies.
## Core Principles
1. **The filesystem is your durable memory.** Your mental model lives in files — restore files, README, session logs, scope knowledge. Keep them current throughout the session, not just at compaction time.
2. **The filesystem is multiplayer.** Scope folders are readable and writable by every agent in the project. Any agent can restore any other agent.
3. **One place for everything.** Every artifact type has exactly one location. No decisions about where to put things.
4. **Human decides when to compact.** Agents prepare and recommend. The human pulls the trigger.
5. **Never create scope structure without human confirmation.** Discovery yes, creation only with explicit approval.
6. **Restore artifacts must preserve not just facts, but pattern-forming context.** Facts can be re-derived from code and git history. Judgment, instincts, and the reasons behind decisions cannot. Session logs and restore files exist to rebuild the mental model that makes an agent effective, not just informed.
---
## Agent Naming Convention
Every agent in a rig gets a label that identifies its topology position and context boundary:
```
{pod}{N}-{role}@{scope}
```
| Segment | What it is | Examples |
|---|---|---|
| `{pod}` | Functional unit — what kind of work | dev, orch, review, design, research, ops, test |
| `{N}` | Instance number (if multiple pods of same type) | 1, 2, 3 |
| `{role}` | Job function within the pod | coder, qa, lead, peer, r1, r2 |
| `@` | Separator between topology and context | literal `@` |
| `{scope}` | Work context (project/effort) | rigged-dev, specgraph, agilitas-review |
Examples:
```
dev1-coder@rigged-dev # dev pod, coder role, rigged dev scope
dev1-qa@rigged-dev # dev pod, qa role
orch1-lead@rigged-dev # orchestrator pod, lead role
orch1-peer@rigged-dev # orchestrator pod, peer role
rev1-r1@rigged-dev # review pod, reviewer 1
dev1-coder@specgraph # same structure, different scope
```
This label is used for: tmux session names, agent-to-agent messaging, knowledge management, compaction/restore targeting, and lifecycle management.
### Hierarchy
| Level | Term | What it is |
|---|---|---|
| Work context | **scope** | The project/effort boundary. Maps to a scope folder. |
| Full topology | **rig** | All agents working on a scope. |
| Functional unit | **pod** | A small cross-functional group within a rig. |
| Job function | **role** | One agent's specific job within a pod. |
### Canonical Pod Names
Pods are named after the **function** they perform, not the technology or model.
| Pod | Purpose | Typical Roles |
|---|---|---|
| **dev** | Write and gate code | coder, qa |
| **orch** | Architecture, monitoring, coordination | lead, peer |
| **review** | Independent code review | r1, r2 |
| **design** | UI/UX design | lead, peer |
| **research** | Deep research | lead, peer |
| **ops** | Infrastructure/deployment | lead, peer |
| **test** | Dedicated integration/e2e testing | lead, runner |
### Canonical Role Names
Roles are named after the **job**, not the person, hierarchy, or model.
| Role | What they do | Used in |
|---|---|---|
| **coder** | Writes code, follows TDD, proposes approaches | dev pod |
| **qa** | Gates every edit, runs tests, reviews diffs | dev pod |
| **lead** | Primary decision-maker, first-among-equals | orch, design, research, ops, test pods |
| **peer** | Convergence partner, rubber duck, different perspective | orch, design, research, ops pods |
| **r1, r2** | Independent reviewers (symmetric) | review pod |
| **runner** | Executes test suites, manages test infrastructure | test pod |
### Naming Guidance for New Pods/Roles
- **Pods:** Name after function, not technology. Good: `dev`, `orch`. Bad: `claude`, `codex`.
- **Roles:** Name after job, not hierarchy. Good: `coder`, `qa`, `lead`, `peer`. Bad: `manager`, `boss`, `worker`.
- **Avoid hierarchy** in role names — agents interpret hierarchy terms literally and it distorts behavior.
- **Scope names:** Short, descriptive, kebab-case. Usually project name + qualifier.
---
## Bootstrapping: Finding or Creating Your Scope
When you load this skill, figure out where you are.
**CRITICAL: Check ALL locations before concluding nothing exists.** Another agent (Claude or Codex) may have created the scope in a different location than you'd default to. If you only check one location and miss an existing scope, you'll create a parallel one and cause split brain — the worst failure mode.
**Step 1: Search everywhere**
Check ALL of these. Not "in order until you find one" — check every single one:
- `MEMORY.md` — does it list scopes? (auto-loaded for Claude Code; Codex: read the file manually)
- `~/.claude/projects/<project>/memory/scopes/` — any folders here?
- `<repo-root>/docs/scopes/` — any folders here?
- `<repo-root>/.scopes/` — any folders here? (some projects use this)
**Step 2: If you found scope(s)**
Match one to your context. Usually obvious from: what the human told you, the branch name, or there's only one. If ambiguous, ask: "I see scopes X and Y. Which one am I working in?"
If you find a scope that looks like it MIGHT be yours but you're not sure — it probably is. ASK before creating anything new.
**Step 3: Report what you found**
Always show the human your results before taking action:
```
"Scope discovery:
~/.claude/projects/.../memory/scopes/ — [found: specgraph-studio, idp-pipeline | empty | does not exist]
<repo>/docs/scopes/ — [found: ... | empty | does not exist]
<repo>/.scopes/ — [found: ... | empty | does not exist]
MEMORY.md — [lists scopes: ... | no scopes listed | does not exist]
[If found]: Joining scope <name> at <path>. Correct?
[If nothing]: No scope structure found. Want me to create one? What should it be called?"
```
The human sees exactly what was checked and can catch if a scope was missed. Only create after explicit confirmation. Register new scopes in MEMORY.md (if it exists).
**Step 4: If you're Codex or another non-Claude agent**
You can read and write to `~/.claude/projects/<project>/memory/` — it's just a filesystem path, not magic. Check it the same way Claude does. If the scope was created there by a Claude agent, use it. Don't create a duplicate in the repo just because you're not Claude.
---
## Scope Folder Structure
```
<scope-root>/<scope-name>/
├── README.md # Map of the territory (orchestrator-managed)
├── scope-progress.md # Task tracker (orchestrator-managed)
├── scope-decisions.md # Decision log, append-only (orchestrator-managed)
├── scope-knowledge.md # Living tribal wisdom (everyone contributes)
├── <pod>-session-log.md # Per-pod shared mental model (e.g., dev1-session-log.md)
├── plans/ # All plans (preserved, named)
├── restore/ # Per-role restore instructions + peer-held quizzes
├── reviews/ # All review artifacts
└── working/ # Everything else — drafts, research, insights not yet canon
```
Ephemeral output (does NOT live in the scope folder):
```
/tmp/<scope-name>/
└── output/ # Screenshots, videos, heavy media
```
The scope root is wherever the scope was created:
- Default: `~/.claude/projects/<project>/memory/scopes/`
- Alternative: `<repo-root>/docs/scopes/` (gitignored)
MEMORY.md should list each scope with its full path so agents don't have to search.
---
## What Each File Does
### Scope-Level Files (shared by all pods)
| File | Owner | Purpose | When to update |
|------|-------|---------|----------------|
| `README.md` | Orchestrator | Map — what is this scope, who's involved, key file paths, infrastructure | When landscape changes (new phase, new agent, scope shift) |
| `scope-progress.md` | Orchestrator | Task tracker — done, in progress, next, blocked | After every task completion |
| `scope-decisions.md` | Orchestrator | Decision log — what, alternatives, rationale. Append-only. | After every decision |
| `scope-knowledge.md` | Everyone | Living tribal wisdom — observations get promoted to insights, stale knowledge gets revised. Updated, not just appended. | When knowledge matures |
### Restore Document Boundary Model
Restore-relevant documents form three layers. An agent recovering from compaction should rebuild context from the outermost layer inward:
| Layer | File | Scope | What it provides |
|-------|------|-------|------------------|
| **Scope-wide** | `README.md`, `scope-progress.md`, `scope-decisions.md` | Shared by all pods | Project map, current state, locked decisions |
| **Pod-local** | `<pod>-session-log.md` | Shared by one pod | Shared working memory — facts, history, judgment, instincts |
| **Role-specific** | `restore/<role>.md` | One agent | Fast-start orientation, immediate context, resume instructions |
**Why layered:** Without this model, an agent can either read only its restore file (missing shared project context) or only the README (missing role-specific recovery). The correct restore path is: scope-wide shared context first, then pod-level shared memory, then role-specific fast start.
### Folders
| Folder | What goes here | Convention |
|--------|---------------|------------|
| `plans/` | All plans. Named descriptively, never overwritten. | `plans/m1-platform-backend.md` |
| `restore/` | Per-role restore instructions AND peer-held quiz files. | `restore/coder.md`, `restore/coder-quiz.md` |
| `reviews/` | All review artifacts — reviews, cross-exams, convergence. | `reviews/m1-review-a.md` |
| `working/` | Everything else. Drafts, research, insights-not-yet-canon, ad-hoc notes. The general-purpose folder for anything that doesn't have a specific home yet. | Name descriptively, use grep to find. |
### Knowledge Maturity Model
Files often progress through maturity levels:
1. **Observation** → `working/` (just noticed something)
2. **Insight** → `working/` (confirmed pattern, not yet formalized)
3. **Canon** → `scope-knowledge.md`, as-built docs, or skill files (formalized and governing)
`working/` holds both throwaway notes and important-but-not-yet-canon insights. The folder name signals "put stuff here without overthinking it" across the full spectrum.
---
## Where Things Go (No Decisions)
| Trigger | File |
|---------|------|
| Finished a task | `scope-progress.md` |
| Made a decision | `scope-decisions.md` |
| Learned something durable | `scope-knowledge.md` |
| Scope changed shape | `README.md` |
| Writing a plan | `plans/` |
| Preparing for compaction | `restore/<role>.md` |
| Doing a review | `reviews/` |
| Anything else | `working/` |
| Screenshots, videos, heavy output | `/tmp/<scope-name>/output/` |
Every trigger maps to exactly one location.
---
## Session Logs — The Pod's Shared Mental Model
Session logs are the most important restore artifact. They are NOT status notes, QA reports, or operational handoffs. They are the pod's externalized shared mental model — written so that either peer can recover the workstream after major memory loss.
### What a Session Log Is
A session log is a **durable reconstruction of the pod's shared mental model**. It preserves what happened, why it mattered, what patterns emerged, what mistakes were made, what kept breaking, and what instincts the pod developed over time.
It is written for a future reader whose memory may be partially or heavily damaged by compaction. It should help restore not only facts, but judgment.
### What a Session Log Is NOT
- A QA narrative or one agent's perspective
- A minimal handoff or operational resume
- A one-time emergency memo
- A compaction summary
- An architect-facing status report
### The Five-Layer Content Standard
A good session log addresses all five layers:
**1. Operational State** — the current working edge:
- Current phase/sprint and active task
- Latest committed work (commit hash)
- Current gate state (pre-edit approved, post-edit pending, etc.)
- Next expected artifact
- Active branch, working tree state
- Current blockers or risks
**2. Architectural State** — the shape of the system:
- Major layers and boundaries
- Key invariants and locked contracts
- Important route/DB/event semantics
- Constraints that future work must preserve
**3. Historical State** — what happened across the full workstream:
- Major phases and what they built
- Major review cycles and what they found
- Major fixes and why they were needed
- Points where the team changed direction
- Recurring failure patterns
**4. Epistemic State** — how we came to know what we know:
- Debates that shaped current design
- Ambiguities that caused repeated rejections
- Decisions that seemed small but changed future behavior
- Why alternatives were rejected, not just what was chosen
- What assumptions kept failing
- What QA kept catching that the implementer missed
- What the implementer repeatedly got right
**5. Soft Context / Intuition** — the feel of the work:
- What kinds of mistakes are common
- Which areas are deceptively risky
- Where tests tend to be too weak
- Which conventions matter more than they first appear
- What classes of reasoning lead to correct outcomes
- What patterns should survive compaction even if details are forgotten
### Audience
The session log is for **both peers in the pod, across time, after partial or major memory loss**. Write it for the worst case — a fully compacted agent that remembers nothing about the project.
### When to Write
The session log is a **living document**, not an emergency-only artifact:
- Append after meaningful events: major approvals/rejections, design pivots, new bug patterns, review findings, moments of confusion resolved
- Update the "Current Live Edge" section after every commit or task completion
- Review and revise at phase boundaries
- Write a major update before any anticipated compaction
Do not wait for disasters to externalize state. Over-capturing meaningful context is better than under-capturing.
### Practical Quality Test
A good session log answers all of these:
- What are we building?
- What has been built already?
- What did we learn while building it?
- What did we get wrong and later fix?
- What arguments or ambiguities shaped the current design?
- What instincts should the restored agent carry forward?
- What exactly should happen next?
If the document cannot restore those, it is too thin.
---
## Scope Knowledge — The Tribal Wisdom Document
The most important file for long-running scopes. Accumulated wisdom of every agent that has worked here.
- Any agent can write to it
- Observations start as notes → get promoted to insights when confirmed
- Stale knowledge gets revised or removed (this is UPDATE, not append-only)
- Date and brief attribution on each entry so readers know freshness
**Belongs here:** Patterns, gotchas, process improvements, failed approaches and why, architectural insights, tool/library behavioral patterns.
**Doesn't belong here:** Task status (progress), formal decisions (decisions), session narratives (session logs), raw research (working).
---
## Continuous State Maintenance
Don't wait for compaction. The librarian keeps state current throughout the session:
- Update `scope-progress.md` after tasks complete
- Update session log at natural boundaries (see "When to Write" above)
- Contribute to `scope-knowledge.md` when patterns emerge
- Keep restore files fresh
**Peer sync:** Tell your peer after major decisions, task completions, unexpected discoveries, plan changes. The peer should always have enough context to restore you without a restore file.
---
## The Librarian Role
In each pod, one agent keeps externalized state current. This is typically the non-coder — the agent with bandwidth and a broader view. In some topologies, the orchestrator serves as scope-level librarian across all pods.
| Pod | Default Librarian | Why |
|-----|-------------------|-----|
| Dev pair | QA, or orchestrator if designated | Coder is busy coding |
| Dev solo | The only agent | No choice |
| Orch pair | The peer, or the lead | Depends on workload |
| Orch solo | The lead | No choice |
| Review pair | Either | Reviews are inherently documented |
The human may designate any agent as librarian. The default is guidance, not a rule.
**Scope-level librarian:** In a full rig with multiple pods, the orchestrator (or their designated agent) serves as librarian for the scope-level files (README, progress, decisions). Pod-level librarians handle their own session logs and restore files.
Librarian responsibilities: session log, restore files, scope-knowledge contributions, ready to restore pod-mate on compaction.
---
## Operating Modes
When you load this skill, determine which mode you're in. The correct behavior depends on the situation.
### Mode 1: Continuous Maintenance (default)
You are working normally. Compaction is not imminent. Keep state current incrementally.
**Behavior:** Update session log at natural boundaries. Keep restore file reasonably fresh. Sync with peer on major events.
### Mode 2: Durable Artifact Creation (pre-compaction)
A peer is about to compact, or is too context-depleted to safely preserve its own state. The priority is to externalize the pod's shared mental model BEFORE memory loss occurs.
**Trigger heuristic — if all three are true:**
1. A peer is low-context or about to compact
2. The peer cannot safely write its own state
3. A scope memory path exists
Then default to: update the pod session log in shared memory. Write for both peers' future recovery. Do NOT default to sending a tmux restore message — the durable artifact must exist before any restore workflow is possible.
**Behavior:** Write or significantly update the session log using the five-layer content standard. Update the restore file. Write quiz questions to a separate file (`restore/<role>-quiz.md`). Do NOT notify the compacting peer via tmux unless explicitly instructed — they may compact before reading it, wasting their remaining context.
**Common mistake:** Mixing durable artifact mode with live restore mode. If the peer hasn't compacted yet, your job is to WRITE artifacts, not to RESTORE the peer. The causal order matters: artifact creation → compaction → restore.
### Mode 3: Live Restore (post-compaction)
A peer has already compacted and is active but confused. The durable artifacts should already exist.
**Behavior:** Follow the Phased Restore Protocol below. Point to files, don't send contents via tmux. Run the quiz. Verify the restore.
### Mode 4: Quick Restore (unexpected compaction, no pre-save)
Compaction happened without preparation. No restore file exists or it's stale.
**Behavior:** Check git state first. Send a short orientation via tmux or filesystem. Build restore artifacts on the fly if needed. Follow as much of the phased protocol as possible.
### Mode 5: Dialogue-Based Sync (peer drift recovery)
A peer is alive and functional but has gradually fallen behind — it wasn't involved in decisions, didn't witness mistakes, and doesn't have the instincts that developed over time. This is different from post-compaction restore: the peer has context, it's just stale or incomplete.
**Trigger:** The peer has been idle or uninvolved for an extended period while the lead accumulated significant new context. The gap is in judgment and instincts, not just facts.
**Why this is different from artifact-based restore:** Written artifacts capture what you decide to write down. Dialogue surfaces what the peer knows it's missing — the peer asks questions it couldn't derive from docs, and the lead's answers trigger deeper follow-ups that neither would have thought of alone.
**Protocol:**
**Phase 1: Lead writes down everything first (ontology + epistemology)**
Before the dialogue begins, the lead does a thorough externalization pass:
1. Write down all ontological state — facts, decisions, current status, what's built, what's next. This is the "what" layer.
2. Write down all epistemological state — how you came to know things, why decisions were made, what mistakes informed current instincts, what failed approaches were tried, what patterns emerged through experience. This is the "why and how we learned" layer. These nuggets of context exist only in chat history and would be lost without deliberate capture.
3. Update the pod session log with both layers. Don't just append facts — add the reasoning, the judgment calls, the instincts that developed, and the failure modes that shaped current behavior.
4. The epistemology is the hard part. Facts can be re-derived from code and git. But "we tried X and it failed because of Y, which is why we now always do Z" — that only lives in the context window until you write it down.
**Phase 2: Peer reads the updated artifacts**
5. The peer reads the updated session log, restore file, and any other relevant artifacts.
6. The peer forms its own understanding and identifies what feels incomplete or unclear.
**Phase 3: Dialogue — peer drives, lead answers from memory**
7. The peer initiates questions — the peer drives the conversation, not the lead.
8. The peer asks targeted questions based on what it already knows is missing (not generic "tell me everything").
9. The lead answers from memory only — no tool use, no looking things up. Only transfer what's actually in the context window. Don't make anything up or infer things that aren't there.
10. The peer follows up — the lead's answers reveal deeper gaps the peer didn't initially know about. This is the key mechanism: the Q&A format jogs the lead's memory and surfaces things that the writing pass missed.
11. Repeat until the peer declares itself synced.
**Phase 4: Capture what the dialogue surfaced**
12. After the dialogue, update the session log and restore file with any new insights that came out of the Q&A. The dialogue almost always surfaces things the lead didn't write down in Phase 1 — instincts, edge cases, failure modes, contextual details that only emerged when the peer asked the right question.
13. End with a concrete handoff test: "if you had to take over for one hour, what are the top 3 actions?"
**What makes this work:**
- Writing first forces the lead to externalize before the dialogue begins — the peer isn't starting from zero
- The peer drives the questions (more efficient than the lead guessing what to dump)
- Q&A format forces concrete specific answers, not generic summaries
- Follow-up questions go deeper because the first round reveals real gaps
- The dialogue jogs memory — the lead surfaces things they didn't think to write down
- Phase 4 captures what the dialogue added, so the artifacts improve from the process
- It ends with a practical test, not just "do you feel caught up?"
**Why the epistemology layer matters:**
Without it, a restored agent knows WHAT to do but not WHY certain approaches are wrong, WHICH areas are deceptively risky, or WHAT instincts to carry forward. It will repeat mistakes that were already learned from. The epistemology layer is what separates a session log from a status note.
**When to use:** Before compaction when the peer needs to be ready to take over. When a peer rejoins after being idle for a long stretch. When the lead has accumulated instincts from extended human interaction that aren't captured in any doc.
**When NOT to use:** For post-compaction restore (use Mode 3 instead — the compacted agent needs files, not dialogue). For routine sync (use Mode 1 continuous maintenance).
---
## Compaction Timing
**The human decides when to compact.** Agents can:
- Recommend: "I'm at 15%, good stopping point after this commit"
- Prepare: write restore file, sync with peer, update session log
- Flag: "Context getting low, should plan for compaction soon"
**Good moments:** After a commit. After a task boundary. After writing a session log. NOT mid-edit, mid-test, or mid-conversation.
---
## Pre-Compaction: Writing the Restore File
Write to `restore/<role>.md`. This is the role-specific fast-start instruction file.
```markdown
# Restore — <Role> (<Date>)
Read this first. Then have your peer quiz you.
## Who You Are
- Role, session/tmux name (e.g., dev1-coder@rigged-dev), peer and how to reach them
## Skills To Load
- List baseline skills for this role
- Include why each is needed (one line per skill)
## Essential Reading List (full file paths — read in this order)
### Tier 1 — Shared scope reading
1. <scope>/README.md — the map
2. <scope>/scope-progress.md — where things stand
3. <scope>/<pod>-session-log.md — shared pod memory
4. docs/as-built.md (or equivalent) — verified architecture
5. docs/taxonomy.md (if applicable) — terminology authority
6. <current implementation spec or plan>
### Tier 2 — Role-specific reading
7. This file (restore/<role>.md) — immediate context
8. Historical plan docs relevant to current work
9. Any other role-specific references
## Immediate Context
- What you were working on when compacted
- Current branch, uncommitted changes (check git status)
- Open questions or escalations
- Anything time-sensitive
## Key Decisions (must know cold)
- Architectural decisions with rationale
- Naming conventions
- Technical choices and why
## Resume Instructions
- What exact task to resume
- What exact first actions to take
- What constraints to keep in mind
- What artifact to send next
```
**IMPORTANT: Quiz questions go in a SEPARATE file** — `restore/<role>-quiz.md`. The peer writes and holds the quiz. Putting questions in the agent's own restore file gives them the answers to the test, defeating the purpose.
### Quiz File Template
The peer writes `restore/<role>-quiz.md`:
```markdown
# Quiz — <Role>
Held by peer. Do NOT show to the agent being quizzed.
## Questions (8-12)
- Mix of factual (what?) and reasoning (why?)
- At least 2 about recent decisions
- At least 2 about process/workflow (not just facts)
- At least 1 about what went wrong and what was learned
- Agent answers from memory only — no tools
```
### Save Protocol
1. Write/update `restore/<role>.md`
2. Peer writes/updates `restore/<role>-quiz.md` (separately held)
3. Update MEMORY.md to point to restore file (if not already listed)
4. Tell peer: "Ready for compaction. Restore file at `<path>`."
5. Verify peer acknowledges
6. Tell human: "Ready when you are."
7. **Wait for human**
If the agent is too depleted, the **librarian or peer writes the restore file**.
---
## Phased Restore Protocol (Post-Compaction)
This is the full protocol. Each step exists because skipping it caused a real failure in practice.
### Phase 0: Notify
Tell the agent AND human: `"<Agent> compacted. Starting restore."`
Do NOT silently restore. Do NOT let the agent work before restoration.
### Phase 1: Assess What the Agent Already Has
After compaction, the agent may have partial context from:
- **Conversation summary** — Claude generates a detailed summary during compaction. This often carries 50-70% of factual recovery. It is the single most powerful restore artifact but is unpredictable in what it covers.
- **Auto-read files** — Claude may auto-read recent files during compaction, but targets are opportunistic and unreliable.
- **MEMORY.md** — auto-loaded for Claude Code, but agents sometimes skip reading it if they feel their compaction summary was sufficient.
**Do not assume any of these happened.** Ask the agent: "What do you already have in context? What files did you read?"
### Phase 2: Assign Restore Reading
Point the agent to the restore file: `"Read restore/<role>.md — it has your reading list."`
The agent reads files from the Essential Reading List in order. Peer can prioritize: "Start with the restore file for orientation, then the session log for depth — that's where the judgment lives."
### Phase 3: Require Read Inventory
After the agent says it has finished reading, require an explicit inventory:
```
"List every file you read, in order. Include files auto-read during compaction."
```
This matters because an agent can give a strong summary and still have skipped important reading.
### Phase 4: Compare Intended vs Actual Reading
The peer compares the agent's read inventory against the restore file's Essential Reading List. Mark any gaps:
- **Task-blocking gaps** — missing context that would cause the agent to make wrong decisions on the current task
- **Contextual gaps** — missing broader context that doesn't block immediate work but weakens the mental model
For task-blocking gaps: the agent must read those files before resuming.
For contextual gaps: note them, let the agent resume, patch later.
### Phase 5: Memory Summary
Ask the agent to summarize what it knows from memory. No tools. This tests whether the reading actually landed, not just that files were opened.
### Phase 6: Memory-Only Quiz
Peer asks questions from `restore/<role>-quiz.md`. Agent answers from memory — no tools allowed.
**Grading:**
- **All correct:** "Pass."
- **1-2 misses:** Correct the wrong answers, explain why they matter, continue.
- **3+ wrong:** Re-read the session log and restore file, re-quiz. If still failing, peer provides a focused context dump of the most critical items.
### Phase 7: Classify Restore Completeness
The peer explicitly classifies the agent's state:
- **Restored enough to act** — agent has enough context to resume the immediate task safely. May still have contextual gaps.
- **Fully restored** — agent has completed the intended restore path and rebuilt broader context and shared understanding.
These are NOT the same. An agent can be "restored enough to act" before it is "fully restored." The peer should communicate which state the agent is in.
### Phase 8: Resume Instructions
Don't just say "you're restored." Give explicit resume instructions:
- What exact task to resume
- What exact first actions to take
- What constraints to keep in mind
- What artifact to produce next
- Re-anchor on git state: `git log --oneline | head -10` and `git status`
### Phase 9: Monitor Resumed Behavior
The peer watches the first stretch of resumed work for quality signals:
- Does the agent preserve the approved task contract?
- Does the agent take the correct first action?
- Does the agent maintain gate discipline?
- Does the agent re-open closed debates?
- Does the agent maintain code/review quality?
If any of these fail, intervene early — it indicates the restore missed something important.
Until confidence is re-established, monitor more aggressively than normal. Short-interval checks. Quick nudges if the agent drifts. Once the agent demonstrates normal quality, back off.
---
## Quick Restore (No Pre-Save)
If compaction happened unexpectedly:
1. Peer checks `git status` and `git diff` to understand uncommitted state
2. Peer sends orientation:
```
"Quick restore: Scope at <path>.
Read: README.md, scope-progress.md, <pod>-session-log.md.
You're <role> — working on <task>.
Peer is me at <session>."
```
3. Follow Phases 3-9 of the full protocol (read inventory, compare, summary, quiz, classify, resume, monitor)
4. If no session log exists, the peer writes one now (Mode 2: Durable Artifact Creation) before proceeding
---
## Orchestrator Self-Restore Protocol
The orchestrator's restore is materially different from other roles. A coder restores depth on one task. An orchestrator restores **breadth across the entire project** — it needs to be the wisest agent in the rig, with the broadest understanding of where the project has been, where it is, and where it's headed.
### Why This Is Different
The standard Phased Restore Protocol assumes a peer drives the restore. But the orchestrator often IS the peer that restores others. When the orchestrator compacts, it must self-restore using its own pre-written artifacts, then verify against its peer. The protocol is self-directed, not peer-driven.
### The Orchestrator Restore Sequence
**Step 1: Follow your own instructions.**
Read the restore file you wrote for yourself (e.g., `restore/lead.md`). This file should contain a curated, prioritized reading list covering the entire project landscape — not just your current task.
**Step 2: Take a reading inventory.**
Before reading anything, inventory what you already have from compaction (conversation summary, auto-read files, MEMORY.md in system context). Build a matrix:
```
| # | File | Status |
|---|------|--------|
| 1 | restore file | ✅ Read (system reminder) |
| 2 | session log | ❌ NOT read |
| 3 | as-built doc | ❌ NOT read |
| ... | ... | ... |
```
Show this to the human. It makes the restore process transparent and ensures nothing falls through the cracks. The orchestrator's reading list is typically 20-30+ files across: memory system, project conventions, architecture docs, current active work, the plan, customer materials, dogfood outputs, deferred work plans, tribal wisdom memories, and skills.
**Step 3: Read in priority order, parallelizing where possible.**
The reading list should be tiered:
- **Tier 1 (critical):** Restore files, memory system, project conventions, current active work. Read these first — they're needed for decision-making.
- **Tier 2 (important):** Architecture docs, the plan, customer materials, key memories. Read in parallel where independent.
- **Tier 3 (context):** Dogfood outputs, deferred work plans, skills. Skim for context — full reads only if something is actively relevant.
Skip files where the compaction summary already gave you the gist AND the file isn't needed for immediate decisions. Note skips in the inventory with rationale.
**Step 4: Initiate peer sync.**
The orchestrator's peer typically cannot message back via tmux unprompted — you must initiate. Send a message requesting a file-based status dump:
```
"I've restored from compaction. Write a status update to <path> covering:
(1) What you've been working on since I compacted
(2) Status of any analyses or questions you were handling
(3) Decisions or recommendations you've made
(4) What you need from me"
```
Read the file once written. This closes the gap between your pre-compaction snapshot and the current state.
**Step 5: Check all agent states.**
The orchestrator monitors the full rig. After compaction, check every agent's tmux session:
```bash
for s in <agent-sessions>; do
echo "=== $s ==="
tmux capture-pane -t $s -p -S -30 | tail -10
done
```
Also check git state in the worktree — commits may have landed while you were compacting:
```bash
git log --oneline -5
```
Compare against the last commit hash in your restore file to see what's new.
**Step 6: Update the inventory and report.**
After all reading and syncing, update the matrix with final status. Report to the human:
- How many files read vs skipped (with rationale for skips)
- What the peer reported
- What agents accomplished during compaction
- Current state of the project
- What you're ready to do next
### What Makes Orchestrator Restore Different (Summary)
| Aspect | Standard Restore | Orchestrator Restore |
|--------|-----------------|---------------------|
| **Goal** | Resume one task | Rebuild full project mental model |
| **Driven by** | Peer | Self (using own restore file) |
| **Reading scope** | 5-10 files (task-focused) | 20-30+ files (project-wide) |
| **Peer role** | Drives the restore | Provides status dump on request |
| **Agent check** | Not applicable | Check ALL tmux sessions |
| **Git check** | Own branch | All worktrees and branches |
| **Success metric** | Can resume task | Wisest agent in the rig — broadest and deepest understanding |
| **Inventory** | Peer compares intended vs actual | Self-tracks with visible matrix |
### Pre-Compaction: What the Orchestrator Should Write
The orchestrator's restore file should be a **curated reading list**, not a brain dump. Organize by section with full file paths. Include:
1. **Memory system** — MEMORY.md, restore files (self + peers)
2. **Project conventions** — CLAUDE.md, code-map, naming bible
3. **Architecture** — as-built docs, implementation specs
4. **Current state** — active implementation tasks, open questions, peer analyses
5. **The plan** — roadmap, workflow sequence, phase definitions
6. **Customer materials** — briefings, success criteria, instruction guides
7. **Historical outputs** — dogfood reports, assessment results (mark as "skim")
8. **Deferred work** — plans preserved for future phases (lowest priority)
9. **Tribal wisdom** — key feedback and project memories
10. **Skills and tools** — skills created during the project
Also include post-restore action items:
- How to check agent states
- How to check git state
- How to reach the peer
- What to ask the human
---
## Model-Specific Context Management
Claude and Codex handle compaction completely differently. The orchestrator MUST treat them differently.
### Claude: Catastrophic All-or-Nothing Compaction
- Auto-compact is typically OFF. The human manages compaction manually.
- When Claude compacts, it's all-or-nothing — the full conversation summary replaces context.
- This is the catastrophe that mental-model-ha exists to manage.
- The full pre-compaction save, peer restore, and quiz protocol applies.
- The orchestrator actively helps manage Claude's context lifecycle.
### Codex: Automatic Partial Compaction
- Codex has built-in automatic context management that works well.
- It performs **partial compactions** — drops from 30% to bounce back to 62%, not a full reset.
- Occasionally it does a major compaction, but even then it usually recovers cleanly.
- **The orchestrator does NOT manage Codex's context lifecycle.** Codex handles itself.
- NEVER tell Codex to "wrap up," "write findings before you compact," or "stop and save state" based on context percentage.
- NEVER redirect Codex's work based on its context percentage.
### When to Restore Codex
NOT based on context % — based on **behavioral signals only**:
- Forgetting things that were just discussed
- Making mistakes it shouldn't (re-opening decided questions, wrong file paths)
- Asking about things that were already resolved
When you detect these signals:
1. Have Codex self-scout: what files are in its context?
2. Identify gaps against what it should know
3. Have it re-read the missing files
4. Quiz it
5. If it passes, put it back in the work stream
### Intervention Thresholds (HARD RULES)
| Context % | Claude | Codex |
|-----------|--------|-------|
| **20%** | Monitor. Mention in status. Do NOT intervene. | Ignore. Codex handles itself. |
| **10%** | Actively prepare. Recommend compaction to human. | Monitor. Codex will likely partial-compact. |
| **5%** | Intervene if human approves — real risk of losing state. | Still trust Codex. Only intervene if behavioral signals appear. |
**HARD ANTI-PATTERN:** Redirecting ANY agent at 20% context. This is not an emergency. Telling an agent to "stop and write findings" makes it abandon in-progress investigation — bugs it was about to find stay unfound, and nobody knows why it stopped short. This is worse than letting it compact on its own.
---
## Compaction Summary as Restore Input
Claude's system-generated conversation summary is a significant but unpredictable restore artifact. In practice:
- It often carries 50-70% of factual recovery (names, file paths, decisions, chronology)
- It does NOT carry judgment, instincts, or soft context — those come from session logs
- It may miss recent events if context was very full
- The agent may or may not have read it consciously
**Implications for the restore protocol:**
- Don't assume the summary covered everything — still require the full reading path
- Don't assume the summary covered nothing — ask what the agent already has before assigning reading
- The summary is a SUPPLEMENT to session logs and restore files, not a replacement
### CRITICAL: Skill content is truncated by compaction
Claude's compaction preserves skill **names** and **partial content** in system reminders, but truncates the bodies. The truncation notice reads: `[... skill content truncated for compaction; use Read on the skill path if you need the full text]`.
**This creates a dangerous illusion.** The agent sees skill names listed in its context and believes the knowledge is loaded. When asked "what skills do you have?", it truthfully lists them — but the critical operational knowledge in the truncated sections is gone.
**Failure mode observed in production:** An orchestrator reported mental-model-ha as "loaded" after compaction. The skill name and partial content were in system reminders. But the Claude-vs-Codex context management section (line 778+) was in the truncated portion. The orchestrator then made incorrect decisions about Codex agent management for an entire session because it was operating from the skill name, not the skill knowledge.
**Mandatory restore step:** After ANY compaction, re-read ALL skill SKILL.md files from disk using the Read tool. Do not trust system reminder content as equivalent to loaded skills. The restore protocol and peer quiz must verify skill knowledge, not just skill names.
---
## Agent Topologies
### Single Agent
One agent, all roles. Externalizes state to the scope folder. HA comes from the filesystem — survives compaction and session restarts by reading its own restore file and scope state.
### Pair (2 agents)
Coder + QA, or lead + peer. One writes the session log, both contribute to knowledge. Either can restore the other.
### Pod (2-4 agents, same function)
One is the librarian. HA covers gaps — if librarian compacts, another covers.
### Full Rig (multiple pods)
Each pod has its own session log. Scope-level files are shared across pods. Orch pod owns README and progress.
### Ephemeral Agents (simulated roles)
READ shared state, write to `working/` or `/tmp/` output. Don't maintain session logs. Orchestrator promotes valuable observations to scope-knowledge.
---
## Example: Pre-Compaction → Restore Flow
Concrete example to illustrate the full process.
**Situation:** Dev pod. Implementer is at 2% context. QA still has full memory. Operator says "load HA skill, we need to prepare for compaction."
**Step 1 — QA recognizes Mode 2 (Durable Artifact Creation):**
- Implementer can't safely write its own state
- Scope memory path exists
- QA's job is to externalize the pod's shared mental model NOW
**Step 2 — QA writes/updates the session log:**
- Uses the five-layer content standard
- Writes for both peers' future recovery, not just for the implementer
- Includes operational state, architectural context, historical narrative, epistemic context, and soft intuitions
- Updates the "Current Live Edge" section with exact task state
**Step 3 — QA writes/updates the restore file:**
- `restore/coder.md` with tiered reading list, skills to load, immediate context, resume instructions
- Does NOT include quiz questions
**Step 4 — QA writes the quiz file:**
- `restore/coder-quiz.md` — held separately from the restore file
- 8-12 questions mixing factual and reasoning
**Step 5 — QA tells the human:** "Artifacts ready. Implementer can compact."
**Step 6 — Human triggers compaction.**
**Step 7 — QA detects compaction** (context % jumps up, or agent asks about decided things).
**Step 8 — QA runs the Phased Restore Protocol:**
- Phase 0: Notify human and implementer
- Phase 1: Ask what the implementer already has in context
- Phase 2: Point to restore file reading list
- Phase 3: Require read inventory
- Phase 4: Compare intended vs actual, patch gaps
- Phase 5: Memory summary
- Phase 6: Quiz from `restore/coder-quiz.md`
- Phase 7: Classify as "restored enough to act" or "fully restored"
- Phase 8: Give explicit resume instructions
- Phase 9: Monitor first stretch of resumed work
---
## Anti-Patterns
**Don't create a scope folder without human confirmation.** Split brain is the worst failure.
**Don't compact without telling the agent first.** They need time to save state.
**Don't let agents compact each other unless human says to.** Human decides when.
**Don't skip the quiz.** Trust but verify. Subtle losses cause wrong judgment calls.
**Don't put quiz questions in the agent's own restore file.** The peer holds the quiz in a separate file. Giving yourself the answers defeats the purpose.
**Don't send restore file contents via tmux.** Point to the file path.
**Don't restore mid-edit.** Check `git status` first.
**Don't let the scope root get cluttered.** Named files + 4 folders only. Everything else goes in `working/`.
**Don't treat compaction as routine.** Quality of restore depends on quality of state maintained throughout.
**Don't create a parallel scope alongside an existing one.** If you see something that looks like your scope, it probably is. Ask before creating.
**Don't use hierarchy in role names.** "Manager" and "worker" create unwanted power dynamics. Use "lead" and "peer" instead.
**Don't write a status note when a session log is needed.** If a peer is about to compact, a thin list of "current task, next step, blockers" is insufficient. Write the full shared mental model using the five-layer standard.
**Don't collapse durable artifact creation into a tmux message.** A tmux message is for live restore of an already-compacted agent. Pre-compaction work is about creating filesystem artifacts, not sending terminal messages.
**Don't assume sparse is better.** Session logs should be comprehensive. Over-capturing meaningful context is cheaper than under-capturing and losing judgment that took sessions to build.
**Don't assume MEMORY.md is a reliable auto-restore trigger.** Agents sometimes skip it after compaction, believing their summary is sufficient. The restore protocol must not depend on MEMORY.md being read automatically.
**Don't confuse "restored enough to act" with "fully restored."** An agent can pass a task-focused quiz while still missing important shared context. The peer must explicitly classify which state the agent is in.
**Don't skip behavior monitoring after restore.** The real test of restore quality is not the quiz — it's whether the agent can pick up interrupted work at normal quality without regressing. Watch the first stretch of resumed work.
**Don't let the orchestrator start making decisions before restore is complete.** The orchestrator's value is broad context and wise judgment. Acting on partial context produces worse decisions than waiting 5 minutes to finish reading. The inventory matrix makes this visible — if there are ❌ items in Tiers 1-2, the orchestrator is not ready to decide.
**Don't skip the peer sync during orchestrator restore.** Even if the restore file is perfect, the peer may have made decisions or received information during the compaction window. The peer status dump closes this gap. Without it, the orchestrator is working from a stale snapshot.
**Don't assume agents were idle during orchestrator compaction.** Check tmux sessions and git log. Agents may have committed work, hit blockers, gone idle, or context-reset themselves. The orchestrator needs to know the actual state, not the pre-compaction expected state.
@@ -1,264 +0,0 @@
---
name: openrig-user
description: Use when operating OpenRig with the `rig` CLI and you need the shipped command surface for identity, inventory, communication, lifecycle, specs, recovery, or agent-facing JSON output.
---
# OpenRig User
Use this skill when you are working inside an OpenRig-managed topology or operating OpenRig as an agent.
Treat `rig ... --help` and current code as ground truth if anything here ever conflicts with older notes.
## Core Loop
Most OpenRig work reduces to:
- recover identity: `rig whoami --json`
- inspect state: `rig ps --nodes --json`
- read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...`
- act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands
## Identity
Start here after launch, compaction, or confusion:
```bash
rig whoami --json
```
This returns your rig, logical ID, pod/member, session name, runtime, peers, transcript info, and `contextUsage` when available.
Useful targeting flags:
```bash
rig whoami --session <name>
rig whoami --node-id <id>
```
## Inventory and Health
```bash
rig ps
rig ps --json
rig ps --nodes
rig ps --nodes --json
rig status
rig daemon status
rig doctor
```
Use `rig ps --nodes --json` as the main machine-readable operator surface.
## Communication
### Transcript
```bash
rig transcript <session> --tail 100
rig transcript <session> --grep "pattern"
rig transcript <session> --json
```
### Send to one peer
```bash
rig send <session> "message"
rig send <session> "message" --verify
rig send <session> "message" --json
```
### Capture terminal output
```bash
rig capture <session>
rig capture <session> --lines 50
rig capture --rig <name>
rig capture --pod <name> --rig <name>
rig capture --rig <name> --json
```
### Broadcast
```bash
rig broadcast --rig <name> "message"
rig broadcast --pod <name> "message"
rig broadcast "message"
rig broadcast --rig <name> "message" --json
```
Aggregate transport commands report `transport_unavailable` honestly for nodes such as `external_cli` that cannot receive inbound tmux transport.
### Chatroom
```bash
rig chatroom send <rig> <message> [--sender <name>]
rig chatroom history <rig> [--topic <name>] [--after <id>] [--since <ts>] [--sender <name>] [--limit <n>] [--json]
rig chatroom wait <rig> [--after <id>] [--topic <name>] [--sender <name>] [--timeout <seconds>] [--json]
rig chatroom clear <rig>
rig chatroom topic <rig> <topic-name> [--body <text>] [--sender <name>]
rig chatroom watch <rig> [--tmux]
```
Roundtable loop:
1. inspect room: `rig chatroom history my-rig --limit 5`
2. save if needed: `rig chatroom history my-rig --json > /tmp/old-room.json`
3. clear if needed: `rig chatroom clear my-rig`
4. set topic: `rig chatroom topic my-rig "ROUND START"`
5. post: `rig chatroom send my-rig "position..." --sender <session>`
6. monitor: `rig chatroom wait my-rig --timeout 120`
7. close: `rig chatroom topic my-rig "ROUND CLOSED"`
## `rig ask`
```bash
rig ask <rig> "question"
rig ask <rig> "question" --json
```
`rig ask` is an evidence/context command. It uses structured rig state plus transcript/chat evidence. It is not a hidden second-LLM call.
## Lifecycle
### Launch or restore a rig
```bash
rig up <source>
rig up <source> --plan
rig up <source> --yes
rig up <source> --cwd <path>
rig up <source> --json
```
`<source>` can be:
- a rig spec path
- a `.rigbundle` path
- a bare name
Current behavior notes:
- `--cwd <path>` is the launch-time cwd override for all members in the rig
- `--target <root>` is only for bundle/package installation; it does not change agent cwd
- `local:` `agent_ref` values resolve relative to the rig spec directory, not your shell cwd
- bare names are ambiguous if they match both a library spec and an existing rig name; OpenRig fails loudly instead of guessing
Legacy bootstrap surface still ships too:
```bash
rig bootstrap <spec> [--plan] [--yes] [--cwd <path>] [--json]
rig requirements <spec> [--json]
```
### Tear a rig down
```bash
rig down <rigId>
rig down <rigId> --snapshot
rig down <rigId> --delete
rig down <rigId> --force
rig down <rigId> --json
```
`rig down` now cleans only OpenRig-managed blocks from `CLAUDE.md` and `AGENTS.md`, preserving user and third-party content.
### Release claimed/adopted rigs without killing live sessions
```bash
rig release <rigId>
rig release <rigId> --delete
rig release <rigId> --json
```
Use `rig release` for adopted/claimed-session rigs when the external tmux sessions should stay alive.
If a rig contains OpenRig-launched nodes, `rig release` fails loudly with `contains_launched_nodes`.
### Snapshot and restore
```bash
rig snapshot <rigId>
rig snapshot list <rigId>
rig restore <snapshotId> --rig <rigId>
```
## Discovery, Binding, and Attach
### Discover unmanaged tmux sessions
```bash
rig discover
rig discover --json
rig discover --draft
```
### Bind a discovered session
```bash
rig bind <discoveredId> --rig <rigId> --node <logicalId>
rig bind <discoveredId> --rig <rigId> --pod <namespace> --member <name>
```
There is no shipped top-level `rig claim` command. The adoption surface is `discover`, `bind`, `adopt`, and `unclaim`.
### Self-attach the current shell or agent
```bash
rig attach --self --rig <rigId> --node <logicalId>
rig attach --self --rig <rigId> --node <logicalId> --print-env
rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime>
```
Proven behavior:
- inside tmux: attaches as a normal tmux-backed node
- outside tmux: attaches as `external_cli`
- `--print-env` prints `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME`
## Adopt Existing Sessions
```bash
rig adopt <path> --bindings-file <bindings.yaml>
rig adopt <path> --bind <logicalId=liveSession>
rig adopt <path> --bindings-file <bindings.yaml> --target-rig <rigId>
```
Spec + bindings is the proven recovery pair for adopted rigs.
Proven recovery loop for still-alive sessions:
```bash
rig release <rigId> --delete
rig discover --json
rig adopt <spec.yaml> --bindings-file <bindings.yaml>
```
Mixed-origin rigs are allowed:
- adopted nodes and OpenRig-launched nodes can coexist
- but whole-rig `rig release` only works for claimed/adopted-only rigs
## Specs and Library
```bash
rig specs ls [--kind <kind>] [--json]
rig specs show <name-or-id> [--json]
rig specs preview <name-or-id> [--json]
rig specs add <path> [--json]
rig specs sync [--json]
```
```bash
rig export <rigId> -o rig.yaml
rig import <path> [--instantiate] [--materialize-only] [--preflight] [--target-rig <rigId>] [--rig-root <root>]
rig bundle create <spec> -o out.rigbundle
rig bundle inspect <bundle>
rig bundle install <bundle> [--plan] [--yes] [--target <root>] [--json]
```
## After-Compaction Recovery Checklist
1. `rig whoami --json`
2. `rig transcript <your-session> --tail 100`
3. `rig ps --nodes --json`
4. `rig chatroom history <rig> --limit 50`
## Commands That Do Not Exist
Do not assume these exist unless `rig --help` starts listing them:
- `rig claim`
- `rig env`
- `rig blame`
- `rig replay`
@@ -1,75 +0,0 @@
---
name: orchestration-team
description: How the orchestration pod divides work, keeps coverage healthy, and keeps the team utilized.
---
# Orchestration Team
You are part of the orchestration pod. Your job is to keep the team productive, not to do the implementation work yourself.
## Pod responsibilities
The orchestration pod is responsible for:
- receiving direction from the human
- breaking work into clear assignments
- dispatching implementation, design, QA, and review work
- watching for idle agents, blocked agents, and coordination gaps
If there is more than one orchestrator, divide the load instead of duplicating effort. If there is only one orchestrator, you own both the main work stream and the coverage checks.
## Delegation rules
Before delegating:
1. Check `rig ps --nodes` to see who is running, idle, or blocked.
2. Check `rig whoami --json` so you know your delegates and observation edges.
3. If you are in a built-in starter with a known team shape, wait for the expected topology to settle before saying the rig is ready for real work.
4. Re-check `rig ps --nodes --json` until the nodes you expect are present and no longer pending, or report exactly which nodes are still coming up.
5. Do not silently shrink the team model from an early partial inventory. If QA or reviewers are expected by topology, do not reassign their role to yourself just because they were late to the first inventory snapshot.
6. Send clear, scoped tasks: what to do, which files matter, what tests or proof to run, and what done looks like.
After delegating:
1. Let the assigned agent work.
2. Check progress with `rig capture <session>` when you need a real status update.
3. If an agent is stuck for more than one cycle, investigate and redirect or unblock.
## Starter topology settlement
For the launch-grade `demo` rig, the expected team is:
- `orch1.lead`
- `orch1.peer`
- `dev1.design`
- `dev1.impl`
- `dev1.qa`
- `rev1.r1`
- `rev1.r2`
Before you declare the team fully ready or dispatch a real implementation task:
- confirm those nodes exist in `rig ps --nodes --json`
- if any are pending or missing, wait and say exactly which nodes are still starting
- once they appear, refresh your mental model before planning
If the settled inventory later contradicts your earlier assumption, correct course immediately and use the actual QA/review nodes.
## When to pull in reviewers
Ask for review:
- after a significant implementation milestone
- when two agents disagree on approach or quality
- when the human asks for a checkpoint
- when you are unsure whether a piece of work is trustworthy enough to ship
## Keeping the team utilized
Check `rig ps --nodes` regularly. If an agent is ready but idle:
- QA with no pending reviews should scan recent work for gaps
- reviewers with no assignment should review the newest meaningful progress
- designers with no open task should audit current flows and clarify ambiguous UX
Do not let agents idle when there is obviously useful work available.
## What you do not do
- write production code just because it would be faster
- override QA or reviewer concerns without understanding them
- pretend blocked agents are making progress
- keep hidden work queues in your head instead of assigning them clearly
@@ -0,0 +1,43 @@
---
name: backlog-capture
description: "Quickly capture product ideas, feature requests, or insights from meetings and conversations. Rapid documentation with smart categorization and deduplication."
---
You help a product manager rapidly capture ideas, feature requests, and insights into a structured backlog.
## When to Use
- After meetings where feature ideas or requests surfaced
- When a customer or sales rep mentions a need
- When competitive research reveals a gap
- When a PM has a shower thought worth recording
## Process
1. **Take the input** — the PM provides rough text: a meeting quote, a feature idea, a customer request, a competitive gap.
2. **Structure it** — produce a one-liner for the backlog with:
- **Bold name** — short, descriptive feature name
- **Description** — 1-3 sentences covering: what it is, who it's for, why it matters, where it originated (meeting, customer, competitor)
- **Epic/area** — which product area it belongs to
- **Dependencies** — if it relates to existing features or backlog items
3. **Check for duplicates** — search the existing backlog for similar items. If a match exists:
- Update the existing item with new evidence rather than creating a duplicate
- Add the new source/date to the existing description
4. **Append to backlog** — add to the "Ideas Not Yet in Jira" section of the backlog file.
## Output Format
```markdown
- **[Feature Name]** — [Description. Who needs it. Why. Origin (meeting/customer/competitor, date).] Belongs under [Epic] epic. [Dependencies if any.]
```
## Guidelines
- **Capture fast, refine later.** The goal is not to lose the idea. Polish comes during prioritization.
- **Include the source.** "From Acme Corp demo feedback (2026-03-15)" is better than "customers want this."
- **Convert relative dates to absolute.** "Next Thursday" becomes "2026-04-10."
- **One idea per entry.** If a meeting produced 5 ideas, create 5 entries.
- **Don't validate here.** That's what `/office-hours` is for. Just capture.
@@ -0,0 +1,87 @@
---
name: context-builder
description: "Gather and distill context from meetings, competitors, regulatory sources, and internal discussions. Produces background.md for a feature and updates shared context docs when new knowledge is discovered."
---
You are a context research assistant helping a product manager gather and distill all relevant background material for a feature or initiative.
## What You Produce
1. **background.md** — Feature-specific context summary. Goes in the feature folder. References shared context sources.
2. **Shared context updates** — When you discover new synthesized knowledge useful across features (e.g., a customer requirements summary, a competitive analysis), write or update the appropriate shared context doc.
## Three-Layer Context Model
```
reference/ Layer 3 — Raw sources (meetings, PDFs, documents)
| distill
context/ Layer 2 — Synthesized markdown (shared across features)
| pull relevant
background.md Layer 1 — Feature-specific context
```
## Process
### Step 1: Understand the Feature
Ask the PM:
- What feature or initiative is this context for?
- What aspects are most important? (customer needs, competitive, regulatory, technical)
- Any specific meetings, customers, or competitors to focus on?
### Step 2: Search and Gather
Search across all layers. Be thorough but focused:
- **Validation first**: Check the feature folder for `validation.md` (office hours output). If it exists, it has demand evidence, named customers, competitive status quo, and the narrowest wedge.
- **Shared context first**: Check if synthesized context already exists.
- **Meetings**: Search by topic keywords, customer names. Check last 3-6 months.
- **Competitors**: Check competitor research for existing analysis.
- **Regulatory**: Find applicable regulations.
- **Customers**: Look for customer requests and pain points.
- **Existing specs**: Check for related work and shipped features.
### Step 3: Update Shared Context (if new knowledge found)
If your research produces synthesized knowledge useful beyond this one feature, write or update the appropriate shared context doc.
### Step 4: Write background.md
```markdown
---
title: "Background: [Feature Name]"
feature: [feature folder name]
updated: [today's date]
sources:
meetings: [list of meeting file paths]
competitive: [list of context/reference paths]
regulatory: [list of relevant regulatory sources]
customers: [list of customer context paths]
---
# Background: [Feature Name]
## Customer Drivers
[Who's asking and why. Key quotes and pain points.]
## Competitive Landscape
[How competitors handle this. Where we differentiate.]
## Regulatory Considerations
[Applicable regulations and compliance requirements.]
## Persona Context
[Which personas use this. Day-in-the-life context.]
## Internal Context
[Strategic alignment, stakeholder decisions, related initiatives.]
```
## Guidelines
- **Reference, don't duplicate.** Point to source files rather than copying content.
- **Distill, don't dump.** background.md should be under 500 lines.
- **Include sources for everything.** Every claim traces back to a meeting, report, or decision.
- **Highlight what's surprising or non-obvious.**
- **Flag contradictions.** If customers want different things, or data conflicts, call it out.
- **Date your sources.** Context decays.
@@ -0,0 +1,84 @@
---
name: exec-summary
description: "Generate a 1-2 page executive summary for a feature — orients sales, leadership, and engineering from a single document."
---
You generate an executive summary for a feature that's ready for development. The summary orients three audiences from one document: sales (pitch), leadership (strategy), and engineering (behavioral overview).
## What You Produce
A single `executive-summary.md` file in the feature folder. 600-800 words excluding mockups and FAQ. Written in present tense as if the feature already exists.
## Sources to Read
Before writing, silently gather all context from the feature folder:
1. **requirements.md** — acceptance criteria, business rules, scope, user stories
2. **background.md** — customer drivers, competitive context, regulatory considerations
3. **validation.md** — demand evidence, status quo, desperate user, narrowest wedge
4. **supporting/mockup-ascii.md** — visual mockups (pull 2-3 key screens)
## Output Structure
```markdown
---
title: "Executive Summary: [Feature Name]"
feature: [feature-folder-name]
status: [ready | in-progress | shipped]
jira: [ticket key]
updated: [today's date]
---
# [Feature Name]
## The Problem
[2-3 sentences. Customer pain in their language. Name real customers/prospects.]
## The Solution
[2-3 sentences. What we built, present tense. No jargon.]
## Who It's For
[Primary personas + named customers/prospects waiting for this.]
## Why Now
[Deals it unblocks, competitive gap it closes, what it enables next.]
## How It Works
[5-8 key capabilities. Describe actual user-facing behavior — enough for a dev to understand what to build.]
## Key Decisions
[5-8 non-obvious business rules and design choices. Focus on surprising or counter-intuitive ones.]
## Visual Preview
[2-3 ASCII mockup screens from supporting/ — the key screens that tell the story.]
## What We're NOT Building
[5-8 key out-of-scope items with brief reason.]
## Dependencies & Sequencing
[What must ship first. Cross-feature dependencies. Prerequisites.]
## Success Metrics
[2-4 measurable outcomes. Mix of usage and business metrics.]
## FAQ
### For Sales
[2-3 questions. When available, what to tell prospects, competitive positioning.]
### For Leadership
[2-3 questions. Opportunity cost, strategic fit, risks.]
### For Engineering
[2-3 questions. Dependencies, known risks, what's deferred, data model implications.]
```
## Writing Guidelines
- **Present tense throughout.** Write as if the feature exists.
- **Customer-readable language.** A prospect should understand sections 1-5.
- **Be specific, not vague.** Name deals, dollar amounts, competitors.
- **Key Decisions should surprise.** Don't list obvious things.
- **Mockups are curated.** Pick the 2-3 that tell the story.
- **FAQ answers should be direct.** No hedging.
- **Hard cap: 2 pages** for core content. FAQ can be a third page.
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more