diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b64e7b1..d1bbc05 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ { "name": "contextrelay", "description": "ContextRelay bridge for Claude Code and Codex through a shared daemon, push channel delivery, durable queueing, and reply/get_messages/wait_for_messages tools.", - "version": "1.1.4", + "version": "1.2.0", "author": { "name": "ProofOfWork / Danillo Felix", "email": "danillo@proofofwork.agency" diff --git a/.gitignore b/.gitignore index bff864a..36f438d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ codex-plugin-cc/ /.mcp.json .contextrelay/config.json .contextrelay/current +.contextrelay/rooms.json +.contextrelay/sessions.json .contextrelay/sessions/ .contextrelay/*.lock .contextrelay/state/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0718b50..8020620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.2.0] — 2026-05-20 + +### Added +- Added mesh-worker startup prompts so `ctxrelay mesh start` launches default + and named Codex/Claude lanes with an explicit first task instead of relying + on project instructions alone. +- Added state-scoped room and runtime-session registries for port-base and + named-runtime launches (`.contextrelay/state/instances/port-/`), while + retaining legacy registry helpers for compatibility. +- Added strict live daemon identity matching for port-base status reuse, using + daemon identity, pid, instance id, control port, project root, and state dir. + +### Changed +- Changed Claude channel arguments to `--channels=` and + `--dangerously-load-development-channels=` so variadic channel flags + cannot consume the mesh startup prompt. +- Defaulted mesh Claude launches to approved-channel loading when + `CONTEXTRELAY_ENABLE_MESH=1` is set, without changing non-mesh launch + behavior. +- Allowed named runtime sessions to auto-enable for read-only mesh lanes, so + read-only workers can start without requiring + `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. +- Allowed read-only mesh lanes to share a worktree with another active runtime + while preserving the existing conflict guard for write-capable lanes. + +### Fixed +- Fixed mesh bootstrap flows that could leave workers attached but idle by + making startup work explicit and preserving prompt quoting through the + instance-env shell wrapper. +- Fixed state reuse for alternate port-base launches so live status adoption + cannot accidentally bind to a foreign daemon or project state directory. + ## [1.1.4] — 2026-05-19 ### Added diff --git a/README.md b/README.md index 87a990e..5dfddf5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Agents share ledger entries and messages, not hidden reasoning. | Dependency | Version | Install | |------------|---------|---------| | [Bun](https://bun.sh) | v1.0+ | `curl -fsSL https://bun.sh/install \| bash` | +| [tmux](https://github.com/tmux/tmux/wiki) | latest | `brew install tmux`, `sudo apt-get install tmux`, or WSL package manager | | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | v2.1.80+ | `npm install -g @anthropic-ai/claude-code` | | [Codex CLI](https://github.com/openai/codex) | latest | `npm install -g @openai/codex` | @@ -166,10 +167,61 @@ CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay claude --session review CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay codex --session review ``` -`ctxrelay status --json`, the TUI Runtime Sessions panel, and `ctxrelay session list` show each runtime session's operation path. The stored "worktree" is that folder of operation. It is usually a git checkout or git worktree, but ContextRelay only remembers and validates the path; it does not create git worktrees. Use separate folders for sessions that can write files. +`ctxrelay status --json`, the TUI Runtime Sessions panel, and `ctxrelay session list` show each runtime session's operation path. The stored "worktree" is that folder of operation. It is usually a git checkout or git worktree. ContextRelay remembers and validates the path, and the explicit `ctxrelay worktree` lifecycle can plan/create/status/remove git worktrees when you confirm that action. Use separate folders for sessions that can write files. ContextRelay prevents two launched named Codex runtimes from sharing the same bound folder; the compatibility `default` session is not part of that guard. Named runtime sessions isolate live Claude/Codex routing when opted in, but transcript ledger, viewer, backup, and finality state remain mostly global/default-biased until the next architecture line. +## Mesh Mode (Optional) + +Mesh mode adds an explicit room/lane layer above named runtime sessions. A room is the membership and authority boundary; a lane is one work stream inside that room. This lets a coordinator keep the main thread in control while assigning bounded work to a lane without turning ContextRelay into unrestricted peer routing. + +Mesh mode is off by default. Enable it once per workspace: + +```bash +ctxrelay mesh enable +ctxrelay mesh status +``` + +`ctxrelay mesh enable` writes `features.meshMode.enabled=true` to `.contextrelay/config.json`. Use `ctxrelay mesh disable` to turn the config gate back off. `CONTEXTRELAY_ENABLE_MESH=1` remains available as a temporary process override, but it is not required for normal local use. + +Minimal flow: + +```bash +ctxrelay mesh start review --codex-workers 1 --claude-workers 1 +``` + +The mesh start wizard creates the room, creates `codex-1` and `claude-1` lanes, +enters the default participants, opens worker terminals through tmux from the +correct lane worktree, and still prints worker commands for debugging. If tmux +is missing and stdin is interactive, the CLI asks before running the detected +installer (`-y` accepts automatically). The TUI asks before opening worker +terminals; if tmux is missing there, it asks before leaving the dashboard to run +the same installer in a normal shell. Use `--no-launch-terminals` in CLI scripts. +Writable wizard lanes require existing worktrees: + +```bash +ctxrelay mesh start review --codex-workers 2 --claude-workers 1 --writable --worktree-prefix ../repo- +``` + +Advanced room/lane primitives remain available: + +```bash +ctxrelay room create review --coordinator default:codex +ctxrelay lane create review frontend --worker default:claude --worktree ../repo-frontend +ctxrelay lane enter review frontend --participant default:claude +``` + +Room and lane IDs such as `review` and `frontend` are human-facing registry IDs. Transcript ledgers still use canonical `session__` IDs internally; friendly room/lane IDs never become transcript filenames. + +Limits in v1.2.0: + +- Mesh is single-host, local-daemon orchestration only. +- Routing is bounded coordinator-to-member room/lane work, not free peer mesh. +- Claude mesh control tools are intentionally narrowed until subagent and hook caller scope can be verified; use the CLI or Codex MCP mesh tools for room/lane control. +- Raw process-spawn denial is enforced only through ContextRelay-mediated permissions. Workers that bypass mediation can still spawn processes; routing-edge enforcement is a v1.2.1 hardening target. +- ContextRelay validates worktree paths for write-capable lanes. It never creates git worktrees implicitly; use `ctxrelay worktree plan` first and `ctxrelay worktree create --confirm` only when you want ContextRelay to run `git worktree add`. +- The native TUI can control rooms and lanes; Command Deck renders read-only room/lane panels with copyable CLI commands. Browser-side room/lane mutation remains out of scope. + ## Safety Model ContextRelay is local developer tooling, not a security boundary between tools you do not trust. It defaults to loopback-only endpoints, local auth tokens, browser origin rejection, autonomy off, auto-finality off, read-only backup agents, and coordinator-owned git-write policy. @@ -178,6 +230,8 @@ Provider-native Claude Code and Codex approval systems still apply inside those ## What's New +- v1.2.0 adds optional mesh mode behind `features.meshMode.enabled=true`, with JSON-backed rooms, lanes, active lane selection, route ledger events, and runtime-to-transcript mapping. +- v1.2.0 adds `ctxrelay mesh|room|lane` commands and Codex MCP mesh tools for room/lane orchestration. Claude-side mesh control stays narrowed in this slice until subagent/hook caller scope is enforceable. - `ctxrelay codex-mcp install|remove|status|server` manages Codex MCP registration explicitly. - Codex has typed MCP tools for live messages, handoffs, context reads, notes, artifacts, backup requests, status, and finality. - `ctxrelay coordinator claude|codex|human` rewrites coordinator policy in config and managed instruction blocks. @@ -222,6 +276,8 @@ Claude receives these tools from the ContextRelay Claude Code plugin. | `backup_status` | Return current backup-agent state and latest backup metadata. | none | | `propose_final` | Record a finality proposal, or an auto-final decision when enabled and unblocked. | `summary`, `evidence` required; optional `remaining_risk`, `handles_handoff_id`, `runtimeSessionId` | +Claude does not expose the v1.2.0 mesh control tool family by default. That is intentional: until ContextRelay can reliably distinguish parent Claude sessions from subagent or hook contexts, room/lane control stays on the CLI and Codex MCP side. + #### Codex MCP Tools Codex receives these tools after: @@ -248,6 +304,21 @@ ctxrelay codex-mcp install | `ask_claude_backup` | Ask a read-only headless Claude backup agent for analysis. Requires autonomy enabled. | `ask` required; optional `reason`, `context_refs`, `handles_handoff_id`, `runtimeSessionId` | | `backup_status` | Return current backup-agent state and latest backup metadata. | none | | `propose_final` | Record a Codex finality proposal. | `summary`, `evidence` required; optional `remaining_risk`, `handles_handoff_id`, `runtimeSessionId` | +| `mesh_status` | Inspect mesh-mode gate state. Requires mesh mode for room/lane actions. | none | +| `create_room` | Create a mesh room in `rooms.json`. Requires mesh mode enabled. | `id` required; optional `label`, `coordinatorParticipantId`, `reviewerParticipantId`, `pinnedContext` | +| `select_room` | Select the active room in the room registry. Requires mesh mode enabled. | `roomId` required | +| `room_info` | Inspect one room or the whole room registry. Requires mesh mode enabled. | optional `roomId` | +| `archive_room` | Archive a room and reject future routing to it. Requires mesh mode enabled. | `roomId` required | +| `add_room_member` | Add a participant to a room. Requires mesh mode enabled. | `roomId`, `participantId` required; optional `role` | +| `remove_room_member` | Remove a participant from a room. Requires mesh mode enabled. | `roomId`, `participantId` required | +| `assign_room_coordinator` | Assign the durable room coordinator. Requires mesh mode enabled. | `roomId`, `participantId` required | +| `create_lane` | Create a lane inside a room. Requires mesh mode enabled. | `roomId`, `laneId` required; optional `label`, `worker`, `worktreePath`, `permissions` | +| `enter_lane` | Set a participant's active lane. Requires mesh mode enabled. | `roomId`, `laneId` required; optional `participantId` | +| `lane_info` | Inspect lanes in a room. Requires mesh mode enabled. | `roomId` required; optional `laneId` | +| `complete_lane` | Mark a lane complete and record a heartbeat route event. Requires mesh mode enabled. | `roomId`, `laneId` required; optional `summary` | +| `archive_lane` | Archive a lane. Requires mesh mode enabled. | `roomId`, `laneId` required | +| `assign_participant_to_lane` | Assign a participant to a lane. Requires mesh mode enabled. | `roomId`, `laneId`, `participantId` required; optional `role`, `worktreePath`, `permissions` | +| `remove_participant_from_lane` | Remove a participant from a lane. Requires mesh mode enabled. | `roomId`, `laneId`, `participantId` required | For validation handoffs, use `handoff_to_claude` with `wait_for_reply: true`; the tool waits for Claude to post a handled ledger reply before returning or timing out. @@ -343,6 +414,10 @@ Use `contextrelay`, `context-relay`, or `ctxrelay`; all point to the same CLI. | `ctxrelay detach-claude` | Clear the active Claude foreground connection without killing Codex or the daemon. | | `ctxrelay status [--json]` | Print daemon, session, connection, ledger, task, autonomy, finality, and backup state. | | `ctxrelay session list [--archived] \| create [--label ] [--worktree ] \| select \| archive \| rebind [--worktree ] [--json]` | List, create, select, archive, and rebind registry-backed runtime sessions. `session create` and `session rebind` bind named sessions to the current worktree by default; use `--worktree ` to bind a different checkout. | +| `ctxrelay worktree plan\|create\|status\|remove` | Plan, explicitly create, inspect, and remove git worktrees for writable mesh lanes. Creation and removal require `--confirm`; dirty worktrees are refused. | +| `ctxrelay mesh status\|start --codex-workers --claude-workers \|enable\|disable` | Inspect or change the mesh gate, or start a room/lane wizard from the CLI. | +| `ctxrelay room list\|create \|view \|select \|archive \|add-member \|remove-member \|assign-coordinator ` | Manage JSON-backed mesh rooms when mesh mode is enabled. | +| `ctxrelay lane list \|create [--worker ] [--worktree ]\|view \|enter \|assign \|remove-participant \|complete \|archive ` | Manage lanes inside a mesh room when mesh mode is enabled. | | `ctxrelay recover [--json]` | Summarize crash recovery context, recent failures, interrupted commands, git status, and a resume prompt. | | `ctxrelay instances` | List known project instances and assigned ports. | | `ctxrelay viewer [--no-open]` | Open the local Command Deck for status, task lanes, artifacts, policy, and timeline. | @@ -352,9 +427,9 @@ Use `contextrelay`, `context-relay`, or `ctxrelay`; all point to the same CLI. | `ctxrelay release-gate [--json]` | Run build/check release readiness and record a `release_gate` artifact. | | `ctxrelay kill [--all|--session ]` | Stop the current project instance, every known instance with `--all`, or only one named session's Codex runtime with `--session `. | -The native TUI uses the full terminal window and keeps the common control-plane actions in one place. The top header shows bridge readiness. The Status panel shows transcript id, daemon pid, port group, ledger count, and queue depth. The Runtime Sessions panel lists named runtime sessions when present. The Agents panel shows Claude and Codex connection state and role. The Controls panel shows coordinator, autonomy, finality, readonly policy state, and token mode. The Activity panel shows current handoff state, agent attachment state, and a width-aware capped strip of recent redacted handoffs and blocked or failed runtime events. The live status bar above the footer summarizes readiness, uptime, relay state, agent state, and the latest activity. The footer lists hotkeys as `(r)efresh`, `(p)air`, `(v)iewer`, `(a)uto`, `(f)inal`, `(c)oord`, `(x)readonly`, `(t)token`, and `(q)uit`. +The native TUI uses the full terminal window and keeps the common control-plane actions in one place. The top header shows bridge readiness. The Status panel shows transcript id, daemon pid, port group, ledger count, and queue depth. The Runtime Sessions panel lists named runtime sessions when present. The Agents panel shows Claude and Codex connection state and role. The Controls panel shows coordinator, autonomy, finality, readonly policy state, and token mode. The Mesh Rooms panel shows mesh gate state, active rooms, lanes, coordinator assignments, owners, worktrees, and room/lane warnings when mesh state exists. The Activity panel shows current handoff state, agent attachment state, and a width-aware capped strip of recent redacted handoffs and blocked or failed runtime events. The live status bar above the footer summarizes readiness, uptime, relay state, agent state, and the latest activity. The footer lists hotkeys as `(r)efresh`, `(p)air`, `(v)iewer`, `(a)uto`, `(f)inal`, `(c)oord`, `(x)readonly`, `(t)oken`, `(m)esh wizard`, and `(q)uit`. -Use `p` to launch the Claude + Codex pair, `v` to open the browser Command Deck, `a` to toggle autonomy, `f` to toggle auto-finality, `c` to cycle the coordinator, `x` to toggle readonly permission mode, and `t` to switch token mode between `verbose` and `compact`. Use `v` or `ctxrelay viewer` for the full timeline. +Use `p` to launch the Claude + Codex pair, `v` to open the browser Command Deck, `a` to toggle autonomy, `f` to toggle auto-finality, `c` to cycle the coordinator, `x` to toggle readonly permission mode, and `t` to switch token mode between `verbose` and `compact`. Use `m` to start the mesh wizard, enable mesh when needed, choose worker counts and lane mode, and choose whether to open tmux worker terminals automatically. Advanced room/lane hotkeys remain available for debugging but are intentionally not part of the main footer. Use `v` or `ctxrelay viewer` for the full timeline and read-only mesh room/lane panels. Hook compaction controls the pending-message context injected by the Claude `UserPromptSubmit` hook. The default token mode is `verbose`, which preserves the existing five-preview hook output. `compact` is opt-in and uses one preview, 200 preview characters, and a 60-second dedupe window for identical rendered hook output. Advanced users can keep a mode preset but override individual values: @@ -444,6 +519,7 @@ Project-local state: .contextrelay/ ├── config.json ├── sessions.json +├── rooms.json ├── state/ │ ├── daemon.pid │ ├── daemon-identity @@ -501,6 +577,7 @@ Most users do not need to set these. The CLI exports project instance values aut | `CONTEXTRELAY_INSTANCE_ID` | generated | Stable project instance id exported by the CLI. | | `CONTEXTRELAY_PROJECT_ROOT` | current project root | Project root exported by the CLI. | | `CONTEXTRELAY_ALLOW_NAMED_SESSIONS` | unset | Set to `1` to enable opt-in named Codex runtime launch and non-default live routing. Session registry metadata is visible without this flag. | +| `CONTEXTRELAY_ENABLE_MESH` | unset | Optional process-local override for mesh mode. Normal local use should prefer `ctxrelay mesh enable`. | | `CONTEXTRELAY_NAMED_CODEX_RUNTIME_START_ATTEMPTS` | `3` | Maximum daemon-side launch attempts when allocating concrete ports for a named Codex runtime. | | `CONTEXTRELAY_MODE` | `push` via `ctxrelay claude`, `auto` otherwise | Message delivery mode: `push`, `pull`, or `auto`. | | `CONTEXTRELAY_RUNTIME_SESSION_ID` | unset | Runtime session attached by `ctxrelay claude --session `; replies default to this session when live multi-session is enabled. | @@ -545,7 +622,7 @@ Test harnesses use `CONTEXTRELAY_FAKE_DAEMON_LAUNCH_LOG`, `CONTEXTRELAY_FAKE_DAE ### Viewer And Metrics -`ctxrelay viewer` opens the Command Deck, a token-authenticated local browser view for the current project instance. It shows connection health, agent idle/busy/stale/offline state, task lanes, artifacts, policy, and a latest-first timeline. It cannot send messages, approve work, or mutate git state, but it can clear the current shared session history through its authenticated history endpoint. +`ctxrelay viewer` opens the Command Deck, a token-authenticated local browser view for the current project instance. It shows connection health, agent idle/busy/stale/offline state, task lanes, mesh rooms/lanes, artifacts, policy, and a latest-first timeline. It cannot send messages, approve work, mutate git state, or mutate room/lane state, but it can clear the current shared session history through its authenticated history endpoint. Authenticated local endpoints: @@ -647,7 +724,8 @@ ctxrelay release-gate --json - ContextRelay depends on Claude Code plugin/channel behavior and Codex app-server behavior. - One default Codex TUI and one default Claude foreground connection are expected per project instance unless named sessions are explicitly enabled. -- Named sessions are opt-in runtime pairs, not separate transcript ledgers or room/mesh orchestration. +- Named sessions are opt-in runtime pairs; they are still not a general security boundary or automatic worktree manager. +- Mesh mode is opt-in room/lane orchestration with bounded coordinator-worker DAGs, not unrestricted peer routing. - Backup agents are read-only and intended for analysis, not implementation. - ContextRelay is local developer tooling, not a security boundary between tools you do not trust. - Git writes should be handled only by the configured coordinator or the human. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5ddeac3..235ad75 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,14 +8,16 @@ For user-facing setup and command details, see [README.md](../README.md). For op ContextRelay is a local Claude Code + Codex coordination layer. It provides: -- A CLI for setup, launch, diagnostics, coordination policy, viewer, release checks, and shutdown. +- A CLI and native TUI for setup, launch, diagnostics, coordination policy, mesh room/lane control, viewer, release checks, and shutdown. - A local daemon with loopback-only control endpoints. - A Claude Code plugin that exposes Claude-side MCP tools and slash commands. - A Codex MCP server registered explicitly with `ctxrelay codex-mcp install`. - A Codex app-server proxy for the live Codex TUI bridge. - A shared session ledger in `.contextrelay/sessions/.jsonl`. +- Opt-in room/lane orchestration with bounded coordinator-worker DAGs behind + the mesh-mode gate. - A persistent Claude-bound message queue backed by SQLite. -- A local browser viewer for session state, task lanes, artifacts, policy, and timeline. It cannot send agent work, but it can clear the current shared session history through its authenticated history endpoint. +- A local browser viewer for session state, mesh rooms/lanes, task lanes, artifacts, policy, and timeline. It cannot send agent work or mutate room/lane state, but it can clear the current shared session history through its authenticated history endpoint. - Manual coordinator policy for git ownership: `claude`, `codex`, or `human`. - Explicit read-only backup-agent requests when autonomy is enabled. @@ -39,6 +41,10 @@ ctxrelay permissions status|readonly on|off|allow |deny ctxrelay detach-claude ctxrelay status [--json] ctxrelay session list [--archived] | create [--label ] [--worktree ] | select | archive | rebind [--worktree ] [--json] +ctxrelay worktree plan|create|status|remove +ctxrelay mesh status|start|enable|disable +ctxrelay room list|create|view|select|archive|add-member|remove-member|assign-coordinator +ctxrelay lane list|create|view|enter|assign|remove-participant|complete|archive ctxrelay recover [--json] ctxrelay instances ctxrelay viewer [--no-open] @@ -77,6 +83,10 @@ backup_status propose_final ``` +Claude-side mesh control tools are intentionally not exposed by default in +v1.2.0 because ContextRelay cannot yet verify whether a call came from a parent +Claude session, a subagent, or a hook. + ### Codex MCP Tools Codex receives these tools after `ctxrelay codex-mcp install`: @@ -98,6 +108,21 @@ record_artifact ask_claude_backup backup_status propose_final +mesh_status +create_room +select_room +room_info +archive_room +add_room_member +remove_room_member +assign_room_coordinator +create_lane +enter_lane +lane_info +complete_lane +archive_lane +assign_participant_to_lane +remove_participant_from_lane ``` ### Claude Slash Commands @@ -139,6 +164,16 @@ Claude Code -> Codex TUI ``` +Mesh-enabled mode adds a gated room/lane control layer above runtime sessions: + +```text +Human / coordinator + -> ContextRelay room + -> lane frontend -> runtime session(s) + -> lane review -> runtime session(s) + -> ContextRelay daemon +``` + The daemon currently owns several responsibilities: - Claude foreground attachment tracking. @@ -151,10 +186,10 @@ The daemon currently owns several responsibilities: - Runtime state and liveness reporting. This is a practical pair-oriented architecture with opt-in named runtime -sessions. v1.1 can launch independent named Claude+Codex pairs inside one -daemon when `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`, but transcript ledger, -viewer, backup, and finality state remain mostly global/default-biased. It is -not yet a general room/lane router or unrestricted mesh. +sessions and optional room/lane orchestration. v1.1 can launch independent +named Claude+Codex pairs inside one daemon when +`CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. v1.2.0 adds gated room/lane registry and +control surfaces, but unrestricted peer mesh remains out of scope. ## Implemented Messaging Behavior @@ -189,6 +224,7 @@ Project state lives under `.contextrelay/`: ```text .contextrelay/config.json .contextrelay/sessions.json +.contextrelay/rooms.json .contextrelay/state/ .contextrelay/current .contextrelay/sessions/.jsonl @@ -203,6 +239,7 @@ The shared ledger stores: - handoffs - artifacts - runtime events +- route decisions - backup requests and results - errors - finality proposals and finality decisions @@ -235,8 +272,9 @@ Known limits in the current implementation: - Named Claude+Codex runtime pairs are opt-in and require `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. - The daemon is still tightly coupled to Codex lifecycle/proxy management. -- General room/lane routing, peer mesh, and arbitrary multi-agent routing are - not implemented. +- Room/lane routing is opt-in, single-host, and bounded to coordinator-worker + DAGs. Unrestricted peer mesh and arbitrary multi-host routing are out of + scope. - Named runtime routing does not yet provide fully isolated transcript ledger, viewer, backup, or finality state. - Third-agent adapters and API-backed reviewer workers are not implemented. @@ -268,10 +306,12 @@ Improve the implemented architecture without changing the mental model: ### Architecture Direction -The current daemon works for the default Claude + Codex pair plus opt-in named runtime pairs. If ContextRelay grows beyond that into general room/lane routing, the likely architecture is `Instance -> Session(s) -> Participants`: +The current daemon works for the default Claude + Codex pair, opt-in named +runtime pairs, and gated room/lane orchestration. The architecture direction is +`Instance -> Session(s) -> Participants -> Room/Lane scopes`: - Move toward explicit agent identity instead of singleton Claude/Codex assumptions. -- Add session-scoped routing before adding additional agent types. +- Keep room/lane routing bounded before adding additional agent types. - Separate runtime adapters from daemon routing responsibilities. - Keep runtime-specific behavior in adapters, not in the router. - Make delivery acknowledgments, retry behavior, and offline queue semantics explicit. @@ -315,3 +355,4 @@ Before publishing or tagging a release: 6. Run `ctxrelay release-gate --json` when a ContextRelay session is active. 7. Verify README and this roadmap list every public CLI command and MCP tool. 8. Remove stale future-tense claims unless they are explicitly marked as planned or open. +9. Verify `ctxrelay mesh status` reports the expected enabled/disabled state for the release configuration. diff --git a/docs/SESSION-LIFECYCLE.md b/docs/SESSION-LIFECYCLE.md index 5225666..90ce29a 100644 --- a/docs/SESSION-LIFECYCLE.md +++ b/docs/SESSION-LIFECYCLE.md @@ -2,8 +2,9 @@ This document defines ContextRelay's runtime-session lifecycle. v1.1.4 implements registry-backed named sessions plus explicit first-use named Codex/Claude runtime routing -behind `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. General rooms, lanes, mesh -routing, and fully isolated ledger/finality/viewer state remain future work. +behind `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. v1.2.0 adds optional room/lane +orchestration behind the mesh-mode gate. Fully isolated viewer, backup, and +finality state remain future work. ## Model @@ -172,9 +173,9 @@ Rules: - Target finality is per session: one session can be complete while another remains active. -Open question for implementation: whether the existing `.contextrelay/current` -file remains the active ledger pointer or becomes a compatibility alias for -`activeSessionId`. +The existing `.contextrelay/current` file remains the legacy transcript pointer +in v1.2.0. Mesh active-lane state is tracked in the room/lane registry, not by +repointing `current`. ## Readiness @@ -230,6 +231,69 @@ recreated or rediscovered after restart. Sessions inherit the local instance auth boundary. Session IDs do not create a separate trust boundary inside one instance. +## Room/Lane Registry + +Mesh mode is built on top of runtime sessions. It does not replace +`activeSessionId`, `sessions.default`, `defaultSession`, or the legacy top-level +status fields; those compatibility fields remain until `v2.0`. + +Runtime session entries in `.contextrelay/sessions.json` may include +`transcriptSessionId?: string`. When present, it points to the canonical +transcript ledger id for that runtime session. Missing values preserve the +v1.1.4 compatibility path. Friendly runtime, room, and lane names are registry +ids only; transcript files still use canonical ids such as +`session__`. + +The mesh registry lives at `.contextrelay/rooms.json` and is written atomically +under a lock, mirroring the session registry pattern. Its top-level shape is: + +```json +{ + "schemaVersion": 1, + "instanceId": "project-instance-id", + "activeRoomId": "review", + "activeLaneByParticipant": { + "default:codex": { "roomId": "review", "laneId": "frontend" } + }, + "rooms": { + "review": { + "id": "review", + "instanceId": "project-instance-id", + "status": "open", + "coordinatorParticipantId": "default:codex", + "reviewerParticipantId": "default:claude", + "createdAt": "2026-05-19T00:00:00.000Z", + "members": [], + "lanes": { + "frontend": { + "id": "frontend", + "status": "open", + "assignedParticipantIds": ["default:claude"], + "permissions": ["read", "write", "git"] + } + } + } + } +} +``` + +Rooms use `status: "open" | "paused" | "archived"`. Lanes use +`status: "open" | "complete" | "archived"`. Write-capable lanes require an +assigned worktree and cannot share that worktree with another open +write-capable lane. + +Coordinator drop semantics are fail-closed: when a room's coordinator +participant disconnects or is removed, the target state is `status: "paused"` +with `pausedReason: "coordinator-dropped"`, pending approvals are rejected, and +no silent re-election happens. v1.2.0 includes the manual room-pause registry +primitive; automatic coordinator-disconnect detection and approval rejection are +follow-up hardening work. A human or coordinator-authorized CLI action must +resume or reassign. + +Unknown routing fails closed. Messages targeting unknown room or lane IDs on +write paths are rejected. Missing room/lane scope remains the N=1 compatibility +path and targets the default runtime lane. + ## Migration Order 1. Keep N=1 behavior and expose `sessions.default`. @@ -307,6 +371,12 @@ separate trust boundary inside one instance. default session bypasses the guard in both directions, archived sessions are ignored, and killed runtimes stop blocking once removed from daemon runtime state. Override semantics remain a later slice. +17. Add the room/lane layer above runtime sessions. `ctxrelay mesh`, `ctxrelay + room`, and `ctxrelay lane` manage gated room/lane registry state in + `.contextrelay/rooms.json`. When mesh is disabled, all existing N=1 and + named-session behavior is unchanged. When mesh is enabled, room/lane tools + resolve friendly registry IDs to canonical transcript session IDs through + `sessions.json.transcriptSessionId` and explicit lane scope. Each step must preserve the top-level status fields until a major-version break. diff --git a/docs/V1.2-MESH-MODE.md b/docs/V1.2-MESH-MODE.md index 0cf1f54..ba49968 100644 --- a/docs/V1.2-MESH-MODE.md +++ b/docs/V1.2-MESH-MODE.md @@ -1,7 +1,7 @@ # ContextRelay v1.2 Mesh Mode RFC This document records the current design consensus for ContextRelay v1.2. -It is a planning artifact, not an implemented feature list. +It is both the mesh-mode RFC and the v1.2.0 shipped-surface reference. The working name "mesh mode" is intentionally constrained here. ContextRelay should not implement a free peer-to-peer agent mesh where every agent can @@ -11,11 +11,111 @@ DAGs. ## Status -- v1.1 establishes opt-in named runtime sessions inside one daemon. Live +- v1.1 established opt-in named runtime sessions inside one daemon. Live Claude/Codex routing can be session-scoped, while transcript ledger, viewer, backup, and finality scope still need reconciliation. -- v1.2 should build a controlled orchestration layer above those sessions. -- This RFC must land before any v1.2 router or mesh behavior changes. +- v1.2.0 adds optional room/lane orchestration above those sessions, behind + `features.meshMode.enabled=true`. `CONTEXTRELAY_ENABLE_MESH=1` remains a + process-local override, not a normal-use requirement. +- The shipped surface is intentionally bounded. Free peer mesh, worker + spawning, automatic worktree creation, and multi-host routing remain out of + scope. + +## v1.2.0 Ship List + +The v1.2.0 slice ships these features only when mesh mode is enabled: + +- Config gate: `features.meshMode.enabled`; optional process override: + `CONTEXTRELAY_ENABLE_MESH=1`. +- `ctxrelay mesh status|enable|disable`. +- `ctxrelay mesh start ` wizard for common room/lane setup. +- JSON-backed `.contextrelay/rooms.json` with `schemaVersion: 1`, `instanceId`, + active room/lane metadata, explicit members, and room/lane lifecycle state. +- CLI room and lane commands for create, inspect, select/enter, assignment, + completion, and archive. +- Codex MCP mesh tools mirroring the room/lane control surface. +- Native TUI room/lane panel with `M` mesh wizard and advanced room/lane + control prompts. +- Browser Command Deck read-only room/lane panels with copyable CLI commands. +- Runtime-session to transcript-ledger mapping through `transcriptSessionId` on + `sessions.json` entries. Friendly room/lane IDs never become transcript IDs. +- Route ledger events for visible coordinator decisions and room heartbeat + updates. +- Write-capable lane worktree validation and shared-worktree rejection across + open write-capable lanes. + +Claude-side mesh control tools are intentionally narrowed in v1.2.0 because +ContextRelay cannot yet reliably distinguish parent Claude calls from Claude +subagent or hook contexts. Claude can still participate through existing +handoff, note, artifact, and finality tools; room/lane control should use the +CLI or Codex MCP surface in this slice. + +Native TUI room/lane controls are part of v1.2.0. The browser Command Deck +renders room/lane panels, active markers, route scope badges, and copyable CLI +commands, but remains read-only: it must not mutate room/lane state through the +viewer token. + +The TUI wizard and CLI `mesh start` path default to read-only lanes. They open +worker terminals through tmux and still print worker launch commands as a +fallback. If tmux is missing and stdin is interactive, the CLI asks before +running the detected installer. The TUI asks before leaving the dashboard to run +that same installer in a normal shell. Scripts can pass `--no-launch-terminals`. +Writable launch commands `cd` into the lane's existing worktree before starting +the worker. Writable mode requires existing worktree paths. If mesh config is +disabled, the TUI asks for explicit consent before writing +`features.meshMode.enabled=true`; the CLI `mesh start` command enables the +workspace config gate before creating the room. + +## Lane Semantics + +A room is the membership and authority boundary. It owns the coordinator, +reviewer, participant list, lifecycle status, and room-pinned context. + +A lane is a work stream inside a room. A lane can be Claude-only, Codex-only, a +Claude+Codex pair, or a future agent type. Lanes carry their own lifecycle, +active participant assignment, optional worktree, and default-narrow +permissions. + +Unassigned is a state, not a routable destination. Unknown room or lane IDs fail +closed on write paths. + +There are two ID spaces: + +- Friendly registry IDs such as `review` and `frontend` are used for CLI and MCP + room/lane routing. +- Transcript IDs such as `session__` are used for + `.contextrelay/sessions/*.jsonl` files. + +MCP tools accept friendly IDs and resolve them internally. Friendly room/lane IDs +must never be passed to the transcript ledger as `sessionId`. + +## Three-Tier Context Model + +Mesh mode uses explicit context packets instead of a merged global transcript: + +1. Lane-scoped detail: full work history stays in the current lane's transcript + scope by default. +2. Room-pinned context: coordinator-authored goals, constraints, and references + are visible to all members of the room. +3. Room heartbeat digest: lane artifacts, lane completion, and route decisions + emit compact room-visible status entries. + +Default reads must not silently merge unrelated rooms or lanes. Cross-lane reads +require explicit scope. + +## Subagent And Hook Tool Surface + +Native skills, hooks, and provider subagents are local implementation aids inside +their parent participant's lane. They are not routable ContextRelay participants +unless they have a `runtimeSessionId` in the ContextRelay registry. + +In v1.2.0, ContextRelay does not expose Claude mesh control tools by default +because subagent and hook caller scope cannot be verified at the MCP boundary. +Hook authority must be treated as lane-scoped at policy level, and user-level MCP +servers configured outside ContextRelay remain outside room/lane scope. + +Artifacts produced with subagent assistance can record provenance through +artifact metadata, but structured provenance is intentionally small in v1.2.0. ## Research Basis @@ -129,6 +229,10 @@ If the reviewer participant disconnects, leaves the room, or is killed before answering, the daemon must fail closed by rejecting or expiring the approval. Orphaned approvals must not be left pending indefinitely. +v1.2.0 status: the room registry can represent paused rooms, but automatic +coordinator/reviewer disconnect detection and approval rejection are follow-up +hardening work. + ### Dangerous Primitive Deny List Mesh participants must not receive unrestricted process-spawn capability. @@ -138,6 +242,10 @@ execution. ContextRelay v1.2 must deny raw process-spawn at the routing/policy edge for mesh participants. Workers should use sandboxed command execution or daemon-mediated lifecycle operations only. +v1.2.0 status: raw process-spawn denial is enforced only through +ContextRelay-mediated permissions. Workers that bypass that mediation can still +spawn processes; routing-edge enforcement is a v1.2.1 hardening target. + ### Backpressure Is Visible If an upstream runtime reports overload or a bounded queue is full, ContextRelay @@ -203,16 +311,12 @@ Every room/lane route needs loop controls before cross-session messaging: ## Ledger And Runtime Scope -Runtime sessions and transcript ledger sessions are currently related but not -identical. v1.2 must resolve that before room routing is enabled. - -Acceptable approaches: - -- one ledger stream per runtime session, or -- a durable mapping table from runtime session to ledger session. +Runtime sessions and transcript ledger sessions are related but not identical. +v1.2.0 uses a durable mapping table: each runtime session can carry +`transcriptSessionId` in `.contextrelay/sessions.json`. -In either approach, reads and writes must carry explicit scope. Default reads -must not silently merge room, lane, or worker transcripts. +Reads and writes must carry explicit scope. Default reads must not silently merge +room, lane, or worker transcripts. Finality is scoped. A worker can be done while its room is still active. A room can be done while another room remains active. @@ -273,7 +377,8 @@ Defaults can still resolve to the compatibility session at the routing edge. - Add global pause/kill controls. - Add room-level finality and merge/patch review surfaces. - Retire the named-session experimental gate only when room routing and viewer - controls are stable. + controls are stable. This does not happen in v1.2.0; named-session runtime + launch remains separately gated by `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. ## Non-Goals For v1.2 @@ -289,15 +394,13 @@ Defaults can still resolve to the compatibility session at the routing edge. ## Open Questions -- Whether the runtime-to-ledger relationship should be one ledger per runtime - or a mapping table. -- Whether room/lane state should remain JSON-backed initially or move to - SQLite before live fanout. -- Whether coordinator role is a room property, a participant capability, or - both. -- Whether worker patch merge should be part of v1.2 or a later v1.3 workflow. +- Whether future releases should also model coordinator/reviewer as participant + capabilities. v1.2.0 stores durable coordinator/reviewer assignments on the + room. - How much of Claude worker lifecycle should be managed through stream-json directly versus a thin Claude adapter process. +- The exact routing-edge enforcement point for raw process-spawn denial after + the v1.2.0 policy-only implementation. ## Decision diff --git a/package.json b/package.json index cee7a8c..c2fb685 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@proofofwork-agency/contextrelay", - "version": "1.1.4", + "version": "1.2.0", "description": "ContextRelay: local multi-agent coding orchestration for Claude Code and Codex", "type": "module", "bin": { diff --git a/plugins/contextrelay/.claude-plugin/plugin.json b/plugins/contextrelay/.claude-plugin/plugin.json index bb20622..01ecf68 100644 --- a/plugins/contextrelay/.claude-plugin/plugin.json +++ b/plugins/contextrelay/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "contextrelay", - "version": "1.1.4", + "version": "1.2.0", "description": "ContextRelay bridge for Claude Code and Codex with a shared daemon, push channel delivery, durable queueing, and bidirectional reply tooling.", "author": { "name": "ProofOfWork / Danillo Felix", diff --git a/plugins/contextrelay/server/bridge-server.js b/plugins/contextrelay/server/bridge-server.js index 4877d02..4302ecb 100755 --- a/plugins/contextrelay/server/bridge-server.js +++ b/plugins/contextrelay/server/bridge-server.js @@ -14422,8 +14422,77 @@ import { join as join2 } from "path"; var DEFAULT_CONFIG = { version: "1.0", instanceId: "", + activeProfile: "reviewer", + profiles: { + builder: { + description: "Writable implementation worker profile.", + overrides: { + permissions: { + readonly: false, + allowed: ["read", "write", "shell", "git"], + agentOverrides: {} + } + } + }, + reviewer: { + description: "Read-only architecture and compatibility reviewer profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read"], + agentOverrides: {} + } + } + }, + critic: { + description: "Read-only risk and edge-case review profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read"], + agentOverrides: {} + } + } + }, + tester: { + description: "Test-focused worker profile with shell and read access.", + overrides: { + permissions: { + readonly: false, + allowed: ["read", "shell"], + agentOverrides: {} + } + } + }, + researcher: { + description: "Read-only research worker profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read", "network"], + agentOverrides: {} + } + } + } + }, + wizardPresets: { + review: { profile: "reviewer", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Findings, risks, and recommended next action." }, + debate: { profile: "critic", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Consensus, disagreement, decision, and next action." }, + plan: { profile: "researcher", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Decision-complete implementation plan." }, + implement: { profile: "builder", codexWorkers: 1, claudeWorkers: 1, mode: "writable", outputExpectation: "Patch summary, tests, and residual risk." }, + debug: { profile: "tester", codexWorkers: 1, claudeWorkers: 1, mode: "writable", outputExpectation: "Hypothesis, experiment, fix, and verification." }, + custom: { codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "User-defined output." } + }, + launch: { + backend: "tmux" + }, stateDir: ".contextrelay/state", controlPort: 4502, + features: { + meshMode: { + enabled: false + } + }, codex: { appPort: 4500, proxyPort: 4501 @@ -14551,6 +14620,59 @@ function normalizePermissionOverrides(value) { } return overrides; } +function normalizeLaunchBackend(value) { + return value === "terminal-windows" || value === "tmux" || value === "print-only" ? value : DEFAULT_CONFIG.launch.backend; +} +function normalizeProfile(value) { + if (!isRecord(value)) + return null; + const profile = {}; + if (typeof value.description === "string" && value.description.trim()) { + profile.description = value.description.trim(); + } + if (isRecord(value.overrides)) { + profile.overrides = value.overrides; + } + return Object.keys(profile).length > 0 ? profile : {}; +} +function normalizeProfiles(value) { + const profiles = { ...DEFAULT_CONFIG.profiles }; + if (!isRecord(value)) + return profiles; + for (const [name, raw] of Object.entries(value)) { + if (!/^[a-z][a-z0-9_-]{1,63}$/.test(name)) + continue; + const profile = normalizeProfile(raw); + if (profile) + profiles[name] = profile; + } + return profiles; +} +function normalizeWizardPreset(value, fallback) { + if (!isRecord(value)) + return { ...fallback }; + const codexWorkers = normalizeInteger(value.codexWorkers, fallback.codexWorkers); + const claudeWorkers = normalizeInteger(value.claudeWorkers, fallback.claudeWorkers); + const mode = value.mode === "writable" || value.mode === "read-only" ? value.mode : fallback.mode; + return { + ...typeof value.profile === "string" && value.profile.trim() ? { profile: value.profile.trim() } : fallback.profile ? { profile: fallback.profile } : {}, + codexWorkers: Math.max(0, Math.min(20, codexWorkers)), + claudeWorkers: Math.max(0, Math.min(20, claudeWorkers)), + mode, + ...typeof value.outputExpectation === "string" && value.outputExpectation.trim() ? { outputExpectation: value.outputExpectation.trim() } : fallback.outputExpectation ? { outputExpectation: fallback.outputExpectation } : {} + }; +} +function normalizeWizardPresets(value) { + const raw = isRecord(value) ? value : {}; + return { + review: normalizeWizardPreset(raw.review, DEFAULT_CONFIG.wizardPresets.review), + debate: normalizeWizardPreset(raw.debate, DEFAULT_CONFIG.wizardPresets.debate), + plan: normalizeWizardPreset(raw.plan, DEFAULT_CONFIG.wizardPresets.plan), + implement: normalizeWizardPreset(raw.implement, DEFAULT_CONFIG.wizardPresets.implement), + debug: normalizeWizardPreset(raw.debug, DEFAULT_CONFIG.wizardPresets.debug), + custom: normalizeWizardPreset(raw.custom, DEFAULT_CONFIG.wizardPresets.custom) + }; +} function normalizeConfig(raw) { if (!isRecord(raw)) return null; @@ -14560,16 +14682,32 @@ function normalizeConfig(raw) { const autonomy = isRecord(config2.autonomy) ? config2.autonomy : {}; const turnCoordination = isRecord(config2.turnCoordination) ? config2.turnCoordination : {}; const collaboration = isRecord(config2.collaboration) ? config2.collaboration : {}; + const features = isRecord(config2.features) ? config2.features : {}; + const meshMode = isRecord(features.meshMode) ? features.meshMode : {}; const permissions = isRecord(config2.permissions) ? config2.permissions : {}; + const launch = isRecord(config2.launch) ? config2.launch : {}; + const profiles = normalizeProfiles(config2.profiles); + const activeProfile = typeof config2.activeProfile === "string" && profiles[config2.activeProfile] ? config2.activeProfile : DEFAULT_CONFIG.activeProfile; return { version: typeof config2.version === "string" ? config2.version : DEFAULT_CONFIG.version, instanceId: typeof config2.instanceId === "string" ? config2.instanceId : DEFAULT_CONFIG.instanceId, + ...activeProfile ? { activeProfile } : {}, + profiles, + wizardPresets: normalizeWizardPresets(config2.wizardPresets), + launch: { + backend: normalizeLaunchBackend(launch.backend) + }, stateDir: typeof config2.stateDir === "string" ? config2.stateDir : DEFAULT_CONFIG.stateDir, controlPort: normalizeInteger(config2.controlPort ?? daemon.controlPort, DEFAULT_CONFIG.controlPort), codex: { appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort), proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort) }, + features: { + meshMode: { + enabled: normalizeBoolean(meshMode.enabled, DEFAULT_CONFIG.features.meshMode.enabled) + } + }, autonomy: { enabled: normalizeBoolean(autonomy.enabled, DEFAULT_CONFIG.autonomy.enabled), autoFinalize: normalizeBoolean(autonomy.autoFinalize, DEFAULT_CONFIG.autonomy.autoFinalize), @@ -15761,6 +15899,8 @@ function isBridgeMessage(value) { return false; if (value.roomId !== undefined && typeof value.roomId !== "string") return false; + if (value.laneId !== undefined && typeof value.laneId !== "string") + return false; if (value.target !== undefined && !isMessageSource(value.target)) return false; if (value.caller_agent !== undefined && !isMessageSource(value.caller_agent)) @@ -15771,8 +15911,38 @@ function isBridgeMessage(value) { return false; if (value.message_kind !== undefined && typeof value.message_kind !== "string") return false; + if (value.messageId !== undefined && typeof value.messageId !== "string") + return false; + if (value.traceId !== undefined && typeof value.traceId !== "string") + return false; + if (value.idempotencyKey !== undefined && typeof value.idempotencyKey !== "string") + return false; + if (value.deliveryMode !== undefined && value.deliveryMode !== "online-only" && value.deliveryMode !== "store-if-offline") + return false; + if (value.ack !== undefined && value.ack !== "requested" && value.ack !== "delivered" && value.ack !== "queued" && value.ack !== "dropped" && value.ack !== "failed") + return false; + if (value.ttl !== undefined && typeof value.ttl !== "number") + return false; + if (value.visited !== undefined && (!Array.isArray(value.visited) || !value.visited.every((item) => typeof item === "string"))) + return false; + if (value.from !== undefined && !isRouteEndpoint(value.from)) + return false; + if (value.to !== undefined && !isRouteEndpoint(value.to)) + return false; + if (value.routing !== undefined && !isRoutingEnvelope(value.routing)) + return false; return true; } +function isRouteEndpoint(value) { + if (!isObject2(value)) + return false; + return (value.participantId === undefined || typeof value.participantId === "string") && (value.runtimeSessionId === undefined || typeof value.runtimeSessionId === "string") && (value.roomId === undefined || typeof value.roomId === "string") && (value.laneId === undefined || typeof value.laneId === "string"); +} +function isRoutingEnvelope(value) { + if (!isObject2(value)) + return false; + return typeof value.messageId === "string" && typeof value.traceId === "string" && typeof value.idempotencyKey === "string" && isRouteEndpoint(value.from) && isRouteEndpoint(value.to) && (value.roomId === undefined || typeof value.roomId === "string") && (value.laneId === undefined || typeof value.laneId === "string") && (value.deliveryMode === "online-only" || value.deliveryMode === "store-if-offline") && (value.ack === "requested" || value.ack === "delivered" || value.ack === "queued" || value.ack === "dropped" || value.ack === "failed") && typeof value.ttl === "number" && Array.isArray(value.visited) && value.visited.every((item) => typeof item === "string"); +} var LEDGER_TOOL_KINDS = new Set([ "append_note", "read_context", @@ -15786,7 +15956,26 @@ var LEDGER_TOOL_KINDS = new Set([ "archive_session", "rebind_session", "task_state", - "record_artifact" + "record_artifact", + "request_approval", + "decide_approval", + "expire_approvals", + "mesh_status", + "create_room", + "select_room", + "room_info", + "archive_room", + "add_room_member", + "remove_room_member", + "assign_room_coordinator", + "create_lane", + "enter_lane", + "lane_info", + "complete_lane", + "archive_lane", + "assign_participant_to_lane", + "remove_participant_from_lane", + "route_event" ]); function isKnownErrorCode(value) { return typeof value === "string" && Object.values(ErrorCode2).includes(value); diff --git a/plugins/contextrelay/server/daemon.js b/plugins/contextrelay/server/daemon.js index 21007b8..89f0362 100755 --- a/plugins/contextrelay/server/daemon.js +++ b/plugins/contextrelay/server/daemon.js @@ -2,8 +2,9 @@ // @bun // src/daemon.ts +import { randomUUID as randomUUID3 } from "crypto"; import { appendFileSync as appendFileSync2, realpathSync as realpathSync4, statSync as statSync4 } from "fs"; -import { join as join10, resolve as resolve4 } from "path"; +import { join as join11, resolve as resolve4 } from "path"; import { fileURLToPath as fileURLToPath3 } from "url"; // src/codex-adapter.ts @@ -2852,8 +2853,77 @@ import { join as join3 } from "path"; var DEFAULT_CONFIG = { version: "1.0", instanceId: "", + activeProfile: "reviewer", + profiles: { + builder: { + description: "Writable implementation worker profile.", + overrides: { + permissions: { + readonly: false, + allowed: ["read", "write", "shell", "git"], + agentOverrides: {} + } + } + }, + reviewer: { + description: "Read-only architecture and compatibility reviewer profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read"], + agentOverrides: {} + } + } + }, + critic: { + description: "Read-only risk and edge-case review profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read"], + agentOverrides: {} + } + } + }, + tester: { + description: "Test-focused worker profile with shell and read access.", + overrides: { + permissions: { + readonly: false, + allowed: ["read", "shell"], + agentOverrides: {} + } + } + }, + researcher: { + description: "Read-only research worker profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read", "network"], + agentOverrides: {} + } + } + } + }, + wizardPresets: { + review: { profile: "reviewer", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Findings, risks, and recommended next action." }, + debate: { profile: "critic", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Consensus, disagreement, decision, and next action." }, + plan: { profile: "researcher", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Decision-complete implementation plan." }, + implement: { profile: "builder", codexWorkers: 1, claudeWorkers: 1, mode: "writable", outputExpectation: "Patch summary, tests, and residual risk." }, + debug: { profile: "tester", codexWorkers: 1, claudeWorkers: 1, mode: "writable", outputExpectation: "Hypothesis, experiment, fix, and verification." }, + custom: { codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "User-defined output." } + }, + launch: { + backend: "tmux" + }, stateDir: ".contextrelay/state", controlPort: 4502, + features: { + meshMode: { + enabled: false + } + }, codex: { appPort: 4500, proxyPort: 4501 @@ -2981,6 +3051,59 @@ function normalizePermissionOverrides(value) { } return overrides; } +function normalizeLaunchBackend(value) { + return value === "terminal-windows" || value === "tmux" || value === "print-only" ? value : DEFAULT_CONFIG.launch.backend; +} +function normalizeProfile(value) { + if (!isRecord2(value)) + return null; + const profile = {}; + if (typeof value.description === "string" && value.description.trim()) { + profile.description = value.description.trim(); + } + if (isRecord2(value.overrides)) { + profile.overrides = value.overrides; + } + return Object.keys(profile).length > 0 ? profile : {}; +} +function normalizeProfiles(value) { + const profiles = { ...DEFAULT_CONFIG.profiles }; + if (!isRecord2(value)) + return profiles; + for (const [name, raw] of Object.entries(value)) { + if (!/^[a-z][a-z0-9_-]{1,63}$/.test(name)) + continue; + const profile = normalizeProfile(raw); + if (profile) + profiles[name] = profile; + } + return profiles; +} +function normalizeWizardPreset(value, fallback) { + if (!isRecord2(value)) + return { ...fallback }; + const codexWorkers = normalizeInteger(value.codexWorkers, fallback.codexWorkers); + const claudeWorkers = normalizeInteger(value.claudeWorkers, fallback.claudeWorkers); + const mode = value.mode === "writable" || value.mode === "read-only" ? value.mode : fallback.mode; + return { + ...typeof value.profile === "string" && value.profile.trim() ? { profile: value.profile.trim() } : fallback.profile ? { profile: fallback.profile } : {}, + codexWorkers: Math.max(0, Math.min(20, codexWorkers)), + claudeWorkers: Math.max(0, Math.min(20, claudeWorkers)), + mode, + ...typeof value.outputExpectation === "string" && value.outputExpectation.trim() ? { outputExpectation: value.outputExpectation.trim() } : fallback.outputExpectation ? { outputExpectation: fallback.outputExpectation } : {} + }; +} +function normalizeWizardPresets(value) { + const raw = isRecord2(value) ? value : {}; + return { + review: normalizeWizardPreset(raw.review, DEFAULT_CONFIG.wizardPresets.review), + debate: normalizeWizardPreset(raw.debate, DEFAULT_CONFIG.wizardPresets.debate), + plan: normalizeWizardPreset(raw.plan, DEFAULT_CONFIG.wizardPresets.plan), + implement: normalizeWizardPreset(raw.implement, DEFAULT_CONFIG.wizardPresets.implement), + debug: normalizeWizardPreset(raw.debug, DEFAULT_CONFIG.wizardPresets.debug), + custom: normalizeWizardPreset(raw.custom, DEFAULT_CONFIG.wizardPresets.custom) + }; +} function normalizeConfig(raw) { if (!isRecord2(raw)) return null; @@ -2990,16 +3113,32 @@ function normalizeConfig(raw) { const autonomy = isRecord2(config.autonomy) ? config.autonomy : {}; const turnCoordination = isRecord2(config.turnCoordination) ? config.turnCoordination : {}; const collaboration = isRecord2(config.collaboration) ? config.collaboration : {}; + const features = isRecord2(config.features) ? config.features : {}; + const meshMode = isRecord2(features.meshMode) ? features.meshMode : {}; const permissions = isRecord2(config.permissions) ? config.permissions : {}; + const launch = isRecord2(config.launch) ? config.launch : {}; + const profiles = normalizeProfiles(config.profiles); + const activeProfile = typeof config.activeProfile === "string" && profiles[config.activeProfile] ? config.activeProfile : DEFAULT_CONFIG.activeProfile; return { version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version, instanceId: typeof config.instanceId === "string" ? config.instanceId : DEFAULT_CONFIG.instanceId, + ...activeProfile ? { activeProfile } : {}, + profiles, + wizardPresets: normalizeWizardPresets(config.wizardPresets), + launch: { + backend: normalizeLaunchBackend(launch.backend) + }, stateDir: typeof config.stateDir === "string" ? config.stateDir : DEFAULT_CONFIG.stateDir, controlPort: normalizeInteger(config.controlPort ?? daemon.controlPort, DEFAULT_CONFIG.controlPort), codex: { appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort), proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort) }, + features: { + meshMode: { + enabled: normalizeBoolean(meshMode.enabled, DEFAULT_CONFIG.features.meshMode.enabled) + } + }, autonomy: { enabled: normalizeBoolean(autonomy.enabled, DEFAULT_CONFIG.autonomy.enabled), autoFinalize: normalizeBoolean(autonomy.autoFinalize, DEFAULT_CONFIG.autonomy.autoFinalize), @@ -3023,6 +3162,12 @@ function normalizeConfig(raw) { idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds) }; } +function truthyEnv(value) { + return value !== undefined && ["1", "true", "yes", "on"].includes(value.toLowerCase()); +} +function isMeshModeEnabled(config, env = process.env) { + return truthyEnv(env.CONTEXTRELAY_ENABLE_MESH) || config.features.meshMode.enabled; +} class ConfigService { configDir; configPath; @@ -3103,6 +3248,8 @@ function isBridgeMessage(value) { return false; if (value.roomId !== undefined && typeof value.roomId !== "string") return false; + if (value.laneId !== undefined && typeof value.laneId !== "string") + return false; if (value.target !== undefined && !isMessageSource(value.target)) return false; if (value.caller_agent !== undefined && !isMessageSource(value.caller_agent)) @@ -3113,8 +3260,38 @@ function isBridgeMessage(value) { return false; if (value.message_kind !== undefined && typeof value.message_kind !== "string") return false; + if (value.messageId !== undefined && typeof value.messageId !== "string") + return false; + if (value.traceId !== undefined && typeof value.traceId !== "string") + return false; + if (value.idempotencyKey !== undefined && typeof value.idempotencyKey !== "string") + return false; + if (value.deliveryMode !== undefined && value.deliveryMode !== "online-only" && value.deliveryMode !== "store-if-offline") + return false; + if (value.ack !== undefined && value.ack !== "requested" && value.ack !== "delivered" && value.ack !== "queued" && value.ack !== "dropped" && value.ack !== "failed") + return false; + if (value.ttl !== undefined && typeof value.ttl !== "number") + return false; + if (value.visited !== undefined && (!Array.isArray(value.visited) || !value.visited.every((item) => typeof item === "string"))) + return false; + if (value.from !== undefined && !isRouteEndpoint(value.from)) + return false; + if (value.to !== undefined && !isRouteEndpoint(value.to)) + return false; + if (value.routing !== undefined && !isRoutingEnvelope(value.routing)) + return false; return true; } +function isRouteEndpoint(value) { + if (!isObject(value)) + return false; + return (value.participantId === undefined || typeof value.participantId === "string") && (value.runtimeSessionId === undefined || typeof value.runtimeSessionId === "string") && (value.roomId === undefined || typeof value.roomId === "string") && (value.laneId === undefined || typeof value.laneId === "string"); +} +function isRoutingEnvelope(value) { + if (!isObject(value)) + return false; + return typeof value.messageId === "string" && typeof value.traceId === "string" && typeof value.idempotencyKey === "string" && isRouteEndpoint(value.from) && isRouteEndpoint(value.to) && (value.roomId === undefined || typeof value.roomId === "string") && (value.laneId === undefined || typeof value.laneId === "string") && (value.deliveryMode === "online-only" || value.deliveryMode === "store-if-offline") && (value.ack === "requested" || value.ack === "delivered" || value.ack === "queued" || value.ack === "dropped" || value.ack === "failed") && typeof value.ttl === "number" && Array.isArray(value.visited) && value.visited.every((item) => typeof item === "string"); +} var LEDGER_TOOL_KINDS = new Set([ "append_note", "read_context", @@ -3128,7 +3305,26 @@ var LEDGER_TOOL_KINDS = new Set([ "archive_session", "rebind_session", "task_state", - "record_artifact" + "record_artifact", + "request_approval", + "decide_approval", + "expire_approvals", + "mesh_status", + "create_room", + "select_room", + "room_info", + "archive_room", + "add_room_member", + "remove_room_member", + "assign_room_coordinator", + "create_lane", + "enter_lane", + "lane_info", + "complete_lane", + "archive_lane", + "assign_participant_to_lane", + "remove_participant_from_lane", + "route_event" ]); function isLedgerToolRequestEnvelope(value) { if (!isObject(value)) @@ -3137,6 +3333,10 @@ function isLedgerToolRequestEnvelope(value) { return false; if (value.runtimeSessionId !== undefined && typeof value.runtimeSessionId !== "string") return false; + if (value.roomId !== undefined && typeof value.roomId !== "string") + return false; + if (value.laneId !== undefined && typeof value.laneId !== "string") + return false; return LEDGER_TOOL_KINDS.has(value.type); } function hasValidOptionalRuntimeSessionId(value) { @@ -3571,13 +3771,23 @@ class SessionLedger { meta: { bridgeMessageId: message.id, roomId: message.roomId, + laneId: message.laneId, + messageId: message.messageId, + traceId: message.traceId, + idempotencyKey: message.idempotencyKey, + deliveryMode: message.deliveryMode, + ack: message.ack, + ttl: message.ttl, + visited: message.visited, + routeFrom: message.from, + routeTo: message.to, + routing: message.routing, message_kind: message.message_kind ?? "chat", auto_handled_handoff: handlesHandoffId && !message.handles_handoff_id ? true : undefined } }); } - async appendHandoff(handoff, source, callerDepth = 0, handlesHandoffId, meta) { - const sessionId = this.getOrCreateCurrentSessionId(); + async appendHandoff(handoff, source, callerDepth = 0, handlesHandoffId, meta, sessionId = this.getOrCreateCurrentSessionId()) { const duplicate = this.findDuplicateActiveHandoff(sessionId, handoff, source, meta?.runtimeSessionId); if (duplicate) return duplicate; @@ -4398,6 +4608,11 @@ function viewerHtml() { .meta { color: #aab1bf; font-size: 12px; margin-bottom: 6px; } .lane { border-top: 1px solid #2d3138; border-left: 4px solid transparent; padding: 10px 0 10px 10px; } .lane:first-child { border-top: 0; } + .mesh-room { border-top: 1px solid #2d3138; padding: 12px 0; } + .mesh-room:first-child { border-top: 0; padding-top: 0; } + .mesh-lane { margin-top: 8px; padding: 8px 10px; border: 1px solid #2d3138; border-radius: 6px; background: rgba(16, 17, 20, .34); } + .mesh-command { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; } + .mesh-command code { color: #cdd3df; background: #101114; border: 1px solid #2d3138; border-radius: 6px; padding: 5px 7px; font-size: 12px; overflow-wrap: anywhere; } .pill { display: inline-block; border-radius: 999px; padding: 2px 7px; font-size: 12px; background: #2a2f38; color: #cdd3df; } .claude { color: #c4b5fd; } .codex { color: #67e8f9; } @@ -4453,6 +4668,7 @@ function viewerHtml() {
+

Mesh Rooms

Loading...

Task Board

Loading...

Timeline

Loading...
@@ -4499,8 +4715,10 @@ function viewerHtml() { card("Turn Watchdog", status.lastTurnWatchdog ? new Date(status.lastTurnWatchdog.firedAt).toLocaleTimeString() : "none", !status.lastTurnWatchdog), card("Session", status.sessionId || "none"), card("Ledger entries", status.ledgerEntries ?? 0), + card("Mesh", context.mesh?.enabled ? "enabled" : "off", !!context.mesh?.enabled), card("Autonomy", status.autonomyEnabled ? "on" : "off", !!status.autonomyEnabled), ].join(""); + document.getElementById("mesh").innerHTML = renderMesh(context.mesh); document.getElementById("tasks").innerHTML = renderTasks(context.taskBoard); document.getElementById("agents").innerHTML = (context.agents || []).map(renderAgent).join("") || '
No agent descriptors.
'; document.getElementById("artifacts").innerHTML = [...(context.artifacts || [])].sort(descTs).slice(0, 8).map(renderArtifact).join("") || '
No artifacts.
'; @@ -4538,6 +4756,52 @@ function viewerHtml() { return '
' + esc(lane.status) + ' owner ' + sourceBadge(lane.owner) + handled + ' \xB7 ' + esc(lane.id) + '
' + esc(lane.ask) + '
' + evidence + (lane.blocker ? '
' + esc(lane.blocker) + '
' : '') + '
'; }).join(""); } + function renderMesh(mesh) { + if (!mesh) return '
Mesh status is unavailable.
'; + const registry = mesh.roomRegistry || {}; + const rooms = registry.rooms || []; + const gate = mesh.enabled + ? 'enabled' + : 'disabled'; + const config = mesh.configEnabled ? 'config on' : 'config off'; + const warning = registry.warning ? '
' + esc(registry.warning) + '
' : ''; + const header = '
' + gate + ' \xB7 ' + esc(config) + ' \xB7 read-only Command Deck
'; + const helpCommand = 'ctxrelay room list'; + const help = '
' + esc(helpCommand) + '
'; + if (!rooms.length) { + const create = 'ctxrelay room create review --coordinator default:codex'; + return header + warning + '
No mesh rooms yet.
' + esc(create) + '
'; + } + return header + warning + renderWorkerInbox(mesh.workerInbox || []) + help + rooms.map((room) => renderMeshRoom(room, registry)).join(""); + } + function renderWorkerInbox(workerInbox) { + if (!workerInbox.length) return ''; + return '
Worker inbox
' + + workerInbox.slice(0, 8).map((item) => '
' + esc(item.state) + ' ' + esc(item.roomId + '/' + item.laneId) + '
' + esc(item.why) + '
').join("") + + '
'; + } + function renderMeshRoom(room, registry) { + const active = registry.activeRoomId === room.id ? ' \xB7 active' : ''; + const members = (room.members || []).join(", ") || "none"; + const lanes = Object.values(room.lanes || {}).sort((a, b) => String(a.id).localeCompare(String(b.id))); + const roomCommand = 'ctxrelay room select ' + shellArg(room.id); + const laneCreate = 'ctxrelay lane create ' + shellArg(room.id) + ' frontend --worker default:codex'; + return '
' + esc(room.lifecycle) + ' room ' + esc(room.id) + '' + esc(active) + '
' + + '
' + esc('coordinator: ' + (room.coordinatorParticipantId || 'human') + '\\nreviewer: ' + (room.reviewerParticipantId || 'none') + '\\nmembers: ' + members) + '
' + + '
' + esc(roomCommand) + '' + esc(laneCreate) + '
' + + (lanes.length ? lanes.map((lane) => renderMeshLane(room.id, lane, room.activeLaneId)).join("") : '
No lanes in this room.
') + + '
'; + } + function renderMeshLane(roomId, lane, activeLaneId) { + const active = activeLaneId === lane.id ? ' \xB7 active' : ''; + const permissions = (lane.permissions?.allowed || []).join(", ") || "read"; + const pendingApprovals = Object.keys(lane.pendingApprovals || {}).length; + const enter = 'ctxrelay lane enter ' + shellArg(roomId) + ' ' + shellArg(lane.id) + ' --worker default:codex'; + const complete = 'ctxrelay lane complete ' + shellArg(roomId) + ' ' + shellArg(lane.id); + return '
' + esc(lane.lifecycle) + ' lane ' + esc(lane.id) + '' + esc(active) + '
' + + '
' + esc('owner: ' + (lane.ownerParticipantId || 'none') + '\\nworktree: ' + (lane.worktreePath || 'none') + '\\npermissions: ' + permissions + '\\npending approvals: ' + pendingApprovals) + '
' + + '
' + esc(enter) + '' + esc(complete) + '
'; + } function renderAgent(agent) { return '
' + sourceBadge(agent.id) + ' \xB7 ' + esc(agent.status) + '
' + esc((agent.capabilities || []).join(", ")) + '
'; } @@ -4545,14 +4809,36 @@ function viewerHtml() { return '
' + esc(new Date(artifact.timestamp).toLocaleString()) + ' \xB7 ' + esc(artifact.kind) + ' \xB7 ' + esc(artifact.status || "unknown") + '
' + esc(artifact.title + "\\n" + artifact.summary) + '
'; } function renderEntry(entry) { - return '
' + esc(new Date(entry.timestamp).toLocaleString()) + ' \xB7 ' + esc(entry.type) + ' \xB7 ' + sourceBadge(entry.source) + (entry.target ? ' \u2192 ' + sourceBadge(entry.target) : '') + ' \xB7 ' + esc(entry.id) + '
' + esc(entry.content || "") + '
'; + const scope = (entry.roomId || entry.laneId) + ? ' \xB7 room ' + esc(entry.roomId || '-') + (entry.laneId ? ' \xB7 lane ' + esc(entry.laneId) : '') + : ''; + return '
' + esc(new Date(entry.timestamp).toLocaleString()) + ' \xB7 ' + esc(entry.type) + ' \xB7 ' + sourceBadge(entry.source) + (entry.target ? ' \u2192 ' + sourceBadge(entry.target) : '') + scope + ' \xB7 ' + esc(entry.id) + '
' + esc(entry.content || "") + '
'; } function sourceBadge(source) { return '' + esc(source) + ''; } + function shellArg(value) { + const text = String(value || ""); + return /^[a-zA-Z0-9_./:-]+$/.test(text) ? text : "'" + text.replace(/'/g, "'\\\\''") + "'"; + } + async function copyCommand(command, button) { + try { + await navigator.clipboard.writeText(command); + const previous = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { button.textContent = previous; }, 1200); + } catch { + window.prompt("Copy command", command); + } + } document.getElementById("refresh").addEventListener("click", load); document.getElementById("mode").addEventListener("click", () => { showAllHistory = !showAllHistory; load(); }); document.getElementById("clear-history").addEventListener("click", clearHistory); + document.addEventListener("click", (event) => { + const button = event.target.closest("button[data-copy]"); + if (!button) return; + copyCommand(button.getAttribute("data-copy") || "", button); + }); if (window.EventSource) { const events = new EventSource("/api/viewer/events"); events.addEventListener("viewer_update", (event) => { @@ -4575,6 +4861,88 @@ function viewerHtml() { `; } +// src/session/worker-state.ts +var STALE_AFTER_MS = 5 * 60 * 1000; +function deriveWorkerInbox(registry, entries, now = Date.now()) { + if (!registry) + return []; + const latestErrorByLane = latestEntryByLane(entries, (entry) => entry.type === "error" || entry.runtimeEvent?.status === "failed" || entry.runtimeEvent?.status === "blocked"); + const latestRouteByLane = latestEntryByLane(entries, (entry) => entry.type === "route"); + const result = []; + for (const room of registry.rooms) { + if (room.lifecycle === "archived") + continue; + for (const lane of Object.values(room.lanes)) { + result.push(deriveLaneState(room, lane, latestErrorByLane, latestRouteByLane, now)); + } + } + return result.sort((left, right) => `${left.roomId}/${left.laneId}`.localeCompare(`${right.roomId}/${right.laneId}`)); +} +function deriveLaneState(room, lane, errors, routes, now) { + const key = laneKey(room.id, lane.id); + const pending = Object.values(lane.pendingApprovals); + const latestError = errors.get(key); + const latestRoute = routes.get(key); + if (lane.lifecycle === "complete") { + return baseState(room.id, lane, "complete", "lane marked complete"); + } + if (lane.lifecycle === "archived") { + return baseState(room.id, lane, "complete", "lane archived"); + } + if (pending.length > 0) { + const approval = pending.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt))[0]; + return { + ...baseState(room.id, lane, "waiting_approval", `waiting for ${approval.reviewerParticipantId} to decide ${approval.action}`), + lastApprovalId: approval.approvalId, + runtimeSessionId: approval.runtimeSessionId + }; + } + if (latestError) { + return { + ...baseState(room.id, lane, "failed", latestError.content || "latest lane event failed"), + lastError: latestError.content, + lastActivityAt: new Date(latestError.timestamp).toISOString() + }; + } + const lastSeen = Date.parse(lane.lastSeenAt); + if (Number.isFinite(lastSeen) && now - lastSeen > STALE_AFTER_MS) { + return baseState(room.id, lane, "stale", `no lane activity since ${lane.lastSeenAt}`); + } + if (latestRoute) { + return { + ...baseState(room.id, lane, "active", "latest route delivered or queued"), + lastActivityAt: new Date(latestRoute.timestamp).toISOString() + }; + } + return lane.ownerParticipantId ? baseState(room.id, lane, "queued", "assigned but no route activity yet") : baseState(room.id, lane, "blocked", "no owner participant assigned"); +} +function baseState(roomId, lane, state, why) { + return { + roomId, + laneId: lane.id, + state, + why, + lastActivityAt: lane.lastSeenAt, + ...lane.ownerParticipantId ? { ownerParticipantId: lane.ownerParticipantId } : {} + }; +} +function latestEntryByLane(entries, predicate) { + const result = new Map; + for (const entry of entries) { + if (!predicate(entry)) + continue; + const roomId = typeof entry.meta?.roomId === "string" ? entry.meta.roomId : undefined; + const laneId = typeof entry.meta?.laneId === "string" ? entry.meta.laneId : undefined; + if (!roomId || !laneId) + continue; + result.set(laneKey(roomId, laneId), entry); + } + return result; +} +function laneKey(roomId, laneId) { + return `${roomId}/${laneId}`; +} + // src/viewer-model.ts function buildViewerModel(input) { const taskBoard = deriveTaskBoard(input.entries, { @@ -4595,7 +4963,11 @@ function buildViewerModel(input) { target: entry.target, content: entry.content, runtimeEvent: entry.runtimeEvent, - artifact: entry.artifact + artifact: entry.artifact, + roomId: typeof entry.meta?.roomId === "string" ? entry.meta.roomId : undefined, + laneId: typeof entry.meta?.laneId === "string" ? entry.meta.laneId : undefined, + traceId: typeof entry.meta?.traceId === "string" ? entry.meta.traceId : undefined, + routeDecision: entry.type === "route" && entry.meta?.route && typeof entry.meta.route === "object" ? entry.meta.route : undefined })); return { timeline, @@ -4606,7 +4978,11 @@ function buildViewerModel(input) { connectionHealth: { ...input.connectionHealth, lastBackupResult: input.lastBackupResult ?? null - } + }, + mesh: input.mesh ? { + ...input.mesh, + workerInbox: deriveWorkerInbox(input.mesh.roomRegistry, input.entries) + } : null }; } @@ -5149,15 +5525,15 @@ class SessionRegistry { path; lockPath; lastIssue = null; - constructor(projectRoot, instanceId, now = () => new Date) { + constructor(projectRoot, instanceId, now = () => new Date, registryPath) { this.projectRoot = projectRoot; this.instanceId = instanceId; this.now = now; - this.path = sessionRegistryPath(projectRoot); + this.path = registryPath ?? sessionRegistryPath(projectRoot); this.lockPath = join9(dirname3(this.path), ".sessions.lock"); } load() { - return loadSessionRegistry(this.projectRoot, this.instanceId, this.now); + return loadSessionRegistryFromPath(this.path, this.instanceId, this.now); } withLockedRegistry(fn) { return this.withLock(() => { @@ -5173,7 +5549,7 @@ class SessionRegistry { const now = this.now().toISOString(); const registry = ensureDefaultSession(result.registry, now); if (!isForeignRegistryIssue(result.issue)) { - saveSessionRegistry(this.projectRoot, registry); + saveSessionRegistryToPath(this.path, registry); } return registry; }); @@ -5193,7 +5569,7 @@ class SessionRegistry { ...result.registry.sessions[sessionId], lastSeenAt: maxIsoTimestamp(result.registry.sessions[sessionId].lastSeenAt, now) }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return result.registry; }); } @@ -5223,7 +5599,7 @@ class SessionRegistry { ...options.worktreePath ? { worktreePath: canonicalExistingWorktreePath(options.worktreePath) } : {}, participants: DEFAULT_PARTICIPANTS }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return result.registry; }); } @@ -5253,7 +5629,7 @@ class SessionRegistry { worktreePath: nextWorktreePath, participants: DEFAULT_PARTICIPANTS }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, created: true, @@ -5270,7 +5646,7 @@ class SessionRegistry { worktreePath: nextWorktreePath, lastSeenAt: maxIsoTimestamp(existing.lastSeenAt, now) }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, created: false, @@ -5286,7 +5662,7 @@ class SessionRegistry { throw new Error(`Runtime session ${sessionId} is bound to worktree ${boundPath}, but the current directory is ${nextWorktreePath}. Run from ${boundPath} or rebind this session.`); } existing.lastSeenAt = maxIsoTimestamp(existing.lastSeenAt, now); - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, created: false, @@ -5314,7 +5690,7 @@ class SessionRegistry { } result.registry.activeSessionId = sessionId; session.lastSeenAt = maxIsoTimestamp(session.lastSeenAt, this.now().toISOString()); - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return result.registry; }); } @@ -5348,7 +5724,7 @@ class SessionRegistry { archivedAt: now, lastSeenAt: maxIsoTimestamp(session.lastSeenAt, now) }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, alreadyArchived: false }; }); } @@ -5382,7 +5758,7 @@ class SessionRegistry { worktreePath: nextWorktreePath, lastSeenAt: maxIsoTimestamp(session.lastSeenAt, now) }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, alreadyBound: false }; }); } @@ -5414,7 +5790,33 @@ class SessionRegistry { lastSeenAt: maxIsoTimestamp(session.codexRuntime?.lastSeenAt ?? now, now) }; session.lastSeenAt = maxIsoTimestamp(session.lastSeenAt, now); - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + ensureTranscriptSessionId(sessionId, transcriptSessionId) { + return this.withLock(() => { + const result = this.load(); + this.lastIssue = result.issue ?? null; + if (isForeignRegistryIssue(result.issue)) { + throw new Error(result.issue); + } + if (!isValidRuntimeSessionId(sessionId)) { + throw new Error(invalidSessionIdMessage(sessionId)); + } + const session = result.registry.sessions[sessionId]; + if (!session) { + throw new Error(`Unknown sessionId: ${sessionId}`); + } + if (session.lifecycle === "archived") { + throw new Error(`Cannot update archived session: ${sessionId}`); + } + if (!session.transcriptSessionId) { + const now = this.now().toISOString(); + session.transcriptSessionId = transcriptSessionId; + session.lastSeenAt = maxIsoTimestamp(session.lastSeenAt, now); + saveSessionRegistryToPath(this.path, result.registry); + } return result.registry; }); } @@ -5425,8 +5827,10 @@ class SessionRegistry { function sessionRegistryPath(projectRoot = process.cwd()) { return join9(projectRoot, ".contextrelay", SESSION_REGISTRY_FILE); } -function loadSessionRegistry(projectRoot, instanceId, now = () => new Date) { - const path = sessionRegistryPath(projectRoot); +function sessionRegistryStatePath(stateDir) { + return join9(stateDir, SESSION_REGISTRY_FILE); +} +function loadSessionRegistryFromPath(path, instanceId, now = () => new Date) { try { const parsed = JSON.parse(readFileSync9(path, "utf-8")); return normalizeSessionRegistry(parsed, instanceId, now().toISOString()); @@ -5440,11 +5844,6 @@ function loadSessionRegistry(projectRoot, instanceId, now = () => new Date) { }; } } -function inspectSessionRegistry(projectRoot, instanceId, activeSessionId, now = () => new Date) { - const result = loadSessionRegistry(projectRoot, instanceId, now); - const selectedActiveSessionId = activeSessionId ?? result.registry.activeSessionId; - return inspectSessionRegistryData(result.registry, selectedActiveSessionId, result.issue); -} function inspectSessionRegistryData(registry, activeSessionId = registry.activeSessionId, warning) { return { sessions: Object.values(registry.sessions).sort((left, right) => left.id.localeCompare(right.id)).map((session) => ({ @@ -5455,6 +5854,7 @@ function inspectSessionRegistryData(registry, activeSessionId = registry.activeS lastSeenAt: session.lastSeenAt, ...session.archivedAt ? { archivedAt: session.archivedAt } : {}, archived: session.lifecycle === "archived", + ...session.transcriptSessionId ? { transcriptSessionId: session.transcriptSessionId } : {}, ...session.worktreePath ? { worktreePath: session.worktreePath } : {}, participants: session.participants, ...session.codexRuntime ? { codexRuntime: session.codexRuntime } : {}, @@ -5463,8 +5863,7 @@ function inspectSessionRegistryData(registry, activeSessionId = registry.activeS warning }; } -function saveSessionRegistry(projectRoot, registry) { - const path = sessionRegistryPath(projectRoot); +function saveSessionRegistryToPath(path, registry) { mkdirSync5(dirname3(path), { recursive: true, mode: 448 }); atomicWriteFile(path, JSON.stringify(registry, null, 2) + ` `, { mode: 384 }); @@ -5521,6 +5920,7 @@ function normalizeSession(id, value, nowIso) { const lifecycle = normalizeLifecycle(record.lifecycle); const archivedAt = lifecycle === "archived" ? validOptionalIso(record.archivedAt) : null; const worktreePath = normalizeStoredWorktreePath(record.worktreePath); + const transcriptSessionId = typeof record.transcriptSessionId === "string" && record.transcriptSessionId ? record.transcriptSessionId : undefined; return { id, label: boundedString(record.label, id === DEFAULT_RUNTIME_SESSION_ID2 ? "Default" : id), @@ -5528,6 +5928,7 @@ function normalizeSession(id, value, nowIso) { createdAt, lastSeenAt: maxIsoTimestamp(createdAt, validIsoOr(record.lastSeenAt, createdAt)), ...archivedAt ? { archivedAt } : {}, + ...transcriptSessionId ? { transcriptSessionId } : {}, ...worktreePath ? { worktreePath } : {}, participants: normalizeParticipants(record.participants), ...codexRuntime ? { codexRuntime } : {} @@ -5669,106 +6070,864 @@ function isForeignRegistryIssue(issue) { return issue?.startsWith("Session registry belongs to instance ") === true; } -// src/session/runtime-launch.ts -import { createServer } from "net"; -var DEFAULT_NAMED_RUNTIME_PORT_OFFSET = 100; -var CODEX_RUNTIME_PORT_PAIR_STRIDE = 2; -var DEFAULT_MAX_CODEX_RUNTIME_PORT_PAIRS = 200; -function validateSessionLaunchRequest(request) { - const sessionId = request.sessionId.trim(); - if (!sessionId) { - return { ok: false, error: "runtime session id must be a non-empty string." }; +// src/session/rooms.ts +import { randomUUID as randomUUID2 } from "crypto"; +import { mkdirSync as mkdirSync6, readFileSync as readFileSync10, rmSync as rmSync4 } from "fs"; +import { dirname as dirname4, join as join10 } from "path"; + +// src/session/migrations.ts +function runJsonMigrations(value, currentVersion, migrations, versionKey = "schemaVersion") { + let migrated = { ...value }; + let version = normalizeVersion(migrated[versionKey]); + const ordered = [...migrations].sort((left, right) => left.fromVersion - right.fromVersion); + while (version < currentVersion) { + const migration = ordered.find((item) => item.fromVersion === version); + if (!migration) + break; + migrated = migration.apply(migrated); + version = migration.toVersion; + migrated[versionKey] = version; } - if (!isValidRuntimeSessionId(sessionId)) { - return { - ok: false, - error: `Invalid runtime session id: ${sessionId}. Use 2-64 lowercase letters, numbers, dash, or underscore, starting with a letter.` - }; + return { + ...migrated, + [versionKey]: currentVersion + }; +} +function normalizeVersion(value) { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1; +} + +// src/session/rooms.ts +var ROOM_REGISTRY_VERSION = 2; +var ROOM_REGISTRY_FILE = "rooms.json"; +var ROOM_ID_RE = /^[a-z][a-z0-9_-]{1,63}$/; +var DEFAULT_READ_ONLY_PERMISSIONS = ["read"]; +var DEFAULT_WRITE_PERMISSIONS = ["read", "write", "git"]; +var KNOWN_PERMISSIONS = new Set([ + "read", + "write", + "shell", + "network", + "git", + "secrets", + "browser", + "external_api" +]); +var ROOM_MIGRATIONS = [ + { + fromVersion: 1, + toVersion: 2, + apply: (value) => value } - const session = request.registry.sessions[sessionId]; - if (!session) { - return { ok: false, error: `Unknown runtime session: ${sessionId}` }; +]; + +class RoomRegistry { + projectRoot; + instanceId; + now; + path; + lockPath; + lastIssue = null; + constructor(projectRoot, instanceId, now = () => new Date, registryPath) { + this.projectRoot = projectRoot; + this.instanceId = instanceId; + this.now = now; + this.path = registryPath ?? roomRegistryPath(projectRoot); + this.lockPath = join10(dirname4(this.path), ".rooms.lock"); } - if (session.lifecycle === "archived") { - return { ok: false, error: `Cannot launch archived runtime session: ${sessionId}` }; + load() { + return loadRoomRegistryFromPath(this.path, this.instanceId, this.now); + } + inspect() { + const result = this.load(); + this.lastIssue = result.issue ?? null; + return inspectRoomRegistryData(result.registry, result.issue); + } + createRoom(id, options = {}) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + if (!isValidRoomId(id)) + throw new Error(invalidRoomIdMessage(id)); + if (result.registry.rooms[id]) + throw new Error(`Room ${id} already exists.`); + const now = this.now().toISOString(); + const coordinator = boundedString2(options.coordinatorParticipantId, "human"); + const reviewer = boundedOptionalString(options.reviewerParticipantId); + result.registry.rooms[id] = { + id, + label: boundedString2(options.label, id), + lifecycle: "open", + createdAt: now, + lastSeenAt: now, + coordinatorParticipantId: coordinator, + ...reviewer ? { reviewerParticipantId: reviewer } : {}, + members: [...new Set([coordinator, ...reviewer ? [reviewer] : []].filter((value) => value !== "human"))], + ...boundedOptionalString(options.pinnedContext) ? { pinnedContext: boundedOptionalString(options.pinnedContext) } : {}, + lanes: {} + }; + result.registry.activeRoomId = id; + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); } - if (sessionId !== DEFAULT_RUNTIME_SESSION_ID2 && !request.optInEnabled) { - return { - ok: false, - error: "Named runtime sessions are experimental. Set CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 to enable launch plumbing." - }; + selectRoom(roomId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireRoom(result.registry, roomId); + if (room.lifecycle === "archived") + throw new Error(`Cannot select archived room: ${roomId}`); + result.registry.activeRoomId = roomId; + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); } - if (sessionId === DEFAULT_RUNTIME_SESSION_ID2 && request.defaultCodexRunning === true) { - return { ok: false, error: "Default Codex runtime is already launched." }; + archiveRoom(roomId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireRoom(result.registry, roomId); + if (room.lifecycle === "archived") + return { registry: result.registry, alreadyArchived: true }; + const now = this.now().toISOString(); + room.lifecycle = "archived"; + room.archivedAt = now; + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, now); + if (result.registry.activeRoomId === roomId) + delete result.registry.activeRoomId; + for (const [participantId, scope] of Object.entries(result.registry.activeLaneByParticipant)) { + if (scope.roomId === roomId) + delete result.registry.activeLaneByParticipant[participantId]; + } + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, alreadyArchived: false }; + }); } - return { ok: true, sessionId }; -} -async function allocateCodexRuntimePortPair(options) { - const startOffset = options.startOffset ?? DEFAULT_NAMED_RUNTIME_PORT_OFFSET; - const maxPairs = options.maxPairs ?? DEFAULT_MAX_CODEX_RUNTIME_PORT_PAIRS; - const reserved = new Set(options.reservedPorts ?? []); - const base = options.baseAppPort + startOffset; - for (let i = 0;i < maxPairs; i++) { - const appServerPort = base + i * CODEX_RUNTIME_PORT_PAIR_STRIDE; - const proxyPort = appServerPort + 1; - if (!isValidPort(appServerPort) || !isValidPort(proxyPort)) - break; - if (reserved.has(appServerPort) || reserved.has(proxyPort)) - continue; - if (await canBindPort(appServerPort) && await canBindPort(proxyPort)) { - return { appServerPort, proxyPort }; - } + addMember(roomId, participantId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const participant = requiredParticipantId(participantId); + room.members = [...new Set([...room.members, participant])].sort(); + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); } - throw new Error(`No free named Codex runtime port pair found from base ${base}.`); -} -function collectReservedCodexRuntimePorts(registry) { - const reserved = new Set; - for (const session of Object.values(registry.sessions)) { - if (!session.codexRuntime) - continue; - reserved.add(session.codexRuntime.appServerPort); - reserved.add(session.codexRuntime.proxyPort); + removeMember(roomId, participantId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const participant = requiredParticipantId(participantId); + if (room.coordinatorParticipantId === participant) { + throw new Error(`Cannot remove coordinator ${participant} from room ${roomId}. Assign another coordinator first.`); + } + if (room.reviewerParticipantId === participant) { + delete room.reviewerParticipantId; + } + room.members = room.members.filter((member) => member !== participant); + for (const lane of Object.values(room.lanes)) { + if (lane.ownerParticipantId === participant) + delete lane.ownerParticipantId; + } + const scope = result.registry.activeLaneByParticipant[participant]; + if (scope?.roomId === roomId) + delete result.registry.activeLaneByParticipant[participant]; + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); } - return reserved; -} -function isValidPort(value) { - return Number.isInteger(value) && value > 0 && value <= 65535; -} -function canBindPort(port) { - return new Promise((resolveCheck) => { - const server = createServer(); - server.once("error", () => resolveCheck(false)); - server.once("listening", () => { - server.close(() => resolveCheck(true)); + assignCoordinator(roomId, participantId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const participant = requiredParticipantId(participantId); + room.coordinatorParticipantId = participant; + if (participant !== "human") + room.members = [...new Set([...room.members, participant])].sort(); + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; }); - server.listen(port, "127.0.0.1"); - }); -} - -// src/daemon.ts -var DEFAULT_SESSION_ID = "default"; -var configService = new ConfigService; -var config = configService.loadOrDefault(); -var stateDir = new StateDirResolver(envValue("CONTEXTRELAY_STATE_DIR") ?? resolve4(process.cwd(), config.stateDir)); -stateDir.ensure(); -var localAuthToken = ensureLocalAuthToken(stateDir, "control"); -var proxyAuthToken = ensureLocalAuthToken(stateDir, "proxy"); -var viewerAuthToken = ensureLocalAuthToken(stateDir, "viewer"); -var daemonIdentity = ensureDaemonIdentity(stateDir); -var daemonEntry = currentDaemonEntrySnapshot(); -var INSTANCE_ID = envValue("CONTEXTRELAY_INSTANCE_ID") ?? config.instanceId; -var PROJECT_ROOT = envValue("CONTEXTRELAY_PROJECT_ROOT") ?? process.cwd(); -var sessionRegistry = new SessionRegistry(PROJECT_ROOT, INSTANCE_ID); -sessionRegistry.ensureDefaultSession(); -if (sessionRegistry.lastIssue) { - log(sessionRegistry.lastIssue); -} -var codexAppPort = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10); -var codexProxyPort = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10); -var CONTROL_PORT = envInt("CONTEXTRELAY_CONTROL_PORT", config.controlPort); -var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10); -var CLAUDE_DISCONNECT_GRACE_MS = 5000; -var CLAUDE_PROBE_TIMEOUT_MS = envInt("CONTEXTRELAY_CLAUDE_PROBE_TIMEOUT_MS", 3000); + } + requestApproval(roomId, laneId, options) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const requester = requiredParticipantId(options.requesterParticipantId); + const reviewer = requiredParticipantId(options.reviewerParticipantId ?? room.reviewerParticipantId ?? room.coordinatorParticipantId); + const action = boundedString2(options.action, "unknown"); + const runtimeSessionId = boundedString2(options.runtimeSessionId, "default"); + const idempotencyKey = boundedString2(options.idempotencyKey, randomUUID2(), 240); + const existing = Object.values(lane.pendingApprovals).find((approval2) => approval2.idempotencyKey === idempotencyKey); + if (existing) + return { registry: result.registry, approval: existing, replayed: true }; + const now = this.now(); + const requestedAt = now.toISOString(); + const expiresAt = options.expiresAt ? validIsoOr2(options.expiresAt, new Date(now.getTime() + (options.ttlMs ?? 15 * 60000)).toISOString()) : new Date(now.getTime() + (options.ttlMs ?? 15 * 60000)).toISOString(); + const approval = { + approvalId: randomUUID2(), + requestedAt, + requesterParticipantId: requester, + reviewerParticipantId: reviewer, + action, + runtimeSessionId, + idempotencyKey, + expiresAt, + ...boundedOptionalString(options.reason, 2000) ? { reason: boundedOptionalString(options.reason, 2000) } : {}, + ...options.provenance ? { provenance: options.provenance } : {} + }; + lane.pendingApprovals[approval.approvalId] = approval; + lane.lastSeenAt = maxIsoTimestamp2(lane.lastSeenAt, requestedAt); + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, requestedAt); + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, approval, replayed: false }; + }); + } + decideApproval(roomId, laneId, approvalId, options) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const approval = lane.pendingApprovals[approvalId]; + if (!approval) + throw new Error(`Unknown pending approval ${approvalId} in lane ${laneId}.`); + const reviewer = requiredParticipantId(options.reviewerParticipantId); + if (approval.reviewerParticipantId !== reviewer) { + throw new Error(`Approval ${approvalId} is assigned to ${approval.reviewerParticipantId}, not ${reviewer}.`); + } + delete lane.pendingApprovals[approvalId]; + const now = this.now().toISOString(); + lane.lastSeenAt = maxIsoTimestamp2(lane.lastSeenAt, now); + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, approval }; + }); + } + expireApprovals() { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const now = this.now(); + const nowIso = now.toISOString(); + const expired = []; + for (const room of Object.values(result.registry.rooms)) { + if (room.lifecycle === "archived") + continue; + for (const lane of Object.values(room.lanes)) { + for (const approval of Object.values(lane.pendingApprovals)) { + const expiresAt = Date.parse(approval.expiresAt); + if (!Number.isFinite(expiresAt) || expiresAt > now.getTime()) + continue; + expired.push({ roomId: room.id, laneId: lane.id, approval }); + delete lane.pendingApprovals[approval.approvalId]; + lane.lastSeenAt = maxIsoTimestamp2(lane.lastSeenAt, nowIso); + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, nowIso); + } + } + } + if (expired.length > 0) + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, expired }; + }); + } + createLane(roomId, laneId, options = {}) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + if (!isValidRoomId(laneId)) + throw new Error(invalidRoomIdMessage(laneId)); + if (room.lanes[laneId]) + throw new Error(`Lane ${laneId} already exists in room ${roomId}.`); + const worktreePath = options.worktreePath ? canonicalExistingWorktreePath(options.worktreePath) : undefined; + const permissions = normalizeLanePermissions(options.permissions, worktreePath); + if (permissions.includes("write") && !worktreePath) { + throw new Error(`Lane ${laneId} is write-capable and requires --worktree .`); + } + if (worktreePath && permissions.includes("write")) { + assertNoSharedWritableWorktree(result.registry, roomId, laneId, worktreePath); + } + const now = this.now().toISOString(); + const owner = boundedOptionalString(options.participantId); + room.lanes[laneId] = { + id: laneId, + label: boundedString2(options.label, laneId), + lifecycle: "open", + createdAt: now, + lastSeenAt: now, + ...owner ? { ownerParticipantId: owner } : {}, + ...worktreePath ? { worktreePath } : {}, + permissions: { allowed: permissions }, + pendingApprovals: {} + }; + if (owner) + room.members = [...new Set([...room.members, owner])].sort(); + room.activeLaneId = laneId; + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + enterLane(roomId, laneId, participantId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const participant = requiredParticipantId(participantId); + if (participant !== "human" && !room.members.includes(participant)) { + throw new Error(`Participant ${participant} is not a member of room ${roomId}.`); + } + const now = this.now().toISOString(); + result.registry.activeRoomId = roomId; + result.registry.activeLaneByParticipant[participant] = { roomId, laneId, enteredAt: now }; + room.activeLaneId = laneId; + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, now); + lane.lastSeenAt = maxIsoTimestamp2(lane.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + assignParticipantToLane(roomId, laneId, participantId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const participant = requiredParticipantId(participantId); + room.members = [...new Set([...room.members, participant])].sort(); + lane.ownerParticipantId = participant; + lane.lastSeenAt = maxIsoTimestamp2(lane.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + removeParticipantFromLane(roomId, laneId, participantId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const participant = requiredParticipantId(participantId); + if (lane.ownerParticipantId === participant) + delete lane.ownerParticipantId; + const scope = result.registry.activeLaneByParticipant[participant]; + if (scope?.roomId === roomId && scope.laneId === laneId) + delete result.registry.activeLaneByParticipant[participant]; + lane.lastSeenAt = maxIsoTimestamp2(lane.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + completeLane(roomId, laneId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const now = this.now().toISOString(); + lane.lifecycle = "complete"; + lane.completedAt = now; + lane.lastSeenAt = maxIsoTimestamp2(lane.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + archiveLane(roomId, laneId) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireLane(room, laneId); + if (lane.lifecycle === "archived") + return { registry: result.registry, alreadyArchived: true }; + const now = this.now().toISOString(); + lane.lifecycle = "archived"; + lane.archivedAt = now; + lane.lastSeenAt = maxIsoTimestamp2(lane.lastSeenAt, now); + if (room.activeLaneId === laneId) + delete room.activeLaneId; + for (const [participantId, scope] of Object.entries(result.registry.activeLaneByParticipant)) { + if (scope.roomId === roomId && scope.laneId === laneId) + delete result.registry.activeLaneByParticipant[participantId]; + } + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, alreadyArchived: false }; + }); + } + pauseRoom(roomId, reason) { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireRoom(result.registry, roomId); + if (room.lifecycle === "archived") + throw new Error(`Cannot pause archived room: ${roomId}`); + const now = this.now().toISOString(); + room.lifecycle = "paused"; + room.pausedAt = now; + room.pauseReason = reason; + room.lastSeenAt = maxIsoTimestamp2(room.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + withLockedRegistry(fn) { + return withRoomRegistryLock(this.lockPath, () => { + const result = this.load(); + this.lastIssue = result.issue ?? null; + return fn(result); + }); + } +} +function roomRegistryPath(projectRoot = process.cwd()) { + return join10(projectRoot, ".contextrelay", ROOM_REGISTRY_FILE); +} +function roomRegistryStatePath(stateDir) { + return join10(stateDir, ROOM_REGISTRY_FILE); +} +function loadRoomRegistryFromPath(path, instanceId, now = () => new Date) { + try { + const parsed = JSON.parse(readFileSync10(path, "utf-8")); + return normalizeRoomRegistry(parsed, instanceId, now().toISOString()); + } catch (err) { + if (err?.code === "ENOENT") { + return { registry: createDefaultRoomRegistry(instanceId) }; + } + return { + registry: createDefaultRoomRegistry(instanceId), + issue: `Room registry at ${path} could not be read; recreated empty registry: ${err?.message ?? String(err)}` + }; + } +} +function saveRoomRegistryToPath(path, registry) { + mkdirSync6(dirname4(path), { recursive: true, mode: 448 }); + atomicWriteFile(path, JSON.stringify(registry, null, 2) + ` +`, { mode: 384 }); +} +function inspectRoomRegistryData(registry, warning) { + return { + rooms: Object.values(registry.rooms).sort((left, right) => left.id.localeCompare(right.id)), + ...registry.activeRoomId ? { activeRoomId: registry.activeRoomId } : {}, + activeLaneByParticipant: registry.activeLaneByParticipant, + warning + }; +} +function normalizeRoomRegistry(value, expectedInstanceId, nowIso) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { registry: createDefaultRoomRegistry(expectedInstanceId), issue: "Room registry is not an object; recreated empty registry." }; + } + const record = runJsonMigrations(value, ROOM_REGISTRY_VERSION, ROOM_MIGRATIONS); + const fileInstanceId = typeof record.instanceId === "string" ? record.instanceId : null; + if (fileInstanceId && fileInstanceId !== expectedInstanceId) { + return { + registry: createDefaultRoomRegistry(expectedInstanceId), + issue: `Room registry belongs to instance ${fileInstanceId}; expected ${expectedInstanceId}.` + }; + } + const rooms = normalizeRooms(record.rooms, nowIso); + const activeRoomId = typeof record.activeRoomId === "string" && rooms[record.activeRoomId] ? record.activeRoomId : undefined; + return { + registry: { + schemaVersion: ROOM_REGISTRY_VERSION, + instanceId: expectedInstanceId, + ...activeRoomId ? { activeRoomId } : {}, + activeLaneByParticipant: normalizeActiveLaneByParticipant(record.activeLaneByParticipant, rooms), + rooms + }, + issue: fileInstanceId ? undefined : "Room registry missing instanceId; repaired registry." + }; +} +function createDefaultRoomRegistry(instanceId) { + return { + schemaVersion: ROOM_REGISTRY_VERSION, + instanceId, + activeLaneByParticipant: {}, + rooms: {} + }; +} +function normalizeRooms(value, nowIso) { + const rooms = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) + return rooms; + for (const [id, raw] of Object.entries(value)) { + const normalized = normalizeRoom(id, raw, nowIso); + if (normalized) + rooms[id] = normalized; + } + return rooms; +} +function normalizeRoom(id, value, nowIso) { + if (!isValidRoomId(id) || !value || typeof value !== "object" || Array.isArray(value)) + return null; + const record = value; + const createdAt = validIsoOr2(record.createdAt, nowIso); + const lanes = normalizeLanes(record.lanes, createdAt); + const activeLaneId = typeof record.activeLaneId === "string" && lanes[record.activeLaneId] ? record.activeLaneId : undefined; + return { + id, + label: boundedString2(record.label, id), + lifecycle: normalizeRoomLifecycle(record.lifecycle), + createdAt, + lastSeenAt: maxIsoTimestamp2(createdAt, validIsoOr2(record.lastSeenAt, createdAt)), + ...validOptionalIso2(record.archivedAt) ? { archivedAt: validOptionalIso2(record.archivedAt) } : {}, + ...validOptionalIso2(record.pausedAt) ? { pausedAt: validOptionalIso2(record.pausedAt) } : {}, + ...boundedOptionalString(record.pauseReason) ? { pauseReason: boundedOptionalString(record.pauseReason) } : {}, + coordinatorParticipantId: boundedString2(record.coordinatorParticipantId, "human"), + ...boundedOptionalString(record.reviewerParticipantId) ? { reviewerParticipantId: boundedOptionalString(record.reviewerParticipantId) } : {}, + members: normalizeStringList(record.members), + ...boundedOptionalString(record.pinnedContext, 8000) ? { pinnedContext: boundedOptionalString(record.pinnedContext, 8000) } : {}, + ...activeLaneId ? { activeLaneId } : {}, + lanes + }; +} +function normalizeLanes(value, fallbackIso) { + const lanes = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) + return lanes; + for (const [id, raw] of Object.entries(value)) { + const normalized = normalizeLane(id, raw, fallbackIso); + if (normalized) + lanes[id] = normalized; + } + return lanes; +} +function normalizeLane(id, value, fallbackIso) { + if (!isValidRoomId(id) || !value || typeof value !== "object" || Array.isArray(value)) + return null; + const record = value; + const createdAt = validIsoOr2(record.createdAt, fallbackIso); + const worktreePath = normalizeStoredWorktreePath(record.worktreePath); + return { + id, + label: boundedString2(record.label, id), + lifecycle: normalizeLaneLifecycle(record.lifecycle), + createdAt, + lastSeenAt: maxIsoTimestamp2(createdAt, validIsoOr2(record.lastSeenAt, createdAt)), + ...validOptionalIso2(record.completedAt) ? { completedAt: validOptionalIso2(record.completedAt) } : {}, + ...validOptionalIso2(record.archivedAt) ? { archivedAt: validOptionalIso2(record.archivedAt) } : {}, + ...boundedOptionalString(record.ownerParticipantId) ? { ownerParticipantId: boundedOptionalString(record.ownerParticipantId) } : {}, + ...worktreePath ? { worktreePath } : {}, + permissions: { allowed: normalizePermissionList(record.permissions?.allowed, worktreePath) }, + pendingApprovals: normalizePendingApprovals(record.pendingApprovals) + }; +} +function normalizePendingApprovals(value) { + const approvals = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) + return approvals; + for (const [id, raw] of Object.entries(value)) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const record = raw; + const approvalId = typeof record.approvalId === "string" && record.approvalId ? record.approvalId : id; + const requestedAt = validOptionalIso2(record.requestedAt); + const expiresAt = validOptionalIso2(record.expiresAt); + if (!requestedAt || !expiresAt) + continue; + const requesterParticipantId = boundedOptionalString(record.requesterParticipantId); + const reviewerParticipantId = boundedOptionalString(record.reviewerParticipantId); + const action = boundedOptionalString(record.action); + const runtimeSessionId = boundedOptionalString(record.runtimeSessionId); + const idempotencyKey = boundedOptionalString(record.idempotencyKey, 240); + if (!requesterParticipantId || !reviewerParticipantId || !action || !runtimeSessionId || !idempotencyKey) + continue; + approvals[approvalId] = { + approvalId, + requestedAt, + requesterParticipantId, + reviewerParticipantId, + action, + runtimeSessionId, + idempotencyKey, + expiresAt, + ...boundedOptionalString(record.reason, 2000) ? { reason: boundedOptionalString(record.reason, 2000) } : {}, + ...record.provenance && typeof record.provenance === "object" && !Array.isArray(record.provenance) ? { provenance: record.provenance } : {} + }; + } + return approvals; +} +function normalizeActiveLaneByParticipant(value, rooms) { + const result = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) + return result; + for (const [participantId, raw] of Object.entries(value)) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const record = raw; + const roomId = typeof record.roomId === "string" ? record.roomId : ""; + const laneId = typeof record.laneId === "string" ? record.laneId : ""; + const room = rooms[roomId]; + if (!room?.lanes[laneId]) + continue; + result[participantId] = { + roomId, + laneId, + enteredAt: validIsoOr2(record.enteredAt, new Date(0).toISOString()) + }; + } + return result; +} +function isValidRoomId(value) { + return ROOM_ID_RE.test(value); +} +function participantIdFor(source, runtimeSessionId) { + return `${runtimeSessionId || "default"}:${source}`; +} +function runtimeSessionHasOpenReadOnlyLane(registry, runtimeSessionId) { + const participantPrefix = `${runtimeSessionId}:`; + for (const [participantId, scope] of Object.entries(registry.activeLaneByParticipant)) { + if (!participantId.startsWith(participantPrefix)) + continue; + const room = registry.rooms[scope.roomId]; + const lane = room?.lanes[scope.laneId]; + if (!room || room.lifecycle !== "open" || !lane || lane.lifecycle !== "open") + continue; + if (!lane.permissions.allowed.includes("write")) + return true; + } + return false; +} +function requireRoom(registry, roomId) { + if (!isValidRoomId(roomId)) + throw new Error(invalidRoomIdMessage(roomId)); + const room = registry.rooms[roomId]; + if (!room) + throw new Error(`Unknown room: ${roomId}`); + return room; +} +function requireOpenRoom(registry, roomId) { + const room = requireRoom(registry, roomId); + if (room.lifecycle === "archived") + throw new Error(`Room ${roomId} is archived.`); + if (room.lifecycle === "paused") + throw new Error(`Room ${roomId} is paused${room.pauseReason ? `: ${room.pauseReason}` : ""}.`); + return room; +} +function requireLane(room, laneId) { + if (!isValidRoomId(laneId)) + throw new Error(invalidRoomIdMessage(laneId)); + const lane = room.lanes[laneId]; + if (!lane) + throw new Error(`Unknown lane ${laneId} in room ${room.id}.`); + return lane; +} +function requireOpenLane(room, laneId) { + const lane = requireLane(room, laneId); + if (lane.lifecycle === "archived") + throw new Error(`Lane ${laneId} is archived.`); + if (lane.lifecycle === "complete") + throw new Error(`Lane ${laneId} is complete.`); + return lane; +} +function assertWritableRegistry(result) { + if (isForeignRoomRegistryIssue(result.issue)) + throw new Error(result.issue); +} +function assertNoSharedWritableWorktree(registry, roomId, laneId, worktreePath) { + const normalized = normalizeStoredWorktreePath(worktreePath); + if (!normalized) + return; + for (const room of Object.values(registry.rooms)) { + if (room.lifecycle === "archived") + continue; + for (const lane of Object.values(room.lanes)) { + if (room.id === roomId && lane.id === laneId) + continue; + if (lane.lifecycle === "archived") + continue; + if (!lane.permissions.allowed.includes("write")) + continue; + if (normalizeStoredWorktreePath(lane.worktreePath) === normalized) { + throw new Error(`Lane ${lane.id} in room ${room.id} already owns writable worktree ${normalized}.`); + } + } + } +} +function normalizeLanePermissions(value, worktreePath) { + if (Array.isArray(value)) + return normalizePermissionList(value, worktreePath); + return worktreePath ? [...DEFAULT_WRITE_PERMISSIONS] : [...DEFAULT_READ_ONLY_PERMISSIONS]; +} +function normalizePermissionList(value, worktreePath) { + if (!Array.isArray(value)) + return worktreePath ? [...DEFAULT_WRITE_PERMISSIONS] : [...DEFAULT_READ_ONLY_PERMISSIONS]; + const normalized = value.filter((item) => typeof item === "string" && KNOWN_PERMISSIONS.has(item)); + const unique = [...new Set(normalized)]; + return unique.length > 0 ? unique : [...DEFAULT_READ_ONLY_PERMISSIONS]; +} +function normalizeStringList(value) { + if (!Array.isArray(value)) + return []; + return [...new Set(value.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()))].sort(); +} +function normalizeRoomLifecycle(value) { + if (value === "paused" || value === "archived") + return value; + return "open"; +} +function normalizeLaneLifecycle(value) { + if (value === "complete" || value === "archived") + return value; + return "open"; +} +function requiredParticipantId(value) { + const normalized = boundedOptionalString(value); + if (!normalized) + throw new Error("participantId must be a non-empty string."); + return normalized; +} +function boundedString2(value, fallback, max = 120) { + if (typeof value !== "string") + return fallback; + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= max ? trimmed : fallback; +} +function boundedOptionalString(value, max = 120) { + if (typeof value !== "string") + return; + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= max ? trimmed : undefined; +} +function validIsoOr2(value, fallback) { + if (typeof value !== "string") + return fallback; + const millis = Date.parse(value); + return Number.isFinite(millis) ? new Date(millis).toISOString() : fallback; +} +function validOptionalIso2(value) { + if (typeof value !== "string") + return null; + const millis = Date.parse(value); + return Number.isFinite(millis) ? new Date(millis).toISOString() : null; +} +function maxIsoTimestamp2(left, right) { + return Date.parse(right) > Date.parse(left) ? right : left; +} +function invalidRoomIdMessage(value) { + return `Invalid room or lane id: ${value}. Use 2-64 lowercase letters, numbers, dash, or underscore, starting with a letter.`; +} +function withRoomRegistryLock(lockPath, fn) { + const started = Date.now(); + const timeoutMs = 5000; + mkdirSync6(dirname4(lockPath), { recursive: true, mode: 448 }); + while (true) { + try { + mkdirSync6(lockPath, { mode: 448 }); + break; + } catch (err) { + if (err?.code !== "EEXIST") + throw err; + if (Date.now() - started > timeoutMs) { + throw new Error(`Timed out waiting for ContextRelay room registry lock: ${lockPath}`); + } + Bun.sleepSync(25); + } + } + try { + return fn(); + } finally { + rmSync4(lockPath, { recursive: true, force: true }); + } +} +function isForeignRoomRegistryIssue(issue) { + return issue?.startsWith("Room registry belongs to instance ") === true; +} + +// src/session/runtime-launch.ts +import { createServer } from "net"; +var DEFAULT_NAMED_RUNTIME_PORT_OFFSET = 100; +var CODEX_RUNTIME_PORT_PAIR_STRIDE = 2; +var DEFAULT_MAX_CODEX_RUNTIME_PORT_PAIRS = 200; +function validateSessionLaunchRequest(request) { + const sessionId = request.sessionId.trim(); + if (!sessionId) { + return { ok: false, error: "runtime session id must be a non-empty string." }; + } + if (!isValidRuntimeSessionId(sessionId)) { + return { + ok: false, + error: `Invalid runtime session id: ${sessionId}. Use 2-64 lowercase letters, numbers, dash, or underscore, starting with a letter.` + }; + } + const session = request.registry.sessions[sessionId]; + if (!session) { + return { ok: false, error: `Unknown runtime session: ${sessionId}` }; + } + if (session.lifecycle === "archived") { + return { ok: false, error: `Cannot launch archived runtime session: ${sessionId}` }; + } + if (sessionId !== DEFAULT_RUNTIME_SESSION_ID2 && !request.optInEnabled) { + return { + ok: false, + error: "Named runtime sessions are experimental. Set CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 to enable launch plumbing." + }; + } + if (sessionId === DEFAULT_RUNTIME_SESSION_ID2 && request.defaultCodexRunning === true) { + return { ok: false, error: "Default Codex runtime is already launched." }; + } + return { ok: true, sessionId }; +} +async function allocateCodexRuntimePortPair(options) { + const startOffset = options.startOffset ?? DEFAULT_NAMED_RUNTIME_PORT_OFFSET; + const maxPairs = options.maxPairs ?? DEFAULT_MAX_CODEX_RUNTIME_PORT_PAIRS; + const reserved = new Set(options.reservedPorts ?? []); + const base = options.baseAppPort + startOffset; + for (let i = 0;i < maxPairs; i++) { + const appServerPort = base + i * CODEX_RUNTIME_PORT_PAIR_STRIDE; + const proxyPort = appServerPort + 1; + if (!isValidPort(appServerPort) || !isValidPort(proxyPort)) + break; + if (reserved.has(appServerPort) || reserved.has(proxyPort)) + continue; + if (await canBindPort(appServerPort) && await canBindPort(proxyPort)) { + return { appServerPort, proxyPort }; + } + } + throw new Error(`No free named Codex runtime port pair found from base ${base}.`); +} +function collectReservedCodexRuntimePorts(registry) { + const reserved = new Set; + for (const session of Object.values(registry.sessions)) { + if (!session.codexRuntime) + continue; + reserved.add(session.codexRuntime.appServerPort); + reserved.add(session.codexRuntime.proxyPort); + } + return reserved; +} +function isValidPort(value) { + return Number.isInteger(value) && value > 0 && value <= 65535; +} +function canBindPort(port) { + return new Promise((resolveCheck) => { + const server = createServer(); + server.once("error", () => resolveCheck(false)); + server.once("listening", () => { + server.close(() => resolveCheck(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +// src/daemon.ts +var DEFAULT_SESSION_ID = "default"; +var configService = new ConfigService; +var config = configService.loadOrDefault(); +var stateDir = new StateDirResolver(envValue("CONTEXTRELAY_STATE_DIR") ?? resolve4(process.cwd(), config.stateDir)); +stateDir.ensure(); +var localAuthToken = ensureLocalAuthToken(stateDir, "control"); +var proxyAuthToken = ensureLocalAuthToken(stateDir, "proxy"); +var viewerAuthToken = ensureLocalAuthToken(stateDir, "viewer"); +var daemonIdentity = ensureDaemonIdentity(stateDir); +var daemonEntry = currentDaemonEntrySnapshot(); +var INSTANCE_ID = envValue("CONTEXTRELAY_INSTANCE_ID") ?? config.instanceId; +var PROJECT_ROOT = envValue("CONTEXTRELAY_PROJECT_ROOT") ?? process.cwd(); +var sessionRegistry = new SessionRegistry(PROJECT_ROOT, INSTANCE_ID, undefined, sessionRegistryStatePath(stateDir.dir)); +sessionRegistry.ensureDefaultSession(); +if (sessionRegistry.lastIssue) { + log(sessionRegistry.lastIssue); +} +var roomRegistry = new RoomRegistry(PROJECT_ROOT, INSTANCE_ID, undefined, roomRegistryStatePath(stateDir.dir)); +var codexAppPort = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10); +var codexProxyPort = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10); +var CONTROL_PORT = envInt("CONTEXTRELAY_CONTROL_PORT", config.controlPort); +var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10); +var CLAUDE_DISCONNECT_GRACE_MS = 5000; +var CLAUDE_PROBE_TIMEOUT_MS = envInt("CONTEXTRELAY_CLAUDE_PROBE_TIMEOUT_MS", 3000); var CLAUDE_RESPONSE_TIMEOUT_MS = envInt("CONTEXTRELAY_CLAUDE_RESPONSE_TIMEOUT_MS", 300000); var DAEMON_SHUTDOWN_STEP_TIMEOUT_MS = envInt("CONTEXTRELAY_DAEMON_SHUTDOWN_STEP_TIMEOUT_MS", 4000); var MAX_BUFFERED_MESSAGES = envInt("CONTEXTRELAY_MAX_BUFFERED_MESSAGES", 100); @@ -5782,6 +6941,7 @@ var ATTENTION_WINDOW_MS = envInt("CONTEXTRELAY_ATTENTION_WINDOW_MS", config.turn var STATUS_BUFFERING_ENABLED = config.turnCoordination.bufferStatusDuringAttention === true; var MAX_CALLER_DEPTH = envInt("CONTEXTRELAY_MAX_DEPTH", 3); var BACKUP_THROTTLE_MS = envInt("CONTEXTRELAY_BACKUP_THROTTLE_MS", 60000); +var APPROVAL_EXPIRY_SWEEP_INTERVAL_MS = envInt("CONTEXTRELAY_APPROVAL_EXPIRY_SWEEP_INTERVAL_MS", 60000); var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log }); var defaultCodexRuntime = new CodexAdapter(codexAppPort, codexProxyPort, stateDir.logFile, proxyAuthToken, stateDir); var attachCmd = `${PRIMARY_BIN} codex`; @@ -5799,6 +6959,8 @@ var authLockout = new AuthLockout; var nextControlClientId = 0; var nextSystemMessageId = 0; var shuttingDown = false; +var approvalExpirySweepTimer = null; +var approvalExpirySweepInFlight = false; var ATTACH_STATUS_COOLDOWN_MS = 30000; var MAX_PENDING_CODEX_INJECTIONS = 50; var defaultSession = createDefaultSessionState(DEFAULT_SESSION_ID, defaultCodexRuntime); @@ -5915,7 +7077,7 @@ function createDefaultSessionState(id, codexRuntime) { return state; } function namedRuntimeStateDir(id) { - const dir = id === DEFAULT_SESSION_ID ? stateDir.dir : join10(stateDir.dir, "runtime-sessions", id); + const dir = id === DEFAULT_SESSION_ID ? stateDir.dir : join11(stateDir.dir, "runtime-sessions", id); const resolver = new StateDirResolver(dir); resolver.ensure(); return resolver; @@ -5938,8 +7100,16 @@ function portFromRuntimeUrl(value, label) { } return port; } -function optInNamedSessionsEnabled() { - return envValue(NAMED_SESSION_OPT_IN_ENV) === "1"; +function optInNamedSessionsEnabled(runtimeSessionId) { + return envValue(NAMED_SESSION_OPT_IN_ENV) === "1" || Boolean(runtimeSessionId && runtimeSessionHasReadOnlyMeshLane(runtimeSessionId)); +} +function runtimeSessionHasReadOnlyMeshLane(runtimeSessionId) { + if (!meshModeEnabled()) + return false; + const result = roomRegistry.load(); + if (isForeignRoomRegistryIssue(result.issue)) + return false; + return runtimeSessionHasOpenReadOnlyLane(result.registry, runtimeSessionId); } function allocationReservedPorts(sessionId2, registry, extraReserved = []) { const reserved = collectReservedCodexRuntimePorts(registry); @@ -5986,22 +7156,24 @@ function claimNamedCodexRuntimeLaunch(sessionId2) { } const validation = validateSessionLaunchRequest({ sessionId: sessionId2, - optInEnabled: optInNamedSessionsEnabled(), + optInEnabled: optInNamedSessionsEnabled(sessionId2), registry: result.registry }); if (!validation.ok) { throw new ControlRequestError(ErrorCode.INVALID_REQUEST, validation.error); } - const conflict = findSharedWorktreeRuntimeConflict({ - registry: result.registry, - sessionId: sessionId2, - launchedSessionIds: new Set([ - ...sessions.keys(), - ...namedRuntimeLaunchClaims - ]) - }); - if (conflict) { - throw new ControlRequestError(ErrorCode.INVALID_REQUEST, `Runtime session ${sessionId2} shares worktree ${conflict.worktreePath} with launched runtime session ${conflict.sessionId}. Stop it with \`${PRIMARY_BIN} kill --session ${conflict.sessionId}\` or rebind one session before launching.`); + if (!runtimeSessionHasReadOnlyMeshLane(sessionId2)) { + const conflict = findSharedWorktreeRuntimeConflict({ + registry: result.registry, + sessionId: sessionId2, + launchedSessionIds: new Set([ + ...sessions.keys(), + ...namedRuntimeLaunchClaims + ]) + }); + if (conflict) { + throw new ControlRequestError(ErrorCode.INVALID_REQUEST, `Runtime session ${sessionId2} shares worktree ${conflict.worktreePath} with launched runtime session ${conflict.sessionId}. Stop it with \`${PRIMARY_BIN} kill --session ${conflict.sessionId}\` or rebind one session before launching.`); + } } namedRuntimeLaunchClaims.add(sessionId2); }); @@ -6014,7 +7186,7 @@ async function ensureCodexRuntime(sessionIdInput) { } const validation = validateSessionLaunchRequest({ sessionId: sessionIdInput, - optInEnabled: optInNamedSessionsEnabled(), + optInEnabled: optInNamedSessionsEnabled(sessionIdInput), registry: registryResult.registry }); if (!validation.ok) { @@ -6622,7 +7794,7 @@ function ensureSessionRegistryForLaunch(sessionId2, worktreePath) { if (sessionId2 === DEFAULT_SESSION_ID) { throw new ControlRequestError(ErrorCode.INVALID_REQUEST, "Default runtime session cannot be ensured as a named session."); } - if (!optInNamedSessionsEnabled()) { + if (!optInNamedSessionsEnabled(sessionId2)) { throw new ControlRequestError(ErrorCode.INVALID_REQUEST, "Named runtime sessions are experimental. Set CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 to enable launch plumbing."); } try { @@ -6847,7 +8019,7 @@ async function executeLedgerTool(request) { const runtimeSession = resolveRuntimeSessionMeta(request); if (!runtimeSession.ok) return runtimeSession.result; - const sid = request.sessionId ?? sessionId; + const sid = resolveLedgerSessionId(request); const source = request.source ?? "claude"; const handlesHandoffId = request.handles_handoff_id ?? ledger.autoHandledHandoffId(source, undefined, request.text, sid); const depth = deriveCallerDepth(handlesHandoffId, sid); @@ -6861,13 +8033,14 @@ async function executeLedgerTool(request) { handles_handoff_id: handlesHandoffId, meta: { ...runtimeSession.meta ?? {}, + ...meshScopeMeta(request), auto_handled_handoff: handlesHandoffId && !request.handles_handoff_id ? true : undefined } }); return { ok: true, entry, sessionId: entry.sessionId }; } case "read_context": { - const sid = request.sessionId ?? sessionId; + const sid = resolveLedgerSessionId(request); const entries = ledger.read(sid, request.limit ?? 40); const latestActiveHandoff = ledger.latestActiveHandoff(request.target, sid); return { ok: true, entries, latestActiveHandoff, sessionId: sid }; @@ -6876,7 +8049,7 @@ async function executeLedgerTool(request) { const runtimeSession = resolveRuntimeSessionMeta(request); if (!runtimeSession.ok) return runtimeSession.result; - const depth = deriveCallerDepth(request.handles_handoff_id, request.sessionId ?? sessionId); + const depth = deriveCallerDepth(request.handles_handoff_id, resolveLedgerSessionId(request)); if (depth >= MAX_CALLER_DEPTH) { return { ok: false, @@ -6884,7 +8057,22 @@ async function executeLedgerTool(request) { error: `Bridge recursion depth ${depth} reached CONTEXTRELAY_MAX_DEPTH=${MAX_CALLER_DEPTH}.` }; } - const entry = await ledger.appendHandoff(request.handoff, request.handoff.from, depth, request.handles_handoff_id, runtimeSession.meta); + if (request.roomId || request.laneId) { + const route = await recordRouteDecision({ + traceId: randomUUID3(), + from: { roomId: request.roomId, laneId: request.laneId, participantId: participantIdFor(request.handoff.from, request.runtimeSessionId) }, + to: { roomId: request.roomId, laneId: request.laneId, participantId: participantIdFor(request.handoff.to, request.runtimeSessionId) }, + decision: "deliver", + reason: request.handoff.reason, + timestamp: Date.now() + }, resolveLedgerSessionId(request)); + if (!route.ok) + return route; + } + const entry = await ledger.appendHandoff(request.handoff, request.handoff.from, depth, request.handles_handoff_id, { + ...runtimeSession.meta ?? {}, + ...meshScopeMeta(request) + }, resolveLedgerSessionId(request)); return { ok: true, entry, sessionId: entry.sessionId }; } case "ask_backup": { @@ -6911,13 +8099,15 @@ async function executeLedgerTool(request) { const sid = request.sessionId ?? sessionId; const entries = ledger.read(sid, 1000); const taskBoard = deriveTaskBoard(entries, { claudeResponseTimeoutMs: CLAUDE_RESPONSE_TIMEOUT_MS }); - const registryInspection = inspectSessionRegistry(PROJECT_ROOT, INSTANCE_ID); + const registryInspection = inspectRuntimeSessionRegistry(); return { ok: true, summary: { ...ledger.summarize(sid), sessions: registryInspection.sessions, sessionRegistryWarning: registryInspection.warning, + meshModeEnabled: meshModeEnabled(), + roomRegistry: roomRegistry.inspect(), taskBoard, policy: buildPolicyState(configService.loadOrDefault(), taskBoard, activeRuntimeSession().backupInFlight), autonomy: configService.loadOrDefault().autonomy, @@ -7041,9 +8231,295 @@ async function executeLedgerTool(request) { const runtimeSession = resolveRuntimeSessionMeta(request); if (!runtimeSession.ok) return runtimeSession.result; - const entry = await ledger.appendArtifact(request.artifact, request.source ?? "claude", request.target, request.sessionId, runtimeSession.meta); + const sid = resolveLedgerSessionId(request); + const entry = await ledger.appendArtifact(request.artifact, request.source ?? "claude", request.target, sid, { + ...runtimeSession.meta ?? {}, + ...meshScopeMeta(request), + subagentAssisted: request.artifact.subagentAssisted === true ? true : undefined + }); + if (request.roomId || request.laneId) { + await ledger.append({ + sessionId: sid, + type: "route", + source: "system", + content: `Room heartbeat: artifact recorded${request.roomId ? ` in room ${request.roomId}` : ""}${request.laneId ? ` lane ${request.laneId}` : ""}: ${request.artifact.title}`, + meta: { + heartbeat: true, + artifact_kind: request.artifact.kind, + ...meshScopeMeta(request) + } + }); + } return { ok: true, entry, sessionId: entry.sessionId }; } + case "request_approval": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const { registry, approval, replayed } = roomRegistry.requestApproval(request.roomId, request.laneId, { + requesterParticipantId: request.requesterParticipantId, + reviewerParticipantId: request.reviewerParticipantId, + action: request.action, + runtimeSessionId: request.runtimeSessionId, + idempotencyKey: request.idempotencyKey, + expiresAt: request.expiresAt, + ttlMs: request.ttlMs, + reason: request.reason, + provenance: request.provenance + }); + const sid = resolveLedgerSessionId(request); + const entry = await ledger.append({ + sessionId: sid, + type: "approval_requested", + source: "system", + target: "codex", + content: `approval requested: ${approval.action} in ${request.roomId}/${request.laneId}`, + meta: { + approval, + replayed, + roomId: request.roomId, + laneId: request.laneId, + runtimeSessionId: approval.runtimeSessionId, + idempotencyKey: approval.idempotencyKey + } + }); + return { + ...roomRegistryResult(registry, request.sessionId, replayed ? `Approval ${approval.approvalId} already pending.` : `Approval ${approval.approvalId} requested.`), + entry + }; + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "decide_approval": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const { registry, approval } = roomRegistry.decideApproval(request.roomId, request.laneId, request.approvalId, { + reviewerParticipantId: request.reviewerParticipantId, + decision: request.decision + }); + const sid = resolveLedgerSessionId({ sessionId: request.sessionId, runtimeSessionId: approval.runtimeSessionId }); + const entry = await ledger.append({ + sessionId: sid, + type: "approval_decided", + source: "system", + target: "codex", + content: `approval ${request.decision}: ${approval.action} in ${request.roomId}/${request.laneId}`, + meta: { + approval, + decision: request.decision, + reason: request.reason, + roomId: request.roomId, + laneId: request.laneId, + runtimeSessionId: approval.runtimeSessionId, + idempotencyKey: approval.idempotencyKey + } + }); + return { + ...roomRegistryResult(registry, request.sessionId, `Approval ${request.approvalId} ${request.decision}.`), + entry + }; + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "expire_approvals": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const { registry, expired } = roomRegistry.expireApprovals(); + const entries = []; + for (const item of expired) { + const sid = resolveLedgerSessionId({ sessionId: request.sessionId, runtimeSessionId: item.approval.runtimeSessionId }); + entries.push(await ledger.append({ + sessionId: sid, + type: "approval_expired", + source: "system", + target: "codex", + content: `approval expired: ${item.approval.action} in ${item.roomId}/${item.laneId}`, + meta: { + approval: item.approval, + roomId: item.roomId, + laneId: item.laneId, + runtimeSessionId: item.approval.runtimeSessionId, + idempotencyKey: item.approval.idempotencyKey + } + })); + } + return { + ...roomRegistryResult(registry, request.sessionId, `${expired.length} approval${expired.length === 1 ? "" : "s"} expired.`), + entries + }; + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "mesh_status": { + return { + ok: true, + summary: { + enabled: meshModeEnabled(), + configEnabled: configService.loadOrDefault().features.meshMode.enabled, + envEnabled: envValue("CONTEXTRELAY_ENABLE_MESH") !== undefined, + roomRegistry: roomRegistry.inspect() + }, + sessionId: request.sessionId ?? sessionId + }; + } + case "create_room": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const registry = roomRegistry.createRoom(request.id, { + label: request.label, + coordinatorParticipantId: request.coordinatorParticipantId, + reviewerParticipantId: request.reviewerParticipantId, + pinnedContext: request.pinnedContext + }); + return roomRegistryResult(registry, request.sessionId, `Room ${request.id} created.`); + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "select_room": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + return roomRegistryResult(roomRegistry.selectRoom(request.roomId), request.sessionId, `Room ${request.roomId} selected.`); + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "room_info": { + const gate = requireMeshMode(); + if (gate) + return gate; + const inspection = roomRegistry.inspect(); + const room = request.roomId ? inspection.rooms.find((item) => item.id === request.roomId) : undefined; + if (request.roomId && !room) + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: `Unknown room: ${request.roomId}` }; + return { + ok: true, + summary: request.roomId ? { room, warning: inspection.warning } : inspection, + sessionId: request.sessionId ?? sessionId + }; + } + case "archive_room": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const { registry, alreadyArchived } = roomRegistry.archiveRoom(request.roomId); + return { ...roomRegistryResult(registry, request.sessionId, alreadyArchived ? `Room ${request.roomId} was already archived.` : `Room ${request.roomId} archived.`), alreadyArchived }; + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "add_room_member": + case "remove_room_member": + case "assign_room_coordinator": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const registry = request.type === "add_room_member" ? roomRegistry.addMember(request.roomId, request.participantId) : request.type === "remove_room_member" ? roomRegistry.removeMember(request.roomId, request.participantId) : roomRegistry.assignCoordinator(request.roomId, request.participantId); + return roomRegistryResult(registry, request.sessionId, `Room ${request.roomId} updated.`); + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "create_lane": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + return roomRegistryResult(roomRegistry.createLane(request.roomId, request.laneId, { + label: request.label, + participantId: request.participantId, + worktreePath: request.worktreePath, + permissions: request.permissions?.filter(isPermissionCapability) + }), request.sessionId, `Lane ${request.laneId} created in room ${request.roomId}.`); + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "enter_lane": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const participantId = request.participantId ?? participantIdFor(request.source ?? "agent", request.runtimeSessionId); + return roomRegistryResult(roomRegistry.enterLane(request.roomId, request.laneId, participantId), request.sessionId, `Participant ${participantId} entered lane ${request.laneId} in room ${request.roomId}.`); + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "lane_info": { + const gate = requireMeshMode(); + if (gate) + return gate; + const inspection = roomRegistry.inspect(); + const room = inspection.rooms.find((item) => item.id === request.roomId); + if (!room) + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: `Unknown room: ${request.roomId}` }; + const lane = request.laneId ? room.lanes[request.laneId] : undefined; + if (request.laneId && !lane) + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: `Unknown lane ${request.laneId} in room ${request.roomId}` }; + return { + ok: true, + summary: request.laneId ? { roomId: request.roomId, lane, warning: inspection.warning } : { roomId: request.roomId, lanes: room.lanes, activeLaneId: room.activeLaneId, warning: inspection.warning }, + sessionId: request.sessionId ?? sessionId + }; + } + case "complete_lane": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const registry = roomRegistry.completeLane(request.roomId, request.laneId); + await ledger.append({ + type: "route", + source: "system", + content: `Room heartbeat: lane ${request.laneId} completed in room ${request.roomId}.`, + meta: { heartbeat: true, roomId: request.roomId, laneId: request.laneId } + }); + return roomRegistryResult(registry, request.sessionId, `Lane ${request.laneId} completed.`); + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "archive_lane": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const { registry, alreadyArchived } = roomRegistry.archiveLane(request.roomId, request.laneId); + return { ...roomRegistryResult(registry, request.sessionId, alreadyArchived ? `Lane ${request.laneId} was already archived.` : `Lane ${request.laneId} archived.`), alreadyArchived }; + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "assign_participant_to_lane": + case "remove_participant_from_lane": { + const gate = requireMeshMode(); + if (gate) + return gate; + try { + const registry = request.type === "assign_participant_to_lane" ? roomRegistry.assignParticipantToLane(request.roomId, request.laneId, request.participantId) : roomRegistry.removeParticipantFromLane(request.roomId, request.laneId, request.participantId); + return roomRegistryResult(registry, request.sessionId, `Lane ${request.laneId} updated.`); + } catch (err) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "route_event": { + return recordRouteDecision(request.route, request.sessionId ?? sessionId); + } } } function resolveRuntimeSessionId(request) { @@ -7073,6 +8549,112 @@ function resolveRuntimeSessionId(request) { } return { ok: true, runtimeSessionId }; } +function meshModeEnabled() { + return isMeshModeEnabled(configService.loadOrDefault()); +} +function requireMeshMode() { + if (meshModeEnabled()) + return null; + return { + ok: false, + code: ErrorCode.INVALID_REQUEST, + error: "Mesh mode is disabled. Enable it with `contextrelay mesh enable` or set CONTEXTRELAY_ENABLE_MESH=1." + }; +} +function startApprovalExpirySweep() { + if (APPROVAL_EXPIRY_SWEEP_INTERVAL_MS <= 0 || approvalExpirySweepTimer) + return; + approvalExpirySweepTimer = setInterval(() => { + sweepExpiredApprovals().catch((err) => { + log(`Approval expiry sweep failed: ${err?.message ?? String(err)}`); + }); + }, APPROVAL_EXPIRY_SWEEP_INTERVAL_MS); + approvalExpirySweepTimer.unref?.(); +} +async function sweepExpiredApprovals() { + if (approvalExpirySweepInFlight || !meshModeEnabled()) + return; + approvalExpirySweepInFlight = true; + try { + const { expired } = roomRegistry.expireApprovals(); + for (const item of expired) { + const sid = resolveLedgerSessionId({ runtimeSessionId: item.approval.runtimeSessionId }); + await ledger.append({ + sessionId: sid, + type: "approval_expired", + source: "system", + target: "codex", + content: `approval expired: ${item.approval.action} in ${item.roomId}/${item.laneId}`, + meta: { + approval: item.approval, + roomId: item.roomId, + laneId: item.laneId, + runtimeSessionId: item.approval.runtimeSessionId, + idempotencyKey: item.approval.idempotencyKey + } + }); + } + if (expired.length > 0) { + log(`Expired ${expired.length} pending approval${expired.length === 1 ? "" : "s"}.`); + } + } finally { + approvalExpirySweepInFlight = false; + } +} +function resolveLedgerSessionId(request) { + if (request.sessionId) + return request.sessionId; + if (!meshModeEnabled() || !request.runtimeSessionId) + return sessionId; + const resolved = resolveRuntimeSessionId({ runtimeSessionId: request.runtimeSessionId }); + if (!resolved.ok) + return sessionId; + const registry = sessionRegistry.load().registry; + const runtime = registry.sessions[request.runtimeSessionId]; + if (runtime?.transcriptSessionId) + return runtime.transcriptSessionId; + const transcriptSessionId = request.runtimeSessionId === DEFAULT_SESSION_ID ? sessionId : `session_${Date.now()}_${randomUUID3().slice(0, 8)}`; + try { + sessionRegistry.ensureTranscriptSessionId(request.runtimeSessionId, transcriptSessionId); + } catch (err) { + log(`Failed to persist transcriptSessionId for ${request.runtimeSessionId}: ${err?.message ?? String(err)}`); + } + return transcriptSessionId; +} +function meshScopeMeta(request) { + return { + ...request.roomId ? { roomId: request.roomId } : {}, + ...request.laneId ? { laneId: request.laneId } : {} + }; +} +function roomRegistryResult(registry, sid, message) { + const inspection = inspectRoomRegistryData(registry, isForeignRoomRegistryIssue(roomRegistry.lastIssue ?? undefined) ? roomRegistry.lastIssue ?? undefined : undefined); + return { + ok: true, + summary: { ...inspection }, + sessionId: sid ?? sessionId, + message + }; +} +async function recordRouteDecision(route, sid) { + const entry = await ledger.append({ + sessionId: sid, + type: "route", + source: "system", + target: "claude", + content: `route ${route.decision}: ${route.reason}`, + meta: { + route, + traceId: route.traceId, + roomId: route.from.roomId ?? route.to.roomId, + laneId: route.from.laneId ?? route.to.laneId + } + }); + return { ok: true, entry, sessionId: entry.sessionId }; +} +function isPermissionCapability(value) { + return value === "read" || value === "write" || value === "shell" || value === "network" || value === "git" || value === "secrets" || value === "browser" || value === "external_api"; +} function resolveRuntimeSessionMeta(request) { const resolved = resolveRuntimeSessionId(request); if (!resolved.ok) @@ -7090,7 +8672,7 @@ function resolveLiveRuntimeSession(request) { if (runtimeSessionId === DEFAULT_SESSION_ID) { return { ok: true, session: activeRuntimeSession() }; } - if (!optInNamedSessionsEnabled()) { + if (!optInNamedSessionsEnabled(runtimeSessionId)) { return { ok: false, code: ErrorCode.INVALID_REQUEST, @@ -7792,7 +9374,7 @@ function currentStatus() { const taskBoard = deriveTaskBoard(ledger.read(sessionId, 1000), { claudeResponseTimeoutMs: CLAUDE_RESPONSE_TIMEOUT_MS }); const activeSession = activeRuntimeSession(); const activeCodex = activeSession.codexRuntime; - const registryInspection = inspectSessionRegistry(PROJECT_ROOT, INSTANCE_ID); + const registryInspection = inspectRuntimeSessionRegistry(); const operationPaths = new Map([[DEFAULT_SESSION_ID, PROJECT_ROOT]]); for (const session of registryInspection.sessions) { if (session.worktreePath) @@ -7801,6 +9383,8 @@ function currentStatus() { const runtimeSessions = buildRuntimeSessionStatusMap(taskBoard, operationPaths); const activeSessionStatus = runtimeSessions[activeSession.id] ?? buildRuntimeSessionStatus(activeSession, taskBoard, operationPaths.get(activeSession.id)); const defaultSessionStatus = runtimeSessions[DEFAULT_SESSION_ID] ?? activeSessionStatus; + const meshEnabled = meshModeEnabled(); + const roomRegistryInspection = roomRegistry.inspect(); return { instanceId: INSTANCE_ID, projectRoot: PROJECT_ROOT, @@ -7837,9 +9421,15 @@ function currentStatus() { sessions: runtimeSessions, defaultSession: defaultSessionStatus, registrySessions: registryInspection.sessions, - sessionRegistryWarning: registryInspection.warning + sessionRegistryWarning: registryInspection.warning, + meshModeEnabled: meshEnabled, + roomRegistry: roomRegistryInspection }; } +function inspectRuntimeSessionRegistry() { + const result = sessionRegistry.load(); + return inspectSessionRegistryData(result.registry, result.registry.activeSessionId, result.issue); +} function viewerEventStreamResponse() { let client = null; const stream = new ReadableStream({ @@ -7914,7 +9504,9 @@ function toRecentActivityEntry(entry) { target: entry.target, content: entry.content, status: typeof entry.meta?.runtime_event_status === "string" ? entry.meta.runtime_event_status : typeof entry.meta?.artifact_status === "string" ? entry.meta.artifact_status : undefined, - runtimeSessionId: typeof entry.meta?.runtimeSessionId === "string" ? entry.meta.runtimeSessionId : undefined + runtimeSessionId: typeof entry.meta?.runtimeSessionId === "string" ? entry.meta.runtimeSessionId : undefined, + roomId: typeof entry.meta?.roomId === "string" ? entry.meta.roomId : undefined, + laneId: typeof entry.meta?.laneId === "string" ? entry.meta.laneId : undefined }; } function isLiveActivityEntry(entry) { @@ -7924,7 +9516,7 @@ function isLiveActivityEntry(entry) { return false; if (entry.type === "note") return false; - return entry.type === "message" || entry.type === "handoff" || entry.type === "finality_proposal" || entry.type === "finality_decision" || entry.type === "backup_request" || entry.type === "backup_result" || entry.type === "release_gate" || entry.type === "artifact" || entry.type === "runtime_event" || entry.type === "error" || entry.type === "decision"; + return entry.type === "message" || entry.type === "handoff" || entry.type === "finality_proposal" || entry.type === "finality_decision" || entry.type === "backup_request" || entry.type === "backup_result" || entry.type === "release_gate" || entry.type === "artifact" || entry.type === "route" || entry.type === "runtime_event" || entry.type === "error" || entry.type === "decision"; } function isTuiActivityEntry(entry) { if (entry.type === "handoff") @@ -7935,6 +9527,8 @@ function isTuiActivityEntry(entry) { return true; if (entry.type === "release_gate") return true; + if (entry.type === "route") + return true; if (entry.type !== "runtime_event") return false; return entry.runtimeEvent?.status === "failed" || entry.runtimeEvent?.status === "blocked"; @@ -7977,6 +9571,12 @@ function currentViewerContext(url) { tuiConnected: status.tuiConnected, queuedMessageCount: status.queuedMessageCount }, + mesh: { + enabled: Boolean(status.meshModeEnabled), + configEnabled: configService.loadOrDefault().features.meshMode.enabled, + envEnabled: envValue("CONTEXTRELAY_ENABLE_MESH") !== undefined, + roomRegistry: status.roomRegistry ?? roomRegistry.inspect() + }, claudeResponseTimeoutMs: CLAUDE_RESPONSE_TIMEOUT_MS }) }; @@ -8084,6 +9684,15 @@ async function shutdown(reason) { shuttingDown = true; log(`Shutting down daemon (${reason})...`); const stepResults = await runShutdownSteps([ + { + name: "approval_expiry_sweep", + run: () => { + if (approvalExpirySweepTimer) { + clearInterval(approvalExpirySweepTimer); + approvalExpirySweepTimer = null; + } + } + }, { name: "tui_connection_state", run: () => activeRuntimeSession().tuiConnectionState.dispose(`daemon shutdown (${reason})`) @@ -8166,5 +9775,7 @@ if (daemonLifecycle.wasKilled()) { } writePidFile(); log(`Build: commit=${BUILD_INFO.commit.slice(0, 12)} builtAt=${BUILD_INFO.builtAt} bun=${BUILD_INFO.bunVersion}`); +startApprovalExpirySweep(); startControlServer(); +writeStatusFile(); bootCodex(); diff --git a/src/cli.ts b/src/cli.ts index 7d4a9eb..49685b9 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,9 @@ * contextrelay detach-claude — Detach the active Claude foreground session * contextrelay status — Print daemon, session, and ledger status * contextrelay session — List, create, and select runtime sessions + * contextrelay mesh — Enable, disable, and inspect optional mesh mode + * contextrelay room — Manage mesh rooms + * contextrelay lane — Manage mesh lanes * contextrelay recover — Summarize crash recovery context * contextrelay instances — List known project instances and port groups * contextrelay viewer — Open the browser Command Deck @@ -100,6 +103,22 @@ async function main() { const { runSession } = await import("./cli/session"); await runSession(restArgs); break; + case "mesh": + const { runMesh } = await import("./cli/mesh"); + await runMesh(restArgs); + break; + case "worktree": + const { runWorktree } = await import("./cli/worktree"); + await runWorktree(restArgs); + break; + case "room": + const { runRoom } = await import("./cli/room"); + await runRoom(restArgs); + break; + case "lane": + const { runLane } = await import("./cli/lane"); + await runLane(restArgs); + break; case "recover": const { runRecover } = await import("./cli/recover"); await runRecover(restArgs); @@ -183,6 +202,12 @@ Commands: status [--json] Print daemon, session, and ledger status as JSON session list|create|select|archive|rebind [--json] [--worktree ] List, create, select, archive, and rebind runtime sessions + mesh status|start|enable|disable [--json] + Start, enable, disable, and inspect optional room/lane mesh mode + room list|create|view|select|archive|add-member|remove-member|assign-coordinator + Manage mesh rooms + lane list|create|view|enter|assign|remove-participant|complete|archive + Manage mesh lanes inside rooms recover [--json] Summarize crash recovery context and resume prompt instances List known project instances and assigned ports viewer [--no-open] @@ -219,6 +244,11 @@ Examples: ${SHORT_BIN} detach-claude # Clear a stale active Claude attachment ${SHORT_BIN} status # Show daemon and shared session status ${SHORT_BIN} session list # Show runtime sessions + ${SHORT_BIN} mesh status # Show optional mesh-mode state + ${SHORT_BIN} mesh start review # Start a room/lane mesh wizard + ${SHORT_BIN} room create release # Create a mesh room + ${SHORT_BIN} lane create release tests --worker codex --worktree ../contextrelay-tests + # Create a write-capable lane ${SHORT_BIN} session create side --worktree ../contextrelay-side # Bind a named session to a worktree ${SHORT_BIN} session rebind side --worktree ../contextrelay-side diff --git a/src/cli/claude.ts b/src/cli/claude.ts index d4f7a7a..689d208 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -10,6 +10,7 @@ import { ensureNamedSessionRuntimeViaDaemon, maybePrintNamedSessionCreatedNotice /** Flags that ContextRelay owns and will inject automatically. */ const OWNED_FLAGS = ["--channels", "--dangerously-load-development-channels"]; const DEVELOPMENT_CHANNELS_ENV = "CONTEXTRELAY_CLAUDE_DEVELOPMENT_CHANNELS"; +const MESH_ENV = "CONTEXTRELAY_ENABLE_MESH"; export async function runClaude(args: string[]) { if (args.includes("--help") || args.includes("-h")) { @@ -123,11 +124,12 @@ export function buildClaudeArgs(args: string[], env: NodeJS.ProcessEnv = process // or "plugin:@" for plugin-based channels. // ContextRelay keeps the contextrelay plugin id for compatibility. const channelEntry = `plugin:${PLUGIN_NAME}@${MARKETPLACE_NAME}`; - const useDevelopmentChannels = envValueFrom(env, DEVELOPMENT_CHANNELS_ENV) !== "0"; + const developmentChannels = envValueFrom(env, DEVELOPMENT_CHANNELS_ENV); + const meshMode = envValueFrom(env, MESH_ENV) === "1"; + const useDevelopmentChannels = developmentChannels === "1" || (!meshMode && developmentChannels !== "0"); if (useDevelopmentChannels) { return [ - "--dangerously-load-development-channels", - channelEntry, + `--dangerously-load-development-channels=${channelEntry}`, ...args, ]; } @@ -138,8 +140,7 @@ export function buildClaudeArgs(args: string[], env: NodeJS.ProcessEnv = process channelsEnabled: true, allowedChannelPlugins: [{ marketplace: MARKETPLACE_NAME, plugin: PLUGIN_NAME }], }), - "--channels", - channelEntry, + `--channels=${channelEntry}`, ...args, ]; } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index d335781..0b2ebab 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -6,6 +6,7 @@ import { buildInstanceEnv, resolveProjectInstance } from "../instance"; import { readLocalAuthToken } from "../local-auth"; import { StateDirResolver } from "../state-dir"; import { PRIMARY_BIN } from "../branding"; +import { detectTmux } from "../session/terminal-launcher"; interface Check { name: string; @@ -18,6 +19,12 @@ export async function runDoctor(args: string[]) { printDoctorHelp(); return; } + if (args[0] === "tmux") { + const result = detectTmux(); + console.log(`tmux: ${result.available ? "available" : "unavailable"} (${result.detail})`); + if (!result.available) process.exit(1); + return; + } const skipAuth = args.includes("--no-auth"); const unsupported = args.filter((arg) => arg !== "--no-auth"); diff --git a/src/cli/kill.ts b/src/cli/kill.ts index 736bb9a..d4d626c 100644 --- a/src/cli/kill.ts +++ b/src/cli/kill.ts @@ -4,7 +4,7 @@ import { DISPLAY_NAME, PRIMARY_BIN } from "../branding"; import { buildInstanceEnv, listRegisteredInstances, resolveProjectInstance, type ProjectInstance } from "../instance"; import { appendLocalAuthToken, readLocalAuthToken } from "../local-auth"; import { DaemonClient } from "../daemon-client"; -import { DEFAULT_RUNTIME_SESSION_ID, isForeignRegistryIssue, loadSessionRegistry } from "../session/registry"; +import { DEFAULT_RUNTIME_SESSION_ID, isForeignRegistryIssue, loadSessionRegistryFromPath, sessionRegistryStatePath } from "../session/registry"; import { killManagedCodexTui, runtimeTuiStateDir } from "../managed-codex-tui"; export interface KillCommand { @@ -126,7 +126,7 @@ async function runKillSession(sessionId: string): Promise { const instance = await resolveProjectInstance({ persist: false }); const baseStateDir = new StateDirResolver(instance.stateDir); const sessionStateDir = runtimeTuiStateDir(baseStateDir, sessionId); - const registry = loadSessionRegistry(instance.projectRoot, instance.instanceId); + const registry = loadSessionRegistryFromPath(sessionRegistryStatePath(instance.stateDir), instance.instanceId); if (isForeignRegistryIssue(registry.issue)) { console.error(`Error: ${registry.issue}`); process.exit(1); diff --git a/src/cli/lane.ts b/src/cli/lane.ts new file mode 100644 index 0000000..5f3c999 --- /dev/null +++ b/src/cli/lane.ts @@ -0,0 +1,121 @@ +import { PRIMARY_BIN } from "../branding"; +import { callMeshLedgerTool, printJsonOrSummary } from "./mesh-control"; + +export async function runLane(args: string[] = []): Promise { + const json = args.includes("--json"); + const filtered = args.filter((arg) => arg !== "--json"); + const action = filtered[0] ?? "list"; + + if (action === "list") { + const roomId = requiredArg(filtered[1], "lane list requires a room id"); + printJsonOrSummary(await callMeshLedgerTool({ type: "lane_info", roomId }), json); + return; + } + if (action === "create") { + const roomId = requiredArg(filtered[1], "lane create requires a room id"); + const laneId = requiredArg(filtered[2], "lane create requires a lane id"); + const opts = parseOptions(filtered.slice(3)); + printJsonOrSummary(await callMeshLedgerTool({ + type: "create_lane", + roomId, + laneId, + label: opts.label, + participantId: opts.worker, + worktreePath: opts.worktree, + permissions: opts.permissions, + }), json); + return; + } + if (action === "view" || action === "info") { + const roomId = requiredArg(filtered[1], `lane ${action} requires a room id`); + printJsonOrSummary(await callMeshLedgerTool({ type: "lane_info", roomId, laneId: filtered[2] }), json); + return; + } + if (action === "enter" || action === "select") { + const roomId = requiredArg(filtered[1], `lane ${action} requires a room id`); + const laneId = requiredArg(filtered[2], `lane ${action} requires a lane id`); + const opts = parseOptions(filtered.slice(3)); + printJsonOrSummary(await callMeshLedgerTool({ + type: "enter_lane", + roomId, + laneId, + participantId: opts.worker, + runtimeSessionId: opts.session, + }), json); + return; + } + if (action === "assign") { + const roomId = requiredArg(filtered[1], "lane assign requires a room id"); + const laneId = requiredArg(filtered[2], "lane assign requires a lane id"); + const participantId = requiredArg(filtered[3], "lane assign requires a participant id"); + printJsonOrSummary(await callMeshLedgerTool({ type: "assign_participant_to_lane", roomId, laneId, participantId }), json); + return; + } + if (action === "remove-participant") { + const roomId = requiredArg(filtered[1], "lane remove-participant requires a room id"); + const laneId = requiredArg(filtered[2], "lane remove-participant requires a lane id"); + const participantId = requiredArg(filtered[3], "lane remove-participant requires a participant id"); + printJsonOrSummary(await callMeshLedgerTool({ type: "remove_participant_from_lane", roomId, laneId, participantId }), json); + return; + } + if (action === "complete" || action === "archive") { + const roomId = requiredArg(filtered[1], `lane ${action} requires a room id`); + const laneId = requiredArg(filtered[2], `lane ${action} requires a lane id`); + printJsonOrSummary(await callMeshLedgerTool({ + type: action === "complete" ? "complete_lane" : "archive_lane", + roomId, + laneId, + }), json); + return; + } + if (action === "--help" || action === "-h") { + printLaneHelp(); + return; + } + + console.error(`Unknown lane command: ${action}`); + printLaneHelp(); + process.exit(1); +} + +function parseOptions(args: string[]): { label?: string; worker?: string; session?: string; worktree?: string; permissions?: string[] } { + const result: { label?: string; worker?: string; session?: string; worktree?: string; permissions?: string[] } = {}; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--label") result.label = requiredArg(args[++index], "--label requires a value"); + else if (arg.startsWith("--label=")) result.label = arg.slice("--label=".length); + else if (arg === "--worker" || arg === "--participant") result.worker = requiredArg(args[++index], `${arg} requires a value`); + else if (arg.startsWith("--worker=")) result.worker = arg.slice("--worker=".length); + else if (arg.startsWith("--participant=")) result.worker = arg.slice("--participant=".length); + else if (arg === "--session") result.session = requiredArg(args[++index], "--session requires a value"); + else if (arg.startsWith("--session=")) result.session = arg.slice("--session=".length); + else if (arg === "--worktree") result.worktree = requiredArg(args[++index], "--worktree requires a value"); + else if (arg.startsWith("--worktree=")) result.worktree = arg.slice("--worktree=".length); + else if (arg === "--permissions") result.permissions = requiredArg(args[++index], "--permissions requires a value").split(",").map((part) => part.trim()).filter(Boolean); + else if (arg.startsWith("--permissions=")) result.permissions = arg.slice("--permissions=".length).split(",").map((part) => part.trim()).filter(Boolean); + else throw new Error(`Unknown lane option: ${arg}`); + } + return result; +} + +function requiredArg(value: string | undefined, message: string): string { + if (!value) { + console.error(message); + process.exit(1); + } + return value; +} + +function printLaneHelp(): void { + console.log(` +Usage: + ${PRIMARY_BIN} lane list [--json] + ${PRIMARY_BIN} lane create [--worker ] [--worktree ] [--permissions read,write] + ${PRIMARY_BIN} lane view [id] + ${PRIMARY_BIN} lane enter [--worker ] [--session ] + ${PRIMARY_BIN} lane assign + ${PRIMARY_BIN} lane remove-participant + ${PRIMARY_BIN} lane complete + ${PRIMARY_BIN} lane archive +`.trim()); +} diff --git a/src/cli/mesh-control.ts b/src/cli/mesh-control.ts new file mode 100644 index 0000000..b067394 --- /dev/null +++ b/src/cli/mesh-control.ts @@ -0,0 +1,64 @@ +import { DaemonClient } from "../daemon-client"; +import { DaemonLifecycle } from "../daemon-lifecycle"; +import { appendLocalAuthToken, readLocalAuthToken } from "../local-auth"; +import { buildInstanceEnv, resolveProjectInstance, type ProjectInstance } from "../instance"; +import { StateDirResolver } from "../state-dir"; +import { SHORT_BIN } from "../branding"; +import type { LedgerToolRequest, LedgerToolResult } from "../control-protocol"; + +export async function callMeshLedgerTool(request: LedgerToolRequest, resolvedInstance?: ProjectInstance): Promise { + const instance = resolvedInstance ?? await resolveProjectInstance(); + const stateDir = new StateDirResolver(instance.stateDir); + const lifecycle = createLifecycle(instance, stateDir); + lifecycle.clearKilled(); + await lifecycle.ensureRunning({ waitForReady: false }); + const token = readLocalAuthToken(stateDir); + if (!token) { + throw new Error(`Control auth token was not found. Try: ${SHORT_BIN} kill && ${SHORT_BIN} tui`); + } + const client = new DaemonClient(appendLocalAuthToken(lifecycle.controlWsUrl, token)); + try { + await client.connect(); + return await client.callLedgerTool(request); + } finally { + await client.disconnect(); + } +} + +export function withInstanceEnv(command: string, instance: ProjectInstance): string { + const env = [ + ["CONTEXTRELAY_INSTANCE_ID", instance.instanceId], + ["CONTEXTRELAY_PROJECT_ROOT", instance.projectRoot], + ["CONTEXTRELAY_STATE_DIR", instance.stateDir], + ["CONTEXTRELAY_CONTROL_PORT", String(instance.controlPort)], + ["CODEX_WS_PORT", String(instance.appPort)], + ["CODEX_PROXY_PORT", String(instance.proxyPort)], + ]; + return `env ${env.map(([key, value]) => `${key}=${shellArg(value)}`).join(" ")} sh -lc ${shellArg(command)}`; +} + +export function printJsonOrSummary(result: LedgerToolResult, json: boolean): void { + if (!result.ok) { + console.error(result.error); + process.exit(1); + } + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + if (result.message) console.log(result.message); + if (result.summary) console.log(JSON.stringify(result.summary, null, 2)); +} + +function createLifecycle(instance: ProjectInstance, stateDir: StateDirResolver): DaemonLifecycle { + return new DaemonLifecycle({ + stateDir, + controlPort: instance.controlPort, + env: buildInstanceEnv(instance), + log: (msg) => console.error(`[contextrelay] ${msg}`), + }); +} + +function shellArg(value: string): string { + return /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/src/cli/mesh.ts b/src/cli/mesh.ts new file mode 100644 index 0000000..4685a96 --- /dev/null +++ b/src/cli/mesh.ts @@ -0,0 +1,307 @@ +import { ConfigService, isMeshModeEnabled } from "../config-service"; +import type { MeshLaunchBackend } from "../config-service"; +import { PRIMARY_BIN } from "../branding"; +import { callMeshLedgerTool, printJsonOrSummary, withInstanceEnv } from "./mesh-control"; +import { buildMeshWizardPlan, executeMeshWizardPlan, type MeshWizardMode } from "../session/mesh-wizard"; +import { detectTmux, launchWorkerTerminals, tmuxSessionIsContextRelayOwned, tmuxSessionName, type TerminalLaunchResult } from "../session/terminal-launcher"; +import { installTmux, resolveTmuxInstallPlan } from "../session/tmux-installer"; +import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; +import { resolveProjectInstance } from "../instance"; + +export async function runMesh(args: string[] = []): Promise { + const json = args.includes("--json"); + const filtered = args.filter((arg) => arg !== "--json"); + const action = filtered[0] ?? "status"; + const service = new ConfigService(); + const config = service.loadOrDefault(); + + if (action === "status") { + const result = await callMeshLedgerTool({ type: "mesh_status" }); + if (json) { + printJsonOrSummary(result, true); + return; + } + console.log(`Mesh mode: ${isMeshModeEnabled(config) ? "enabled" : "disabled"}`); + console.log(`Config: ${config.features.meshMode.enabled ? "enabled" : "disabled"}`); + console.log(`Env override: ${process.env.CONTEXTRELAY_ENABLE_MESH ? "enabled" : "disabled"}`); + if (result.ok && result.summary) console.log(JSON.stringify(result.summary.roomRegistry ?? {}, null, 2)); + return; + } + + if (action === "enable" || action === "disable") { + config.features.meshMode.enabled = action === "enable"; + service.save(config); + console.log(`Mesh mode ${action === "enable" ? "enabled" : "disabled"} in ${service.configFilePath}.`); + return; + } + + if (action === "start") { + if (filtered[1] === "--help" || filtered[1] === "-h") { + printMeshHelp(); + return; + } + const opts = parseMeshStartOptions(filtered.slice(1), { backend: meshBackendDefault(config.launch.backend) }); + if (!config.features.meshMode.enabled) { + config.features.meshMode.enabled = true; + service.save(config); + if (!json) console.log(`Mesh mode enabled in ${service.configFilePath}.`); + } + const plan = buildMeshWizardPlan({ + roomId: opts.roomId, + codexWorkers: opts.codexWorkers, + claudeWorkers: opts.claudeWorkers, + mode: opts.mode, + worktreePrefix: opts.worktreePrefix, + commandBin: PRIMARY_BIN, + }); + const instance = await resolveProjectInstance(); + const result = await executeMeshWizardPlan(plan, (request) => callMeshLedgerTool(request, instance)); + if (!result.ok) { + if (json) console.log(JSON.stringify({ ...result, plan }, null, 2)); + console.error(result.error ?? "Mesh start failed."); + process.exit(1); + } + const launchCommands = result.launchCommands.map((command) => withInstanceEnv(command, instance)); + const tmuxReady = opts.launchTerminals && opts.backend === "tmux" + ? await ensureTmuxForMesh({ assumeYes: opts.assumeYes, json }) + : true; + const terminalResult: TerminalLaunchResult = opts.launchTerminals && tmuxReady + ? launchWorkerTerminals(launchCommands, { cwd: process.cwd(), backend: opts.backend, roomId: opts.roomId }) + : opts.launchTerminals + ? { ok: false, launched: 0, skipped: "tmux is required for automatic mesh launch and is not installed." } + : { ok: false, launched: 0, skipped: "Automatic terminal launch disabled by --no-launch-terminals." }; + if (json) { + console.log(JSON.stringify({ ...result, launchCommands, plan, terminalResult }, null, 2)); + return; + } + console.log(`Mesh room ready: ${opts.roomId}`); + for (const message of result.messages) console.log(`- ${message}`); + for (const warning of result.warnings) console.log(`warning: ${warning}`); + printTerminalLaunchSummary(terminalResult); + console.log("\nWorker commands:"); + for (const command of launchCommands) console.log(command); + return; + } + + if (action === "attach") { + if (process.env.CONTEXTRELAY_ENABLE_MESH_ATTACH !== "1") { + console.error("mesh attach is reserved for the v1.2.7 rehydration slice and is hidden by default. Set CONTEXTRELAY_ENABLE_MESH_ATTACH=1 only for local development."); + process.exit(1); + } + const roomId = requiredArg(filtered[1], "mesh attach requires a room id"); + const roomInfo = await callMeshLedgerTool({ type: "room_info", roomId }); + if (!roomInfo.ok) { + console.error(roomInfo.error); + process.exit(1); + } + const tmux = detectTmux(); + if (!tmux.available) { + console.error(`Cannot attach mesh room ${roomId}: tmux unavailable (${tmux.detail}).`); + process.exit(1); + } + const sessionName = tmuxSessionName(roomId); + if (!tmuxSessionIsContextRelayOwned(roomId)) { + console.error(`Refusing to attach ${sessionName}: tmux session is not marked as ContextRelay-created for room ${roomId}.`); + process.exit(1); + } + const result = spawnSync("tmux", ["attach-session", "-t", sessionName], { stdio: "inherit" }); + if (result.error) { + console.error(result.error.message); + process.exit(1); + } + if (typeof result.status === "number" && result.status !== 0) process.exit(result.status); + return; + } + + if (action === "--help" || action === "-h") { + printMeshHelp(); + return; + } + + console.error(`Unknown mesh command: ${action}`); + printMeshHelp(); + process.exit(1); +} + +function printTerminalLaunchSummary(terminalResult: TerminalLaunchResult): void { + if (terminalResult.ok) { + if (terminalResult.alreadyRunning) { + console.log(`\nWorker tmux session already running: ${terminalResult.sessionName}.`); + if (terminalResult.attachCommand) console.log(`Attach: ${terminalResult.attachCommand}`); + return; + } + if (terminalResult.attachCommand && terminalResult.sessionName) { + console.log(`\nStarted tmux session ${terminalResult.sessionName} with ${terminalResult.launched} worker${terminalResult.launched === 1 ? "" : "s"}.`); + console.log(`Attach: ${terminalResult.attachCommand}`); + return; + } + console.log(`\nOpened ${terminalResult.launched} worker terminal${terminalResult.launched === 1 ? "" : "s"}.`); + return; + } + + console.log(`\nWorker terminals were not opened automatically: ${terminalResult.error ?? terminalResult.skipped ?? "unsupported platform"}`); + if (terminalResult.attachCommand && terminalResult.alreadyRunning) console.log(`Attach: ${terminalResult.attachCommand}`); +} + +function printMeshHelp(): void { + console.log(` +Usage: + ${PRIMARY_BIN} mesh status [--json] + ${PRIMARY_BIN} mesh start --codex-workers --claude-workers [--mode read-only|writable] [--worktree-prefix ] [--no-launch-terminals] [-y] + ${PRIMARY_BIN} mesh enable + ${PRIMARY_BIN} mesh disable + +Controls optional room/lane mesh mode. Automatic worker launch uses tmux. If +tmux is missing and stdin is interactive, mesh start asks before running the +detected install command. Use --no-launch-terminals for scripts or print-only +operation. Future backend and attach options are intentionally hidden from +normal help until their release slices. +`.trim()); +} + +interface MeshStartOptions { + roomId: string; + codexWorkers: number; + claudeWorkers: number; + mode: MeshWizardMode; + worktreePrefix?: string; + enableMesh: boolean; + launchTerminals: boolean; + backend: MeshLaunchBackend; + assumeYes: boolean; +} + +export function parseMeshStartOptions(args: string[], defaults: { backend?: MeshLaunchBackend } = {}): MeshStartOptions { + const roomId = requiredArg(args[0], "mesh start requires a room id"); + let codexWorkers = 1; + let claudeWorkers = 1; + let mode: MeshWizardMode = "read-only"; + let worktreePrefix: string | undefined; + let enableMesh = false; + let launchTerminals = true; + let backend: MeshLaunchBackend = defaults.backend ?? "tmux"; + let assumeYes = false; + for (let index = 1; index < args.length; index++) { + const arg = args[index]; + if (arg === "--codex-workers") codexWorkers = parseWorkerCount(requiredArg(args[++index], "--codex-workers requires a value"), "--codex-workers"); + else if (arg.startsWith("--codex-workers=")) codexWorkers = parseWorkerCount(arg.slice("--codex-workers=".length), "--codex-workers"); + else if (arg === "--claude-workers") claudeWorkers = parseWorkerCount(requiredArg(args[++index], "--claude-workers requires a value"), "--claude-workers"); + else if (arg.startsWith("--claude-workers=")) claudeWorkers = parseWorkerCount(arg.slice("--claude-workers=".length), "--claude-workers"); + else if (arg === "--mode") mode = parseMode(requiredArg(args[++index], "--mode requires a value")); + else if (arg.startsWith("--mode=")) mode = parseMode(arg.slice("--mode=".length)); + else if (arg === "--writable") mode = "writable"; + else if (arg === "--read-only") mode = "read-only"; + else if (arg === "--worktree-prefix") worktreePrefix = requiredArg(args[++index], "--worktree-prefix requires a value"); + else if (arg.startsWith("--worktree-prefix=")) worktreePrefix = arg.slice("--worktree-prefix=".length); + else if (arg === "--enable-mesh") enableMesh = true; + else if (arg === "--launch-terminals") launchTerminals = true; + else if (arg === "--no-launch-terminals") launchTerminals = false; + else if (arg === "-y" || arg === "--yes") assumeYes = true; + else if (arg === "--backend") backend = parseBackend(requiredArg(args[++index], "--backend requires a value")); + else if (arg.startsWith("--backend=")) backend = parseBackend(arg.slice("--backend=".length)); + else throw new Error(`Unknown mesh start option: ${arg}`); + } + return { roomId, codexWorkers, claudeWorkers, mode, ...(worktreePrefix ? { worktreePrefix } : {}), enableMesh, launchTerminals, backend, assumeYes }; +} + +function parseMode(value: string): MeshWizardMode { + if (value === "read-only" || value === "writable") return value; + throw new Error(`Invalid --mode: ${value}. Use read-only or writable.`); +} + +function parseBackend(value: string): MeshLaunchBackend { + if (value === "terminal-windows" || value === "tmux" || value === "print-only") return value; + throw new Error(`Invalid --backend: ${value}. Use terminal-windows, tmux, or print-only.`); +} + +function parseWorkerCount(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 20 || String(parsed) !== value.trim()) { + throw new Error(`${flag} must be an integer from 0 to 20.`); + } + return parsed; +} + +function requiredArg(value: string | undefined, message: string): string { + if (!value) { + throw new Error(message); + } + return value; +} + +export function meshBackendDefault(configBackend: MeshLaunchBackend | undefined): MeshLaunchBackend { + return configBackend === "print-only" ? "print-only" : "tmux"; +} + +interface EnsureTmuxForMeshOptions { + assumeYes: boolean; + json: boolean; + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; + detectTmuxFn?: typeof detectTmux; + resolveInstallPlanFn?: typeof resolveTmuxInstallPlan; + installTmuxFn?: typeof installTmux; + confirmTmuxInstallFn?: typeof confirmTmuxInstall; +} + +export async function ensureTmuxForMesh(options: EnsureTmuxForMeshOptions): Promise { + const detectTmuxFn = options.detectTmuxFn ?? detectTmux; + const resolveInstallPlanFn = options.resolveInstallPlanFn ?? resolveTmuxInstallPlan; + const installTmuxFn = options.installTmuxFn ?? installTmux; + const confirmTmuxInstallFn = options.confirmTmuxInstallFn ?? confirmTmuxInstall; + const detected = detectTmuxFn(); + if (detected.available) return true; + + const plan = resolveInstallPlanFn(); + if (!plan.ok || !plan.plan) { + if (!options.json) { + console.error(`tmux is required for automatic mesh worker launch: ${detected.detail}`); + console.error(plan.detail ?? "Install tmux and rerun mesh start."); + } + return false; + } + + if (options.json) { + return false; + } + + const stdinIsTTY = options.stdinIsTTY ?? Boolean(process.stdin.isTTY); + const stdoutIsTTY = options.stdoutIsTTY ?? Boolean(process.stdout.isTTY); + if (!stdinIsTTY || !stdoutIsTTY) { + if (!options.json) { + console.error(`tmux is required for automatic mesh worker launch: ${detected.detail}`); + console.error(`Install command: ${plan.plan.displayCommand}`); + console.error("Rerun with -y from an interactive terminal to install automatically, or use --no-launch-terminals."); + } + return false; + } + + const shouldInstall = options.assumeYes || await confirmTmuxInstallFn(plan.plan.label, plan.plan.displayCommand); + if (!shouldInstall) return false; + + const installed = installTmuxFn(plan.plan); + if (!installed.ok) { + console.error(`tmux install failed: ${installed.detail}`); + return false; + } + + const after = detectTmuxFn(); + if (!after.available) { + console.error(`tmux still was not detected after install: ${after.detail}`); + return false; + } + return true; +} + +async function confirmTmuxInstall(label: string, command: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = (await rl.question( + `tmux is required for mesh worker terminals. Install with ${label} now? (${command}) [y/N]: `, + )).trim().toLowerCase(); + return answer === "y" || answer === "yes"; + } finally { + rl.close(); + } +} diff --git a/src/cli/room.ts b/src/cli/room.ts new file mode 100644 index 0000000..f11cbbf --- /dev/null +++ b/src/cli/room.ts @@ -0,0 +1,103 @@ +import { PRIMARY_BIN } from "../branding"; +import { callMeshLedgerTool, printJsonOrSummary } from "./mesh-control"; + +export async function runRoom(args: string[] = []): Promise { + const json = args.includes("--json"); + const filtered = args.filter((arg) => arg !== "--json"); + const action = filtered[0] ?? "list"; + + if (action === "list") { + printJsonOrSummary(await callMeshLedgerTool({ type: "room_info" }), json); + return; + } + if (action === "create") { + const id = requiredArg(filtered[1], "room create requires an id"); + const opts = parseOptions(filtered.slice(2)); + printJsonOrSummary(await callMeshLedgerTool({ + type: "create_room", + id, + label: opts.label, + coordinatorParticipantId: opts.coordinator, + reviewerParticipantId: opts.reviewer, + pinnedContext: opts.pinned, + }), json); + return; + } + if (action === "view" || action === "info") { + printJsonOrSummary(await callMeshLedgerTool({ type: "room_info", roomId: filtered[1] }), json); + return; + } + if (action === "select") { + const roomId = requiredArg(filtered[1], "room select requires an id"); + printJsonOrSummary(await callMeshLedgerTool({ type: "select_room", roomId }), json); + return; + } + if (action === "archive") { + const roomId = requiredArg(filtered[1], "room archive requires an id"); + printJsonOrSummary(await callMeshLedgerTool({ type: "archive_room", roomId }), json); + return; + } + if (action === "add-member" || action === "remove-member") { + const roomId = requiredArg(filtered[1], `room ${action} requires a room id`); + const participantId = requiredArg(filtered[2], `room ${action} requires a participant id`); + printJsonOrSummary(await callMeshLedgerTool({ + type: action === "add-member" ? "add_room_member" : "remove_room_member", + roomId, + participantId, + }), json); + return; + } + if (action === "assign-coordinator") { + const roomId = requiredArg(filtered[1], "room assign-coordinator requires a room id"); + const participantId = requiredArg(filtered[2], "room assign-coordinator requires a participant id"); + printJsonOrSummary(await callMeshLedgerTool({ type: "assign_room_coordinator", roomId, participantId }), json); + return; + } + if (action === "--help" || action === "-h") { + printRoomHelp(); + return; + } + + console.error(`Unknown room command: ${action}`); + printRoomHelp(); + process.exit(1); +} + +function parseOptions(args: string[]): { label?: string; coordinator?: string; reviewer?: string; pinned?: string } { + const result: { label?: string; coordinator?: string; reviewer?: string; pinned?: string } = {}; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--label") result.label = requiredArg(args[++index], "--label requires a value"); + else if (arg.startsWith("--label=")) result.label = arg.slice("--label=".length); + else if (arg === "--coordinator") result.coordinator = requiredArg(args[++index], "--coordinator requires a value"); + else if (arg.startsWith("--coordinator=")) result.coordinator = arg.slice("--coordinator=".length); + else if (arg === "--reviewer") result.reviewer = requiredArg(args[++index], "--reviewer requires a value"); + else if (arg.startsWith("--reviewer=")) result.reviewer = arg.slice("--reviewer=".length); + else if (arg === "--pinned") result.pinned = requiredArg(args[++index], "--pinned requires a value"); + else if (arg.startsWith("--pinned=")) result.pinned = arg.slice("--pinned=".length); + else throw new Error(`Unknown room option: ${arg}`); + } + return result; +} + +function requiredArg(value: string | undefined, message: string): string { + if (!value) { + console.error(message); + process.exit(1); + } + return value; +} + +function printRoomHelp(): void { + console.log(` +Usage: + ${PRIMARY_BIN} room list [--json] + ${PRIMARY_BIN} room create [--label ] [--coordinator ] [--reviewer ] [--pinned ] + ${PRIMARY_BIN} room view [--json] + ${PRIMARY_BIN} room select + ${PRIMARY_BIN} room archive + ${PRIMARY_BIN} room add-member + ${PRIMARY_BIN} room remove-member + ${PRIMARY_BIN} room assign-coordinator +`.trim()); +} diff --git a/src/cli/session.ts b/src/cli/session.ts index 00bcf83..a3d2fe2 100644 --- a/src/cli/session.ts +++ b/src/cli/session.ts @@ -8,7 +8,8 @@ import type { DaemonStatus, LedgerToolRequest } from "../control-protocol"; import { DEFAULT_RUNTIME_SESSION_ID, inspectSessionRegistryData, - loadSessionRegistry, + loadSessionRegistryFromPath, + sessionRegistryStatePath, type SessionInspection, type SessionRegistryInspection, } from "../session/registry"; @@ -262,7 +263,7 @@ function formatSessionLine(session: SessionInspection, runtime?: NonNullable { const instance = await resolveProjectInstance({ persist: false }); - const registry = loadSessionRegistry(instance.projectRoot, instance.instanceId); + const registry = loadSessionRegistryFromPath(sessionRegistryStatePath(instance.stateDir), instance.instanceId); const inspection = inspectSessionRegistryData(registry.registry, registry.registry.activeSessionId, registry.issue); const stateDir = new StateDirResolver(instance.stateDir); const lifecycle = createLifecycle(instance, stateDir); diff --git a/src/cli/tui.tsx b/src/cli/tui.tsx index d317fe0..44f9d10 100644 --- a/src/cli/tui.tsx +++ b/src/cli/tui.tsx @@ -1,14 +1,22 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, render, Text, useApp, useInput, useStdout } from "ink"; import { DaemonLifecycle } from "../daemon-lifecycle"; +import { DaemonClient } from "../daemon-client"; import { buildInstanceEnv, resolveProjectInstance, type ProjectInstance } from "../instance"; import { StateDirResolver } from "../state-dir"; import { DISPLAY_NAME, PRIMARY_BIN, SHORT_BIN } from "../branding"; import { buildAgentRegistry } from "../agent-descriptors"; import { descriptorFromAdapter, type AgentDescriptor } from "../agents"; -import { ConfigService, type ContextRelayConfig, type CollaborationCoordinator } from "../config-service"; +import { ConfigService, type ContextRelayConfig, type CollaborationCoordinator, type MeshLaunchBackend } from "../config-service"; +import type { LedgerToolRequest } from "../control-protocol"; import { installInstructions } from "../instructions"; +import { appendLocalAuthToken, readLocalAuthToken } from "../local-auth"; +import { withInstanceEnv } from "./mesh-control"; +import { buildMeshWizardPlan, executeMeshWizardPlan, type MeshWizardMode } from "../session/mesh-wizard"; +import { launchWorkerTerminals, tmuxSessionName, type TerminalLaunchResult } from "../session/terminal-launcher"; +import { installTmux } from "../session/tmux-installer"; import { formatCompactWorktreePath } from "../session/worktree"; +import { ensureTmuxForMesh, meshBackendDefault } from "./mesh"; import { evaluatePairLaunchStatus, recordPairLaunchEvent, runPair } from "./pair"; import { runViewer } from "./viewer"; @@ -21,6 +29,21 @@ interface TuiState { lastAction?: string; lastActionAt?: number; startedAt?: number; + meshLaunchNotice?: MeshLaunchNotice; +} + +interface TuiPromptState { + label: string; + value: string; + submit: (value: string) => void; + options?: Array<{ label: string; value: string }>; + selectedIndex?: number; +} + +export interface MeshLaunchNotice { + summary: string; + attachCommand?: string; + workerCommands?: string[]; } interface TuiViewport { @@ -107,6 +130,8 @@ Hotkeys: c Cycle coordinator x Toggle readonly permission mode t Toggle token mode + m Start mesh wizard + M Start mesh wizard q Quit `); } @@ -115,6 +140,7 @@ function ContextRelayTui({ lifecycle, initial }: { lifecycle: DaemonLifecycle; i const { exit } = useApp(); const viewport = useTerminalViewport(); const [state, setState] = useState(() => ({ ...initial, startedAt: initial.startedAt ?? Date.now() })); + const [prompt, setPrompt] = useState(null); const [now, setNow] = useState(() => Date.now()); async function refresh(lastAction = state.lastAction) { @@ -155,6 +181,222 @@ function ContextRelayTui({ lifecycle, initial }: { lifecycle: DaemonLifecycle; i } } + async function runMeshRequest(request: LedgerToolRequest, lastAction: string) { + if (!state.instance) { + setState((prev) => ({ ...prev, error: "Project instance is not resolved.", lastAction: `${lastAction} failed`, lastActionAt: Date.now() })); + return; + } + if (request.type !== "mesh_status" && !state.status?.meshModeEnabled) { + setState((prev) => ({ + ...prev, + error: "Mesh mode is disabled. Press m to start the mesh wizard and enable it.", + lastAction: `${lastAction} blocked`, + lastActionAt: Date.now(), + })); + return; + } + setState((prev) => ({ ...prev, lastAction: `${lastAction}...`, lastActionAt: Date.now(), error: undefined })); + try { + const result = await callTuiLedgerTool(lifecycle, state.instance, request); + if (!result.ok) { + setState((prev) => ({ ...prev, error: result.error, lastAction: `${lastAction} failed`, lastActionAt: Date.now() })); + return; + } + await refresh(result.message ?? lastAction); + } catch (err: any) { + setState((prev) => ({ ...prev, error: err.message, lastAction: `${lastAction} failed`, lastActionAt: Date.now() })); + } + } + + function openPrompt(label: string, initialValue: string, submit: (value: string) => void) { + setPrompt({ label, value: initialValue, submit }); + setState((prev) => ({ ...prev, error: undefined })); + } + + function openSelectPrompt(label: string, options: Array<{ label: string; value: string }>, initialValue: string, submit: (value: string) => void) { + const selectedIndex = Math.max(0, options.findIndex((option) => option.value === initialValue)); + setPrompt({ + label, + value: options[selectedIndex]?.value ?? options[0]?.value ?? "", + submit, + options, + selectedIndex, + }); + setState((prev) => ({ ...prev, error: undefined })); + } + + function selectedMeshScope(): { roomId?: string; laneId?: string } { + return resolveSelectedMeshScope(state.status ?? {}); + } + + async function executeWizard(options: { roomId: string; codexWorkers: number; claudeWorkers: number; mode: MeshWizardMode; worktreePrefix?: string; launchTerminals: boolean }) { + if (!state.instance) { + setState((prev) => ({ ...prev, error: "Project instance is not resolved.", lastAction: "mesh wizard failed", lastActionAt: Date.now() })); + return; + } + try { + const plan = buildMeshWizardPlan({ ...options, commandBin: PRIMARY_BIN }); + setState((prev) => ({ ...prev, lastAction: "starting mesh wizard...", lastActionAt: Date.now(), error: undefined })); + const result = await executeMeshWizardPlan(plan, (request) => callTuiLedgerTool(lifecycle, state.instance!, request)); + if (!result.ok) { + setState((prev) => ({ ...prev, error: result.error ?? "Mesh wizard failed.", lastAction: "mesh wizard failed", lastActionAt: Date.now() })); + return; + } + const launchCommands = result.launchCommands.map((command) => withInstanceEnv(command, state.instance!)); + const backend = meshBackendDefault(state.config?.launch.backend); + const tmuxReady = options.launchTerminals && backend === "tmux" + ? await ensureTmuxForTui() + : true; + let terminalResult: TerminalLaunchResult; + if (options.launchTerminals && tmuxReady) { + terminalResult = launchWorkerTerminals(launchCommands, { + cwd: process.cwd(), + backend, + roomId: options.roomId, + }); + } else if (options.launchTerminals) { + terminalResult = { ok: false, launched: 0, skipped: "tmux is required for automatic mesh launch and is not installed." }; + } else { + terminalResult = { ok: false, launched: 0, skipped: "disabled by prompt" }; + } + const terminalMessage = formatMeshWizardTerminalMessage({ + roomId: options.roomId, + backend, + terminalResult, + launchCommands, + }); + const meshLaunchNotice = buildMeshLaunchNotice({ + roomId: options.roomId, + backend, + terminalResult, + launchCommands, + }); + setState((prev) => ({ ...prev, meshLaunchNotice })); + await refresh(terminalMessage); + } catch (err: any) { + setState((prev) => ({ ...prev, error: err.message, lastAction: "mesh wizard failed", lastActionAt: Date.now() })); + } + } + + function startMeshWizard() { + if (!state.config?.features.meshMode.enabled) { + openSelectPrompt("Mesh is off. Enable and continue?", [ + { label: "Yes, enable mesh", value: "yes" }, + { label: "No, cancel", value: "no" }, + ], "yes", (value) => { + if (value !== "yes") { + setState((prev) => ({ ...prev, lastAction: "mesh wizard cancelled", lastActionAt: Date.now(), error: undefined })); + return; + } + updateConfig((config) => { + config.features.meshMode.enabled = true; + }, "mesh config enabled"); + continueMeshWizardAfterConfig(); + }); + return; + } + continueMeshWizardAfterConfig(); + } + + function continueMeshWizardAfterConfig() { + openPrompt("Mesh room name", selectedMeshScope().roomId ?? "review", (roomId) => { + if (!roomId) return setState((prev) => ({ ...prev, error: "Room id is required." })); + openPrompt("Codex workers", "1", (codexValue) => { + const codexWorkers = parseWizardCount(codexValue, "Codex workers"); + if (codexWorkers === null) return; + openPrompt("Claude workers", "1", (claudeValue) => { + const claudeWorkers = parseWizardCount(claudeValue, "Claude workers"); + if (claudeWorkers === null) return; + openSelectPrompt("Lane mode", [ + { label: "Read-only", value: "read-only" }, + { label: "Writable", value: "writable" }, + ], "read-only", (modeValue) => { + const mode = modeValue as MeshWizardMode; + if (mode === "writable") { + openPrompt("Worktree prefix", "../repo-", (worktreePrefix) => { + if (!worktreePrefix) return setState((prev) => ({ ...prev, error: "Writable mode requires a worktree prefix." })); + promptOpenWorkerTerminals((launchTerminals) => { + void executeWizard({ roomId, codexWorkers, claudeWorkers, mode, worktreePrefix, launchTerminals }); + }); + }); + return; + } + promptOpenWorkerTerminals((launchTerminals) => { + void executeWizard({ roomId, codexWorkers, claudeWorkers, mode, launchTerminals }); + }); + }); + }); + }); + }); + } + + function promptOpenWorkerTerminals(submit: (launchTerminals: boolean) => void) { + const backend = meshBackendDefault(state.config?.launch.backend); + const launchLabel = backend === "tmux" ? "Yes, start tmux session" : "Yes, open terminals"; + openSelectPrompt("Open worker terminals?", [ + { label: launchLabel, value: "yes" }, + { label: "No, only print commands", value: "no" }, + ], "yes", (value) => submit(value === "yes")); + } + + async function ensureTmuxForTui(): Promise { + setState((prev) => ({ ...prev, lastAction: "checking tmux...", lastActionAt: Date.now(), error: undefined })); + let exitedForInstall = false; + let installResultDetail: string | undefined; + const ready = await ensureTmuxForMesh({ + json: false, + assumeYes: false, + stdinIsTTY: Boolean(process.stdin.isTTY), + stdoutIsTTY: Boolean(process.stdout.isTTY), + confirmTmuxInstallFn: (label, command) => promptTmuxInstall(label, command), + installTmuxFn: (plan) => { + exitedForInstall = true; + exit(); + console.log(`\nInstalling tmux with ${plan.label}: ${plan.displayCommand}\n`); + const result = installTmux(plan); + installResultDetail = result.detail; + if (result.ok) { + console.log(`\ntmux installed. Reopen ${DISPLAY_NAME} and run the mesh wizard again.`); + } + return result; + }, + }); + if (exitedForInstall) { + if (!ready) { + console.log(`\ntmux was not ready after the installer${installResultDetail ? `: ${installResultDetail}` : "."}`); + } + process.exit(ready ? 0 : 1); + } + if (!ready) { + setState((prev) => ({ + ...prev, + lastAction: "tmux unavailable", + lastActionAt: Date.now(), + error: "tmux is required for automatic mesh worker launch. Install tmux or choose command-only launch.", + })); + } + return ready; + } + + function promptTmuxInstall(label: string, command: string): Promise { + return new Promise((resolve) => { + openSelectPrompt(`Install tmux with ${label}? ${command}`, [ + { label: "Yes, install tmux", value: "yes" }, + { label: "No, commands only", value: "no" }, + { label: "Cancel", value: "cancel" }, + ], "yes", (value) => resolve(value === "yes")); + }); + } + + function parseWizardCount(value: string, label: string): number | null { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 20 || String(parsed) !== value.trim()) { + setState((prev) => ({ ...prev, error: `${label} must be an integer from 0 to 20.` })); + return null; + } + return parsed; + } + useEffect(() => { refresh(); const timer = setInterval(() => refresh(), 1000); @@ -167,6 +409,35 @@ function ContextRelayTui({ lifecycle, initial }: { lifecycle: DaemonLifecycle; i }, []); useInput((_input, key) => { + if (prompt) { + if (key.escape) { + setPrompt(null); + return; + } + if (key.return) { + const current = prompt; + setPrompt(null); + current.submit(current.value.trim()); + return; + } + if (prompt.options && (key.upArrow || _input === "k")) { + setPrompt((prev) => selectPromptOption(prev, -1)); + return; + } + if (prompt.options && (key.downArrow || _input === "j")) { + setPrompt((prev) => selectPromptOption(prev, 1)); + return; + } + if (prompt.options) return; + if (key.backspace || key.delete) { + setPrompt((prev) => prev ? { ...prev, value: prev.value.slice(0, -1) } : prev); + return; + } + if (_input && !key.ctrl && !key.meta) { + setPrompt((prev) => prev ? { ...prev, value: `${prev.value}${_input}` } : prev); + } + return; + } if (key.escape || _input === "q") { exit(); return; @@ -233,6 +504,102 @@ function ContextRelayTui({ lifecycle, initial }: { lifecycle: DaemonLifecycle; i const current = config.turnCoordination.hookCompaction.mode; config.turnCoordination.hookCompaction.mode = current === "compact" ? "verbose" : "compact"; }, "token mode toggled"); + return; + } + if (_input === "m") { + startMeshWizard(); + return; + } + if (_input === "M") { + startMeshWizard(); + return; + } + if (_input === "R") { + openPrompt("Room id [coordinator]", "review default:codex", (value) => { + const [id, coordinatorParticipantId] = splitPrompt(value); + if (!id) return setState((prev) => ({ ...prev, error: "Room id is required." })); + void runMeshRequest({ type: "create_room", id, coordinatorParticipantId }, "room create"); + }); + return; + } + if (_input === "G") { + openPrompt("Select room", selectedMeshScope().roomId ?? "", (value) => { + if (!value) return setState((prev) => ({ ...prev, error: "Room id is required." })); + void runMeshRequest({ type: "select_room", roomId: value }, "room select"); + }); + return; + } + if (_input === "O") { + const { roomId } = selectedMeshScope(); + openPrompt("Coordinator: room participant", roomId ? `${roomId} default:codex` : "review default:codex", (value) => { + const [nextRoomId, participantId] = splitPrompt(value); + if (!nextRoomId || !participantId) return setState((prev) => ({ ...prev, error: "Room id and participant id are required." })); + void runMeshRequest({ type: "assign_room_coordinator", roomId: nextRoomId, participantId }, "coordinator assign"); + }); + return; + } + if (_input === "N") { + const { roomId } = selectedMeshScope(); + openPrompt("Member: +|- room participant", roomId ? `+ ${roomId} default:codex` : "+ review default:codex", (value) => { + const [mode, nextRoomId, participantId] = splitPrompt(value); + if ((mode !== "+" && mode !== "-") || !nextRoomId || !participantId) { + return setState((prev) => ({ ...prev, error: "Use: + room participant OR - room participant." })); + } + void runMeshRequest({ type: mode === "+" ? "add_room_member" : "remove_room_member", roomId: nextRoomId, participantId }, "room member"); + }); + return; + } + if (_input === "L") { + const { roomId } = selectedMeshScope(); + openPrompt("Lane: room lane [participant] [worktree]", roomId ? `${roomId} frontend default:codex` : "review frontend default:codex", (value) => { + const [nextRoomId, laneId, participantId, worktreePath] = splitPrompt(value); + if (!nextRoomId || !laneId) return setState((prev) => ({ ...prev, error: "Room id and lane id are required." })); + void runMeshRequest({ type: "create_lane", roomId: nextRoomId, laneId, participantId, worktreePath }, "lane create"); + }); + return; + } + if (_input === "E") { + const { roomId, laneId } = selectedMeshScope(); + openPrompt("Enter lane: room lane [participant]", roomId && laneId ? `${roomId} ${laneId} default:codex` : "review frontend default:codex", (value) => { + const [nextRoomId, nextLaneId, participantId] = splitPrompt(value); + if (!nextRoomId || !nextLaneId) return setState((prev) => ({ ...prev, error: "Room id and lane id are required." })); + void runMeshRequest({ type: "enter_lane", roomId: nextRoomId, laneId: nextLaneId, participantId }, "lane enter"); + }); + return; + } + if (_input === "A") { + const { roomId, laneId } = selectedMeshScope(); + openPrompt("Assign: +|- room lane participant", roomId && laneId ? `+ ${roomId} ${laneId} default:codex` : "+ review frontend default:codex", (value) => { + const [mode, nextRoomId, nextLaneId, participantId] = splitPrompt(value); + if ((mode !== "+" && mode !== "-") || !nextRoomId || !nextLaneId || !participantId) { + return setState((prev) => ({ ...prev, error: "Use: + room lane participant OR - room lane participant." })); + } + void runMeshRequest({ + type: mode === "+" ? "assign_participant_to_lane" : "remove_participant_from_lane", + roomId: nextRoomId, + laneId: nextLaneId, + participantId, + }, "lane assignment"); + }); + return; + } + if (_input === "C") { + const { roomId, laneId } = selectedMeshScope(); + openPrompt("Complete lane: room lane", roomId && laneId ? `${roomId} ${laneId}` : "review frontend", (value) => { + const [nextRoomId, nextLaneId] = splitPrompt(value); + if (!nextRoomId || !nextLaneId) return setState((prev) => ({ ...prev, error: "Room id and lane id are required." })); + void runMeshRequest({ type: "complete_lane", roomId: nextRoomId, laneId: nextLaneId }, "lane complete"); + }); + return; + } + if (_input === "X") { + const { roomId, laneId } = selectedMeshScope(); + openPrompt("Archive: lane room lane OR room room", roomId && laneId ? `lane ${roomId} ${laneId}` : "room review", (value) => { + const [kind, nextRoomId, nextLaneId] = splitPrompt(value); + if (kind === "room" && nextRoomId) return void runMeshRequest({ type: "archive_room", roomId: nextRoomId }, "room archive"); + if (kind === "lane" && nextRoomId && nextLaneId) return void runMeshRequest({ type: "archive_lane", roomId: nextRoomId, laneId: nextLaneId }, "lane archive"); + setState((prev) => ({ ...prev, error: "Use: room roomId OR lane roomId laneId." })); + }); } }); @@ -260,9 +627,11 @@ function ContextRelayTui({ lifecycle, initial }: { lifecycle: DaemonLifecycle; i + + {prompt ? : null}
@@ -361,6 +730,23 @@ function AgentPanel({ agents }: { agents: AgentDescriptor[] }) { ); } +function MeshPanel({ status, config }: { status: Record; config?: ContextRelayConfig }) { + const rows = formatMeshRoomRows(status, 5); + const meshConfig = config?.features.meshMode.enabled ?? false; + const meshRuntime = Boolean(status.meshModeEnabled); + return ( + + Mesh Rooms + + {status.roomRegistry?.warning ? {status.roomRegistry.warning} : null} + {rows.map((row) => ( + {row.text} + ))} + m wizard advanced: R/G/O/N/L/E/A/C/X + + ); +} + function ActivityPanel({ state, width }: { state: TuiState; width: number }) { const status = state.status ?? {}; const handoff = status.latestActiveHandoff ?? status.activeHandoff; @@ -373,6 +759,7 @@ function ActivityPanel({ state, width }: { state: TuiState; width: number }) { Activity {state.error ? Error: {truncate(state.error, 96)} : null} {state.lastAction ? Last action: {state.lastAction} : null} + {state.meshLaunchNotice ? : null} @@ -386,6 +773,58 @@ function ActivityPanel({ state, width }: { state: TuiState; width: number }) { ); } +function MeshLaunchNoticeRows({ notice }: { notice: MeshLaunchNotice }) { + return ( + + Mesh: {notice.summary} + {notice.attachCommand ? Attach: {notice.attachCommand} : null} + {notice.workerCommands?.slice(0, 3).map((command, index) => ( + Run: {command} + ))} + {notice.workerCommands && notice.workerCommands.length > 3 ? Run remaining workers from the command output. : null} + + ); +} + +function PromptLine({ prompt, width }: { prompt: TuiPromptState; width: number }) { + if (prompt.options) { + return ( + + + {prompt.label} + + {prompt.options.map((option, index) => ( + + + {index === prompt.selectedIndex ? "● " : "○ "}{option.label} + + {index < prompt.options!.length - 1 ? : null} + + ))} + ↑↓ enter + + + ); + } + return ( + + + {prompt.label} + + {prompt.value} + + + ); +} + +function selectPromptOption(prompt: TuiPromptState | null, delta: number): TuiPromptState | null { + if (!prompt?.options?.length) return prompt; + const nextIndex = (prompt.selectedIndex ?? 0) + delta; + const wrappedIndex = (nextIndex + prompt.options.length) % prompt.options.length; + const value = prompt.options[wrappedIndex]?.value ?? prompt.value; + return { ...prompt, selectedIndex: wrappedIndex, value }; +} + function Footer({ width }: { width: number }) { const compact = width < 92; return ( @@ -401,6 +840,7 @@ function Footer({ width }: { width: number }) { c coord x ro t token + m mesh q quit ) : ( @@ -413,6 +853,7 @@ function Footer({ width }: { width: number }) { (c)oord (x)readonly (t)token + (m)esh wizard (q)uit )} @@ -468,6 +909,59 @@ function Metric({ label, value, valueColor }: { label: string; value: string; va ); } +export function formatMeshWizardTerminalMessage(options: { + roomId: string; + backend: MeshLaunchBackend; + terminalResult: TerminalLaunchResult; + launchCommands: string[]; +}): string { + const { roomId, backend, terminalResult, launchCommands } = options; + if (terminalResult.ok) { + if (backend === "tmux") { + const sessionName = terminalResult.sessionName ?? tmuxSessionName(roomId); + const attachCommand = terminalResult.attachCommand ?? `tmux attach -t ${sessionName}`; + if (terminalResult.alreadyRunning) { + return `mesh room ${roomId} ready; tmux session ${sessionName} already running; attach: ${attachCommand}`; + } + return `mesh room ${roomId} ready; tmux session ${sessionName} started; attach: ${attachCommand}`; + } + return `mesh room ${roomId} ready; opened ${terminalResult.launched} worker terminal${terminalResult.launched === 1 ? "" : "s"}`; + } + + const commandSummary = launchCommands.length > 0 ? `; worker commands: ${launchCommands.join(" ; ")}` : ""; + if (terminalResult.error) { + const total = Math.max(launchCommands.length, terminalResult.launched); + return `mesh room ${roomId} ready; terminal launch failed (launched ${terminalResult.launched} of ${total}): ${terminalResult.error}${commandSummary}`; + } + return `mesh room ${roomId} ready; terminal launch skipped: ${terminalResult.skipped ?? "unsupported"}${commandSummary}`; +} + +export function buildMeshLaunchNotice(options: { + roomId: string; + backend: MeshLaunchBackend; + terminalResult: TerminalLaunchResult; + launchCommands: string[]; +}): MeshLaunchNotice | undefined { + const { backend, terminalResult, launchCommands } = options; + if (backend === "tmux" && terminalResult.attachCommand && terminalResult.sessionName) { + return { + summary: terminalResult.alreadyRunning + ? `tmux session ${terminalResult.sessionName} already running` + : `tmux session ${terminalResult.sessionName} ready`, + attachCommand: terminalResult.attachCommand, + }; + } + + if (!terminalResult.ok && launchCommands.length > 0) { + return { + summary: terminalResult.error ? "worker launch failed; run manually" : "worker launch skipped; run manually", + workerCommands: launchCommands, + }; + } + + return undefined; +} + export function formatRuntimeSessionRows(status: Record, limit = 4): Array<{ id: string; text: string; color: string }> { const registrySessions = Array.isArray(status.registrySessions) ? status.registrySessions : []; const runtimeSessions = status.sessions && typeof status.sessions === "object" ? status.sessions as Record : {}; @@ -499,6 +993,79 @@ export function formatRuntimeSessionRows(status: Record, limit = 4) return rows; } +export function formatMeshRoomRows(status: Record, limit = 5): Array<{ id: string; text: string; color: string }> { + const registry = status.roomRegistry && typeof status.roomRegistry === "object" ? status.roomRegistry as Record : {}; + const rooms = Array.isArray(registry.rooms) ? registry.rooms : []; + if (rooms.length === 0) { + return [{ id: "__none", color: "gray", text: status.meshModeEnabled ? "No rooms — press m to start wizard" : "Mesh off — press m to start wizard" }]; + } + const rows: Array<{ id: string; text: string; color: string }> = []; + for (const room of rooms.slice(0, limit)) { + const lanes = room.lanes && typeof room.lanes === "object" ? Object.values(room.lanes as Record) : []; + const active = registry.activeRoomId === room.id ? "*" : " "; + const openLanes = lanes.filter((lane: any) => lane.lifecycle === "open").length; + const activeLane = room.activeLaneId ? ` lane:${room.activeLaneId}` : ""; + const coordinator = room.coordinatorParticipantId ? ` coord:${truncateDisplayWidth(room.coordinatorParticipantId, 18)}` : ""; + rows.push({ + id: room.id, + color: room.lifecycle === "archived" ? "gray" : room.lifecycle === "paused" ? "yellow" : registry.activeRoomId === room.id ? "green" : "white", + text: `${active}${room.id} ${room.lifecycle} lanes:${lanes.length}/${openLanes}${activeLane}${coordinator}`, + }); + for (const lane of lanes.slice(0, Math.max(0, limit - rows.length))) { + const laneActive = room.activeLaneId === lane.id ? ">" : " "; + const owner = lane.ownerParticipantId ? ` owner:${truncateDisplayWidth(lane.ownerParticipantId, 16)}` : ""; + const worktree = lane.worktreePath ? ` path:${formatCompactWorktreePath(lane.worktreePath, 16)}` : ""; + const approvals = lane.pendingApprovals && typeof lane.pendingApprovals === "object" + ? Object.keys(lane.pendingApprovals).length + : 0; + const approvalText = approvals > 0 ? ` approvals:${approvals}` : ""; + rows.push({ + id: `${room.id}:${lane.id}`, + color: approvals > 0 ? "yellow" : lane.lifecycle === "open" ? "gray" : lane.lifecycle === "complete" ? "green" : "gray", + text: ` ${laneActive}${lane.id} ${lane.lifecycle}${owner}${worktree}${approvalText}`, + }); + } + } + if (rooms.length > limit) { + rows.push({ id: "__more", color: "gray", text: `+${rooms.length - limit} more rooms — ${SHORT_BIN} room list` }); + } + return rows; +} + +function resolveSelectedMeshScope(status: Record): { roomId?: string; laneId?: string } { + const registry = status.roomRegistry && typeof status.roomRegistry === "object" ? status.roomRegistry as Record : {}; + const rooms = Array.isArray(registry.rooms) ? registry.rooms : []; + const roomId = typeof registry.activeRoomId === "string" + ? registry.activeRoomId + : typeof rooms[0]?.id === "string" ? rooms[0].id : undefined; + const room = rooms.find((item: any) => item.id === roomId) ?? rooms[0]; + const laneId = typeof room?.activeLaneId === "string" + ? room.activeLaneId + : room?.lanes && typeof room.lanes === "object" + ? Object.keys(room.lanes).find((id) => room.lanes[id]?.lifecycle === "open") ?? Object.keys(room.lanes)[0] + : undefined; + return { roomId, laneId }; +} + +function splitPrompt(value: string): string[] { + return value.trim().split(/\s+/).filter(Boolean); +} + +async function callTuiLedgerTool(lifecycle: DaemonLifecycle, instance: ProjectInstance, request: LedgerToolRequest) { + const stateDir = new StateDirResolver(instance.stateDir); + const token = readLocalAuthToken(stateDir); + if (!token) { + throw new Error(`Control auth token was not found. Try: ${SHORT_BIN} kill && ${SHORT_BIN} tui`); + } + const client = new DaemonClient(appendLocalAuthToken(lifecycle.controlWsUrl, token)); + try { + await client.connect(); + return await client.callLedgerTool(request); + } finally { + await client.disconnect(); + } +} + function claudeConnectionLabel(status: Record): "connected" | "reconnecting" | "waiting" { if (status.claudeConnected) return "connected"; if (status.claudeState === "reconnecting") return "reconnecting"; @@ -603,7 +1170,7 @@ function isWideCodePoint(code: number): boolean { } export function formatActivityEntry( - entry: { timestamp?: number; type?: string; status?: string; content?: string; meta?: Record; runtimeSessionId?: string }, + entry: { timestamp?: number; type?: string; status?: string; content?: string; meta?: Record; runtimeSessionId?: string; roomId?: string; laneId?: string }, maxWidth = 64, options: { showSessionId?: boolean } = {}, ): string { @@ -614,7 +1181,8 @@ export function formatActivityEntry( const status = entry.status ? `/${entry.status}` : ""; const content = typeof entry.content === "string" ? firstLine(entry.content) : ""; const session = options.showSessionId ? activitySessionSuffix(entry) : ""; - const row = `${time} ${entry.type ?? "event"}${status} ${content}${session}`; + const mesh = activityMeshSuffix(entry); + const row = `${time} ${entry.type ?? "event"}${status} ${content}${session}${mesh}`; if (maxWidth <= time.length + 2) return truncateDisplayWidth(`${time} …`, maxWidth); return truncateDisplayWidth(row, maxWidth); } @@ -644,6 +1212,13 @@ function activitySessionSuffix(entry: { meta?: Record; runtimeS return sessionId && sessionId !== "default" ? ` [s:${sessionId.slice(0, 8)}]` : ""; } +function activityMeshSuffix(entry: { meta?: Record; roomId?: string; laneId?: string }): string { + const roomId = entry.roomId ?? (typeof entry.meta?.roomId === "string" ? entry.meta.roomId : undefined); + const laneId = entry.laneId ?? (typeof entry.meta?.laneId === "string" ? entry.meta.laneId : undefined); + if (!roomId && !laneId) return ""; + return ` [${roomId ?? "-"}${laneId ? `/${laneId}` : ""}]`; +} + function formatElapsed(ms: number): string { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts new file mode 100644 index 0000000..c0d5356 --- /dev/null +++ b/src/cli/worktree.ts @@ -0,0 +1,89 @@ +import { PRIMARY_BIN } from "../branding"; +import { createGitWorktree, planGitWorktreeCreate, removeGitWorktree, statusGitWorktrees } from "../session/git-worktree"; + +export async function runWorktree(args: string[] = []): Promise { + const json = args.includes("--json"); + const filtered = args.filter((arg) => arg !== "--json"); + const action = filtered[0] ?? "status"; + try { + if (action === "plan" || action === "create") { + const opts = parsePlanOptions(filtered.slice(1)); + const result = action === "create" + ? createGitWorktree({ ...opts, confirm: filtered.includes("--confirm") }) + : planGitWorktreeCreate(opts); + printJsonOrText(result, json); + return; + } + if (action === "status") { + printJsonOrText(statusGitWorktrees(), json); + return; + } + if (action === "remove") { + const targetPath = requiredArg(filtered[1], "worktree remove requires a path"); + removeGitWorktree({ targetPath, confirm: filtered.includes("--confirm") }); + console.log(`Removed worktree ${targetPath}.`); + return; + } + if (action === "--help" || action === "-h") { + printHelp(); + return; + } + throw new Error(`Unknown worktree command: ${action}`); + } catch (err: any) { + console.error(err?.message ?? String(err)); + process.exit(1); + } +} + +function parsePlanOptions(args: string[]): { targetPath: string; branch: string; base?: string } { + let targetPath = ""; + let branch = ""; + let base: string | undefined; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--path") targetPath = requiredArg(args[++index], "--path requires a value"); + else if (arg.startsWith("--path=")) targetPath = arg.slice("--path=".length); + else if (arg === "--branch") branch = requiredArg(args[++index], "--branch requires a value"); + else if (arg.startsWith("--branch=")) branch = arg.slice("--branch=".length); + else if (arg === "--base") base = requiredArg(args[++index], "--base requires a value"); + else if (arg.startsWith("--base=")) base = arg.slice("--base=".length); + else if (arg === "--confirm") continue; + else throw new Error(`Unknown worktree option: ${arg}`); + } + if (!targetPath) throw new Error("--path is required."); + if (!branch) throw new Error("--branch is required."); + return { targetPath, branch, ...(base ? { base } : {}) }; +} + +function printJsonOrText(value: unknown, json: boolean): void { + if (json) { + console.log(JSON.stringify(value, null, 2)); + return; + } + if (Array.isArray(value)) { + for (const item of value as any[]) { + console.log(`${item.worktree?.path ?? item.targetPath}: ${item.detail ?? (item.canCreate ? "can create" : item.issues?.join("; "))}`); + } + return; + } + const plan = value as any; + console.log(plan.canCreate ? "Worktree can be created." : `Worktree cannot be created: ${plan.issues.join("; ")}`); + console.log(`path: ${plan.targetPath}`); + console.log(`branch: ${plan.branch}`); + if (plan.base) console.log(`base: ${plan.base}`); +} + +function requiredArg(value: string | undefined, message: string): string { + if (!value) throw new Error(message); + return value; +} + +function printHelp(): void { + console.log(` +Usage: + ${PRIMARY_BIN} worktree plan --path --branch [--base ] [--json] + ${PRIMARY_BIN} worktree create --path --branch [--base ] --confirm [--json] + ${PRIMARY_BIN} worktree status [--json] + ${PRIMARY_BIN} worktree remove --confirm +`.trim()); +} diff --git a/src/codex-mcp.ts b/src/codex-mcp.ts index 9c7aec4..fda5cd0 100644 --- a/src/codex-mcp.ts +++ b/src/codex-mcp.ts @@ -30,6 +30,26 @@ type WaitForClaudeReplyOutcome = { latestActiveHandoff: LedgerEntry | null; }; +const CODEX_MESH_TOOL_NAMES = new Set([ + "mesh_status", + "create_room", + "select_room", + "room_info", + "archive_room", + "add_room_member", + "remove_room_member", + "assign_room_coordinator", + "create_lane", + "enter_lane", + "lane_info", + "complete_lane", + "archive_lane", + "assign_participant_to_lane", + "remove_participant_from_lane", + "request_approval", + "expire_approvals", +]); + export function buildCodexMcpInstructions(coordinator: CollaborationCoordinator = "claude"): string { const role = coordinator === "codex" ? [ @@ -333,6 +353,106 @@ export const CODEX_MCP_TOOLS = [ required: ["summary", "evidence"], }, }, + { + name: "mesh_status", + description: "Return optional mesh-mode state and room/lane registry inspection.", + inputSchema: { type: "object" as const, properties: {}, required: [] }, + }, + { + name: "create_room", + description: "Create a mesh room. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { id: { type: "string" }, label: { type: "string" }, coordinatorParticipantId: { type: "string" }, reviewerParticipantId: { type: "string" }, pinnedContext: { type: "string" } }, required: ["id"] }, + }, + { + name: "select_room", + description: "Select a mesh room. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" } }, required: ["roomId"] }, + }, + { + name: "room_info", + description: "Inspect all rooms or one room. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" } }, required: [] }, + }, + { + name: "archive_room", + description: "Archive a mesh room. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" } }, required: ["roomId"] }, + }, + { + name: "add_room_member", + description: "Add a participant to a mesh room. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, participantId: { type: "string" } }, required: ["roomId", "participantId"] }, + }, + { + name: "remove_room_member", + description: "Remove a participant from a mesh room. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, participantId: { type: "string" } }, required: ["roomId", "participantId"] }, + }, + { + name: "assign_room_coordinator", + description: "Assign the durable coordinator for a mesh room. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, participantId: { type: "string" } }, required: ["roomId", "participantId"] }, + }, + { + name: "create_lane", + description: "Create a lane inside a mesh room. Write-capable lanes require a worktree.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, laneId: { type: "string" }, label: { type: "string" }, participantId: { type: "string" }, worktreePath: { type: "string" }, permissions: { type: "array", items: { type: "string" } } }, required: ["roomId", "laneId"] }, + }, + { + name: "enter_lane", + description: "Set the current mesh room/lane scope for a participant.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, laneId: { type: "string" }, participantId: { type: "string" }, runtimeSessionId: { type: "string" } }, required: ["roomId", "laneId"] }, + }, + { + name: "lane_info", + description: "Inspect lanes in a room or one lane. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, laneId: { type: "string" } }, required: ["roomId"] }, + }, + { + name: "complete_lane", + description: "Mark a lane complete and emit a room heartbeat.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, laneId: { type: "string" } }, required: ["roomId", "laneId"] }, + }, + { + name: "archive_lane", + description: "Archive a lane. Requires mesh mode to be enabled.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, laneId: { type: "string" } }, required: ["roomId", "laneId"] }, + }, + { + name: "assign_participant_to_lane", + description: "Assign a participant as the owner of a lane.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, laneId: { type: "string" }, participantId: { type: "string" } }, required: ["roomId", "laneId", "participantId"] }, + }, + { + name: "remove_participant_from_lane", + description: "Remove a participant assignment from a lane.", + inputSchema: { type: "object" as const, properties: { roomId: { type: "string" }, laneId: { type: "string" }, participantId: { type: "string" } }, required: ["roomId", "laneId", "participantId"] }, + }, + { + name: "request_approval", + description: "Create a durable, scoped approval request for a lane action. Requires mesh mode to be enabled.", + inputSchema: { + type: "object" as const, + properties: { + roomId: { type: "string" }, + laneId: { type: "string" }, + requesterParticipantId: { type: "string" }, + reviewerParticipantId: { type: "string" }, + action: { type: "string" }, + runtimeSessionId: { type: "string" }, + idempotencyKey: { type: "string" }, + expiresAt: { type: "string" }, + ttlMs: { type: "number" }, + reason: { type: "string" }, + }, + required: ["roomId", "laneId", "requesterParticipantId", "action"], + }, + }, + { + name: "expire_approvals", + description: "Expire stale pending approvals and record fail-closed audit entries.", + inputSchema: { type: "object" as const, properties: {}, required: [] }, + }, ]; export class CodexMcpAdapter { @@ -386,6 +506,7 @@ export class CodexMcpAdapter { if (name === "ask_claude_backup") return this.handleAskClaudeBackup(input); if (name === "backup_status") return this.handleBackupStatus(); if (name === "propose_final") return this.handleProposeFinal(input); + if (CODEX_MESH_TOOL_NAMES.has(name)) return this.handleMeshTool(name, input); return toolError(`Unknown tool: ${name}`); }); @@ -672,6 +793,12 @@ export class CodexMcpAdapter { }); } + private async handleMeshTool(name: string, args: Record): Promise { + const request = meshToolRequest(name, args); + if (!request.ok) return toolError(request.error); + return this.handleJsonTool(request.request); + } + private async handleRecordArtifact(args: Record): Promise { if (!isArtifactKind(args.kind)) return toolError("Error: missing or invalid parameter 'kind'"); const title = requiredString(args.title, "title"); @@ -899,6 +1026,99 @@ function isArtifactStatus(value: unknown): value is "passed" | "failed" | "block return value === "passed" || value === "failed" || value === "blocked" || value === "unknown"; } +function meshToolRequest(name: string, args: Record): { ok: true; request: LedgerToolRequest } | { ok: false; error: string } { + const stringValue = (key: string): string | undefined => typeof args[key] === "string" && (args[key] as string).trim() ? args[key] as string : undefined; + const requireValue = (key: string): { ok: true; value: string } | { ok: false; error: string } => { + const value = stringValue(key); + return value ? { ok: true, value } : { ok: false, error: `Error: missing required parameter '${key}'` }; + }; + if (name === "mesh_status") return { ok: true, request: { type: "mesh_status" } }; + if (name === "create_room") { + const id = requireValue("id"); + if (!id.ok) return id; + return { ok: true, request: { type: "create_room", id: id.value, label: stringValue("label"), coordinatorParticipantId: stringValue("coordinatorParticipantId"), reviewerParticipantId: stringValue("reviewerParticipantId"), pinnedContext: stringValue("pinnedContext") } }; + } + if (name === "select_room" || name === "room_info" || name === "archive_room") { + if (name === "room_info") return { ok: true, request: { type: "room_info", roomId: stringValue("roomId") } }; + const roomId = requireValue("roomId"); + if (!roomId.ok) return roomId; + if (name === "select_room") return { ok: true, request: { type: "select_room", roomId: roomId.value } }; + return { ok: true, request: { type: "archive_room", roomId: roomId.value } }; + } + if (name === "add_room_member" || name === "remove_room_member" || name === "assign_room_coordinator") { + const roomId = requireValue("roomId"); + if (!roomId.ok) return roomId; + const participantId = requireValue("participantId"); + if (!participantId.ok) return participantId; + return { + ok: true, + request: { + type: name === "add_room_member" ? "add_room_member" : name === "remove_room_member" ? "remove_room_member" : "assign_room_coordinator", + roomId: roomId.value, + participantId: participantId.value, + }, + }; + } + if (name === "create_lane") { + const roomId = requireValue("roomId"); + if (!roomId.ok) return roomId; + const laneId = requireValue("laneId"); + if (!laneId.ok) return laneId; + return { ok: true, request: { type: "create_lane", roomId: roomId.value, laneId: laneId.value, label: stringValue("label"), participantId: stringValue("participantId"), worktreePath: stringValue("worktreePath"), permissions: stringArray(args.permissions) } }; + } + if (name === "enter_lane") { + const roomId = requireValue("roomId"); + if (!roomId.ok) return roomId; + const laneId = requireValue("laneId"); + if (!laneId.ok) return laneId; + return { ok: true, request: { type: "enter_lane", roomId: roomId.value, laneId: laneId.value, participantId: stringValue("participantId"), runtimeSessionId: stringValue("runtimeSessionId"), source: "codex" } }; + } + if (name === "lane_info") { + const roomId = requireValue("roomId"); + if (!roomId.ok) return roomId; + return { ok: true, request: { type: "lane_info", roomId: roomId.value, laneId: stringValue("laneId") } }; + } + if (name === "complete_lane" || name === "archive_lane" || name === "assign_participant_to_lane" || name === "remove_participant_from_lane") { + const roomId = requireValue("roomId"); + if (!roomId.ok) return roomId; + const laneId = requireValue("laneId"); + if (!laneId.ok) return laneId; + if (name === "complete_lane") return { ok: true, request: { type: "complete_lane", roomId: roomId.value, laneId: laneId.value } }; + if (name === "archive_lane") return { ok: true, request: { type: "archive_lane", roomId: roomId.value, laneId: laneId.value } }; + const participantId = requireValue("participantId"); + if (!participantId.ok) return participantId; + return { ok: true, request: { type: name === "assign_participant_to_lane" ? "assign_participant_to_lane" : "remove_participant_from_lane", roomId: roomId.value, laneId: laneId.value, participantId: participantId.value } }; + } + if (name === "request_approval") { + const roomId = requireValue("roomId"); + if (!roomId.ok) return roomId; + const laneId = requireValue("laneId"); + if (!laneId.ok) return laneId; + const requesterParticipantId = requireValue("requesterParticipantId"); + if (!requesterParticipantId.ok) return requesterParticipantId; + const action = requireValue("action"); + if (!action.ok) return action; + return { + ok: true, + request: { + type: "request_approval", + roomId: roomId.value, + laneId: laneId.value, + requesterParticipantId: requesterParticipantId.value, + action: action.value, + reviewerParticipantId: stringValue("reviewerParticipantId"), + runtimeSessionId: stringValue("runtimeSessionId"), + idempotencyKey: stringValue("idempotencyKey"), + expiresAt: stringValue("expiresAt"), + ttlMs: typeof args.ttlMs === "number" ? args.ttlMs : undefined, + reason: stringValue("reason"), + }, + }; + } + if (name === "expire_approvals") return { ok: true, request: { type: "expire_approvals" } }; + return { ok: false, error: `Unknown mesh tool: ${name}` }; +} + function formatHandoffForClaude(handoff: HandoffPayload, entryId?: string): string { const refs = handoff.context_refs.length > 0 ? handoff.context_refs.map((ref) => `- ${ref}`).join("\n") diff --git a/src/config-service.ts b/src/config-service.ts index 8c45857..67a83f8 100644 --- a/src/config-service.ts +++ b/src/config-service.ts @@ -5,6 +5,8 @@ import type { AgentId, PermissionCapability } from "./agents"; /** Machine-readable project config schema. */ export type CollaborationCoordinator = "claude" | "codex" | "human"; export type HookCompactionMode = "verbose" | "compact"; +export type MeshWizardPresetName = "review" | "debate" | "plan" | "implement" | "debug" | "custom"; +export type MeshLaunchBackend = "terminal-windows" | "tmux" | "print-only"; export interface HookCompactionConfig { mode: HookCompactionMode; @@ -20,11 +22,35 @@ export interface ResolvedHookCompactionConfig { dedupeSeconds: number; } +export interface ContextRelayProfile { + description?: string; + overrides?: Partial>; +} + +export interface MeshWizardPresetConfig { + profile?: string; + codexWorkers: number; + claudeWorkers: number; + mode: "read-only" | "writable"; + outputExpectation?: string; +} + export interface ContextRelayConfig { version: string; instanceId: string; + activeProfile?: string; + profiles: Record; + wizardPresets: Record; + launch: { + backend: MeshLaunchBackend; + }; stateDir: string; controlPort: number; + features: { + meshMode: { + enabled: boolean; + }; + }; codex: { appPort: number; proxyPort: number; @@ -58,8 +84,77 @@ export interface ContextRelayConfig { const DEFAULT_CONFIG: ContextRelayConfig = { version: "1.0", instanceId: "", + activeProfile: "reviewer", + profiles: { + builder: { + description: "Writable implementation worker profile.", + overrides: { + permissions: { + readonly: false, + allowed: ["read", "write", "shell", "git"], + agentOverrides: {}, + }, + }, + }, + reviewer: { + description: "Read-only architecture and compatibility reviewer profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read"], + agentOverrides: {}, + }, + }, + }, + critic: { + description: "Read-only risk and edge-case review profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read"], + agentOverrides: {}, + }, + }, + }, + tester: { + description: "Test-focused worker profile with shell and read access.", + overrides: { + permissions: { + readonly: false, + allowed: ["read", "shell"], + agentOverrides: {}, + }, + }, + }, + researcher: { + description: "Read-only research worker profile.", + overrides: { + permissions: { + readonly: true, + allowed: ["read", "network"], + agentOverrides: {}, + }, + }, + }, + }, + wizardPresets: { + review: { profile: "reviewer", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Findings, risks, and recommended next action." }, + debate: { profile: "critic", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Consensus, disagreement, decision, and next action." }, + plan: { profile: "researcher", codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "Decision-complete implementation plan." }, + implement: { profile: "builder", codexWorkers: 1, claudeWorkers: 1, mode: "writable", outputExpectation: "Patch summary, tests, and residual risk." }, + debug: { profile: "tester", codexWorkers: 1, claudeWorkers: 1, mode: "writable", outputExpectation: "Hypothesis, experiment, fix, and verification." }, + custom: { codexWorkers: 1, claudeWorkers: 1, mode: "read-only", outputExpectation: "User-defined output." }, + }, + launch: { + backend: "tmux", + }, stateDir: ".contextrelay/state", controlPort: 4502, + features: { + meshMode: { + enabled: false, + }, + }, codex: { appPort: 4500, proxyPort: 4501, @@ -107,6 +202,12 @@ const warnedConfigValues = new Set(); interface LegacyContextRelayConfig { version?: unknown; instanceId?: unknown; + activeProfile?: unknown; + profiles?: unknown; + wizardPresets?: unknown; + launch?: { + backend?: unknown; + }; stateDir?: unknown; controlPort?: unknown; daemon?: { @@ -138,6 +239,11 @@ interface LegacyContextRelayConfig { coordinator?: unknown; gitWrites?: unknown; }; + features?: { + meshMode?: { + enabled?: unknown; + }; + }; permissions?: { readonly?: unknown; allowed?: unknown; @@ -229,6 +335,65 @@ function normalizePermissionOverrides(value: unknown): ContextRelayConfig["permi return overrides; } +function normalizeLaunchBackend(value: unknown): MeshLaunchBackend { + return value === "terminal-windows" || value === "tmux" || value === "print-only" + ? value + : DEFAULT_CONFIG.launch.backend; +} + +function normalizeProfile(value: unknown): ContextRelayProfile | null { + if (!isRecord(value)) return null; + const profile: ContextRelayProfile = {}; + if (typeof value.description === "string" && value.description.trim()) { + profile.description = value.description.trim(); + } + if (isRecord(value.overrides)) { + profile.overrides = value.overrides as ContextRelayProfile["overrides"]; + } + return Object.keys(profile).length > 0 ? profile : {}; +} + +function normalizeProfiles(value: unknown): ContextRelayConfig["profiles"] { + const profiles: ContextRelayConfig["profiles"] = { ...DEFAULT_CONFIG.profiles }; + if (!isRecord(value)) return profiles; + for (const [name, raw] of Object.entries(value)) { + if (!/^[a-z][a-z0-9_-]{1,63}$/.test(name)) continue; + const profile = normalizeProfile(raw); + if (profile) profiles[name] = profile; + } + return profiles; +} + +function normalizeWizardPreset(value: unknown, fallback: MeshWizardPresetConfig): MeshWizardPresetConfig { + if (!isRecord(value)) return { ...fallback }; + const codexWorkers = normalizeInteger(value.codexWorkers, fallback.codexWorkers); + const claudeWorkers = normalizeInteger(value.claudeWorkers, fallback.claudeWorkers); + const mode = value.mode === "writable" || value.mode === "read-only" ? value.mode : fallback.mode; + return { + ...(typeof value.profile === "string" && value.profile.trim() ? { profile: value.profile.trim() } : fallback.profile ? { profile: fallback.profile } : {}), + codexWorkers: Math.max(0, Math.min(20, codexWorkers)), + claudeWorkers: Math.max(0, Math.min(20, claudeWorkers)), + mode, + ...(typeof value.outputExpectation === "string" && value.outputExpectation.trim() + ? { outputExpectation: value.outputExpectation.trim() } + : fallback.outputExpectation + ? { outputExpectation: fallback.outputExpectation } + : {}), + }; +} + +function normalizeWizardPresets(value: unknown): ContextRelayConfig["wizardPresets"] { + const raw = isRecord(value) ? value : {}; + return { + review: normalizeWizardPreset(raw.review, DEFAULT_CONFIG.wizardPresets.review), + debate: normalizeWizardPreset(raw.debate, DEFAULT_CONFIG.wizardPresets.debate), + plan: normalizeWizardPreset(raw.plan, DEFAULT_CONFIG.wizardPresets.plan), + implement: normalizeWizardPreset(raw.implement, DEFAULT_CONFIG.wizardPresets.implement), + debug: normalizeWizardPreset(raw.debug, DEFAULT_CONFIG.wizardPresets.debug), + custom: normalizeWizardPreset(raw.custom, DEFAULT_CONFIG.wizardPresets.custom), + }; +} + function normalizeConfig(raw: unknown): ContextRelayConfig | null { if (!isRecord(raw)) return null; @@ -238,11 +403,24 @@ function normalizeConfig(raw: unknown): ContextRelayConfig | null { const autonomy = isRecord(config.autonomy) ? config.autonomy : {}; const turnCoordination = isRecord(config.turnCoordination) ? config.turnCoordination : {}; const collaboration = isRecord(config.collaboration) ? config.collaboration : {}; + const features = isRecord(config.features) ? config.features : {}; + const meshMode = isRecord(features.meshMode) ? features.meshMode : {}; const permissions = isRecord(config.permissions) ? config.permissions : {}; + const launch = isRecord(config.launch) ? config.launch : {}; + const profiles = normalizeProfiles(config.profiles); + const activeProfile = typeof config.activeProfile === "string" && profiles[config.activeProfile] + ? config.activeProfile + : DEFAULT_CONFIG.activeProfile; return { version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version, instanceId: typeof config.instanceId === "string" ? config.instanceId : DEFAULT_CONFIG.instanceId, + ...(activeProfile ? { activeProfile } : {}), + profiles, + wizardPresets: normalizeWizardPresets(config.wizardPresets), + launch: { + backend: normalizeLaunchBackend(launch.backend), + }, stateDir: typeof config.stateDir === "string" ? config.stateDir : DEFAULT_CONFIG.stateDir, controlPort: normalizeInteger( config.controlPort ?? daemon.controlPort, @@ -258,6 +436,11 @@ function normalizeConfig(raw: unknown): ContextRelayConfig | null { DEFAULT_CONFIG.codex.proxyPort, ), }, + features: { + meshMode: { + enabled: normalizeBoolean(meshMode.enabled, DEFAULT_CONFIG.features.meshMode.enabled), + }, + }, autonomy: { enabled: normalizeBoolean(autonomy.enabled, DEFAULT_CONFIG.autonomy.enabled), autoFinalize: normalizeBoolean(autonomy.autoFinalize, DEFAULT_CONFIG.autonomy.autoFinalize), @@ -311,6 +494,13 @@ function truthyEnv(value: string | undefined): boolean { return value !== undefined && ["1", "true", "yes", "on"].includes(value.toLowerCase()); } +export function isMeshModeEnabled( + config: ContextRelayConfig, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return truthyEnv(env.CONTEXTRELAY_ENABLE_MESH) || config.features.meshMode.enabled; +} + function boundedInteger(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, Math.floor(value))); } diff --git a/src/control-protocol.ts b/src/control-protocol.ts index 8efea17..156a369 100644 --- a/src/control-protocol.ts +++ b/src/control-protocol.ts @@ -2,6 +2,7 @@ import type { BridgeMessage, HandoffPayload, LedgerArtifact, LedgerEntry, Messag import { ErrorCode } from "./errors"; import type { SessionParticipantStatus } from "./session-participants"; import type { SessionInspection } from "./session/registry"; +import type { ApprovalDecision, RoomRegistryInspection, RouteDecision } from "./session/rooms"; export interface BackupResultSummary { target: MessageSource; @@ -28,6 +29,8 @@ export interface RecentActivityEntry { content: string; status?: string; runtimeSessionId?: string; + roomId?: string; + laneId?: string; } export interface DaemonStatus { @@ -67,6 +70,8 @@ export interface DaemonStatus { defaultSession?: DaemonSessionStatus; registrySessions?: SessionInspection[]; sessionRegistryWarning?: string; + meshModeEnabled?: boolean; + roomRegistry?: RoomRegistryInspection; } export interface DaemonSessionStatus { @@ -110,19 +115,38 @@ export interface SessionRegistryEnsureInfo { } export type LedgerToolRequest = - | { type: "append_note"; sessionId?: string; runtimeSessionId?: string; source?: MessageSource; text: string; handles_handoff_id?: string } - | { type: "read_context"; sessionId?: string; target?: MessageSource; limit?: number } - | { type: "handoff"; sessionId?: string; runtimeSessionId?: string; handoff: HandoffPayload; handles_handoff_id?: string } - | { type: "ask_backup"; sessionId?: string; runtimeSessionId?: string; source: MessageSource; target: MessageSource; ask: string; reason?: string; context_refs?: string[]; handles_handoff_id?: string } + | { type: "append_note"; sessionId?: string; runtimeSessionId?: string; roomId?: string; laneId?: string; source?: MessageSource; text: string; handles_handoff_id?: string } + | { type: "read_context"; sessionId?: string; runtimeSessionId?: string; roomId?: string; laneId?: string; target?: MessageSource; limit?: number } + | { type: "handoff"; sessionId?: string; runtimeSessionId?: string; roomId?: string; laneId?: string; handoff: HandoffPayload; handles_handoff_id?: string } + | { type: "ask_backup"; sessionId?: string; runtimeSessionId?: string; roomId?: string; laneId?: string; source: MessageSource; target: MessageSource; ask: string; reason?: string; context_refs?: string[]; handles_handoff_id?: string } | { type: "backup_status"; sessionId?: string } - | { type: "propose_final"; sessionId?: string; runtimeSessionId?: string; source?: MessageSource; summary: string; evidence: string; remaining_risk?: string; handles_handoff_id?: string | string[] } + | { type: "propose_final"; sessionId?: string; runtimeSessionId?: string; roomId?: string; laneId?: string; source?: MessageSource; summary: string; evidence: string; remaining_risk?: string; handles_handoff_id?: string | string[] } | { type: "session_info"; sessionId?: string } | { type: "create_session"; sessionId?: string; id: string; label?: string; worktreePath?: string } | { type: "select_session"; sessionId?: string; id: string } | { type: "archive_session"; sessionId?: string; id: string } | { type: "rebind_session"; sessionId?: string; id: string; worktreePath?: string } | { type: "task_state"; sessionId?: string } - | { type: "record_artifact"; sessionId?: string; runtimeSessionId?: string; source?: MessageSource; target?: MessageSource; artifact: LedgerArtifact }; + | { type: "record_artifact"; sessionId?: string; runtimeSessionId?: string; roomId?: string; laneId?: string; source?: MessageSource; target?: MessageSource; artifact: LedgerArtifact } + | { type: "request_approval"; sessionId?: string; roomId: string; laneId: string; requesterParticipantId: string; reviewerParticipantId?: string; action: string; runtimeSessionId?: string; idempotencyKey?: string; expiresAt?: string; ttlMs?: number; reason?: string; provenance?: Record } + | { type: "decide_approval"; sessionId?: string; roomId: string; laneId: string; approvalId: string; reviewerParticipantId: string; decision: Exclude; reason?: string } + | { type: "expire_approvals"; sessionId?: string } + | { type: "mesh_status"; sessionId?: string } + | { type: "create_room"; sessionId?: string; id: string; label?: string; coordinatorParticipantId?: string; reviewerParticipantId?: string; pinnedContext?: string } + | { type: "select_room"; sessionId?: string; roomId: string } + | { type: "room_info"; sessionId?: string; roomId?: string } + | { type: "archive_room"; sessionId?: string; roomId: string } + | { type: "add_room_member"; sessionId?: string; roomId: string; participantId: string } + | { type: "remove_room_member"; sessionId?: string; roomId: string; participantId: string } + | { type: "assign_room_coordinator"; sessionId?: string; roomId: string; participantId: string } + | { type: "create_lane"; sessionId?: string; roomId: string; laneId: string; label?: string; participantId?: string; worktreePath?: string; permissions?: string[] } + | { type: "enter_lane"; sessionId?: string; roomId: string; laneId: string; participantId?: string; runtimeSessionId?: string; source?: MessageSource } + | { type: "lane_info"; sessionId?: string; roomId: string; laneId?: string } + | { type: "complete_lane"; sessionId?: string; roomId: string; laneId: string } + | { type: "archive_lane"; sessionId?: string; roomId: string; laneId: string } + | { type: "assign_participant_to_lane"; sessionId?: string; roomId: string; laneId: string; participantId: string } + | { type: "remove_participant_from_lane"; sessionId?: string; roomId: string; laneId: string; participantId: string } + | { type: "route_event"; sessionId?: string; route: RouteDecision }; export type LedgerToolResult = | { ok: true; entry?: LedgerEntry; entries?: LedgerEntry[]; summary?: Record; latestActiveHandoff?: LedgerEntry | null; backupStatus?: BackupStatus; sessionId: string; message?: string; alreadyArchived?: boolean; alreadyBound?: boolean } @@ -189,14 +213,49 @@ function isBridgeMessage(value: unknown): value is BridgeMessage { if (typeof value.content !== "string") return false; if (typeof value.timestamp !== "number") return false; if (value.roomId !== undefined && typeof value.roomId !== "string") return false; + if (value.laneId !== undefined && typeof value.laneId !== "string") return false; if (value.target !== undefined && !isMessageSource(value.target)) return false; if (value.caller_agent !== undefined && !isMessageSource(value.caller_agent)) return false; if (value.caller_depth !== undefined && typeof value.caller_depth !== "number") return false; if (value.handles_handoff_id !== undefined && typeof value.handles_handoff_id !== "string") return false; if (value.message_kind !== undefined && typeof value.message_kind !== "string") return false; + if (value.messageId !== undefined && typeof value.messageId !== "string") return false; + if (value.traceId !== undefined && typeof value.traceId !== "string") return false; + if (value.idempotencyKey !== undefined && typeof value.idempotencyKey !== "string") return false; + if (value.deliveryMode !== undefined && value.deliveryMode !== "online-only" && value.deliveryMode !== "store-if-offline") return false; + if (value.ack !== undefined && value.ack !== "requested" && value.ack !== "delivered" && value.ack !== "queued" && value.ack !== "dropped" && value.ack !== "failed") return false; + if (value.ttl !== undefined && typeof value.ttl !== "number") return false; + if (value.visited !== undefined && (!Array.isArray(value.visited) || !value.visited.every((item) => typeof item === "string"))) return false; + if (value.from !== undefined && !isRouteEndpoint(value.from)) return false; + if (value.to !== undefined && !isRouteEndpoint(value.to)) return false; + if (value.routing !== undefined && !isRoutingEnvelope(value.routing)) return false; return true; } +function isRouteEndpoint(value: unknown): boolean { + if (!isObject(value)) return false; + return (value.participantId === undefined || typeof value.participantId === "string") + && (value.runtimeSessionId === undefined || typeof value.runtimeSessionId === "string") + && (value.roomId === undefined || typeof value.roomId === "string") + && (value.laneId === undefined || typeof value.laneId === "string"); +} + +function isRoutingEnvelope(value: unknown): boolean { + if (!isObject(value)) return false; + return typeof value.messageId === "string" + && typeof value.traceId === "string" + && typeof value.idempotencyKey === "string" + && isRouteEndpoint(value.from) + && isRouteEndpoint(value.to) + && (value.roomId === undefined || typeof value.roomId === "string") + && (value.laneId === undefined || typeof value.laneId === "string") + && (value.deliveryMode === "online-only" || value.deliveryMode === "store-if-offline") + && (value.ack === "requested" || value.ack === "delivered" || value.ack === "queued" || value.ack === "dropped" || value.ack === "failed") + && typeof value.ttl === "number" + && Array.isArray(value.visited) + && value.visited.every((item) => typeof item === "string"); +} + const LEDGER_TOOL_KINDS = new Set([ "append_note", "read_context", @@ -211,12 +270,33 @@ const LEDGER_TOOL_KINDS = new Set([ "rebind_session", "task_state", "record_artifact", + "request_approval", + "decide_approval", + "expire_approvals", + "mesh_status", + "create_room", + "select_room", + "room_info", + "archive_room", + "add_room_member", + "remove_room_member", + "assign_room_coordinator", + "create_lane", + "enter_lane", + "lane_info", + "complete_lane", + "archive_lane", + "assign_participant_to_lane", + "remove_participant_from_lane", + "route_event", ]); function isLedgerToolRequestEnvelope(value: unknown): value is LedgerToolRequest { if (!isObject(value)) return false; if (typeof value.type !== "string") return false; if (value.runtimeSessionId !== undefined && typeof value.runtimeSessionId !== "string") return false; + if (value.roomId !== undefined && typeof value.roomId !== "string") return false; + if (value.laneId !== undefined && typeof value.laneId !== "string") return false; return (LEDGER_TOOL_KINDS as Set).has(value.type); } diff --git a/src/daemon.ts b/src/daemon.ts index c4e7894..f362cfd 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { randomUUID } from "node:crypto"; import { appendFileSync, realpathSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,7 +18,7 @@ import { TuiConnectionState } from "./tui-connection-state"; import { ClaudeAttachmentState } from "./claude-attachment-state"; import { DaemonLifecycle } from "./daemon-lifecycle"; import { StateDirResolver } from "./state-dir"; -import { ConfigService } from "./config-service"; +import { ConfigService, isMeshModeEnabled } from "./config-service"; import { CLOSE_CODE_EVICTED_STALE, CLOSE_CODE_REPLACED, validateControlClientMessage } from "./control-protocol"; import type { BackupResultSummary, CodexRuntimeLaunchInfo, ControlClientMessage, ControlServerMessage, DaemonStatus, LedgerToolRequest, LedgerToolResult, RecentActivityEntry } from "./control-protocol"; import { ErrorCode } from "./errors"; @@ -58,12 +59,22 @@ import { recentTurnWatchdogDiagnostic } from "./codex-diagnostics"; import { buildClaudeParticipant, buildCodexParticipant } from "./session-participants"; import { SessionRegistry, - inspectSessionRegistry, inspectSessionRegistryData, isForeignRegistryIssue, isValidRuntimeSessionId, + sessionRegistryStatePath, type RegisteredCodexRuntime, + type SessionRegistryInspection, } from "./session/registry"; +import { + RoomRegistry, + inspectRoomRegistryData, + isForeignRoomRegistryIssue, + participantIdFor, + roomRegistryStatePath, + runtimeSessionHasOpenReadOnlyLane, + type RouteDecision, +} from "./session/rooms"; import { allocateCodexRuntimePortPair, canBindPort, @@ -128,11 +139,12 @@ const daemonIdentity = ensureDaemonIdentity(stateDir); const daemonEntry = currentDaemonEntrySnapshot(); const INSTANCE_ID = envValue("CONTEXTRELAY_INSTANCE_ID") ?? config.instanceId; const PROJECT_ROOT = envValue("CONTEXTRELAY_PROJECT_ROOT") ?? process.cwd(); -const sessionRegistry = new SessionRegistry(PROJECT_ROOT, INSTANCE_ID); +const sessionRegistry = new SessionRegistry(PROJECT_ROOT, INSTANCE_ID, undefined, sessionRegistryStatePath(stateDir.dir)); sessionRegistry.ensureDefaultSession(); if (sessionRegistry.lastIssue) { log(sessionRegistry.lastIssue); } +const roomRegistry = new RoomRegistry(PROJECT_ROOT, INSTANCE_ID, undefined, roomRegistryStatePath(stateDir.dir)); const codexAppPort = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10); const codexProxyPort = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10); @@ -154,6 +166,7 @@ const ATTENTION_WINDOW_MS = envInt("CONTEXTRELAY_ATTENTION_WINDOW_MS", config.tu const STATUS_BUFFERING_ENABLED = config.turnCoordination.bufferStatusDuringAttention === true; const MAX_CALLER_DEPTH = envInt("CONTEXTRELAY_MAX_DEPTH", 3); const BACKUP_THROTTLE_MS = envInt("CONTEXTRELAY_BACKUP_THROTTLE_MS", 60_000); +const APPROVAL_EXPIRY_SWEEP_INTERVAL_MS = envInt("CONTEXTRELAY_APPROVAL_EXPIRY_SWEEP_INTERVAL_MS", 60_000); const daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log }); @@ -174,6 +187,8 @@ const authLockout = new AuthLockout(); let nextControlClientId = 0; let nextSystemMessageId = 0; let shuttingDown = false; +let approvalExpirySweepTimer: ReturnType | null = null; +let approvalExpirySweepInFlight = false; const ATTACH_STATUS_COOLDOWN_MS = 30_000; // Don't re-send status on rapid reattach const MAX_PENDING_CODEX_INJECTIONS = 50; const defaultSession = createDefaultSessionState(DEFAULT_SESSION_ID, defaultCodexRuntime); @@ -350,8 +365,15 @@ function portFromRuntimeUrl(value: string, label: string): number { return port; } -function optInNamedSessionsEnabled(): boolean { - return envValue(NAMED_SESSION_OPT_IN_ENV) === "1"; +function optInNamedSessionsEnabled(runtimeSessionId?: string): boolean { + return envValue(NAMED_SESSION_OPT_IN_ENV) === "1" || Boolean(runtimeSessionId && runtimeSessionHasReadOnlyMeshLane(runtimeSessionId)); +} + +function runtimeSessionHasReadOnlyMeshLane(runtimeSessionId: string): boolean { + if (!meshModeEnabled()) return false; + const result = roomRegistry.load(); + if (isForeignRoomRegistryIssue(result.issue)) return false; + return runtimeSessionHasOpenReadOnlyLane(result.registry, runtimeSessionId); } function allocationReservedPorts(sessionId: string, registry: ReturnType["registry"], extraReserved: Iterable = []): Set { @@ -403,26 +425,28 @@ function claimNamedCodexRuntimeLaunch(sessionId: RuntimeSessionId): void { const validation = validateSessionLaunchRequest({ sessionId, - optInEnabled: optInNamedSessionsEnabled(), + optInEnabled: optInNamedSessionsEnabled(sessionId), registry: result.registry, }); if (!validation.ok) { throw new ControlRequestError(ErrorCode.INVALID_REQUEST, validation.error); } - const conflict = findSharedWorktreeRuntimeConflict({ - registry: result.registry, - sessionId, - launchedSessionIds: new Set([ - ...sessions.keys(), - ...namedRuntimeLaunchClaims, - ]), - }); - if (conflict) { - throw new ControlRequestError( - ErrorCode.INVALID_REQUEST, - `Runtime session ${sessionId} shares worktree ${conflict.worktreePath} with launched runtime session ${conflict.sessionId}. Stop it with \`${PRIMARY_BIN} kill --session ${conflict.sessionId}\` or rebind one session before launching.`, - ); + if (!runtimeSessionHasReadOnlyMeshLane(sessionId)) { + const conflict = findSharedWorktreeRuntimeConflict({ + registry: result.registry, + sessionId, + launchedSessionIds: new Set([ + ...sessions.keys(), + ...namedRuntimeLaunchClaims, + ]), + }); + if (conflict) { + throw new ControlRequestError( + ErrorCode.INVALID_REQUEST, + `Runtime session ${sessionId} shares worktree ${conflict.worktreePath} with launched runtime session ${conflict.sessionId}. Stop it with \`${PRIMARY_BIN} kill --session ${conflict.sessionId}\` or rebind one session before launching.`, + ); + } } namedRuntimeLaunchClaims.add(sessionId); @@ -438,7 +462,7 @@ async function ensureCodexRuntime(sessionIdInput: string): Promise= MAX_CALLER_DEPTH) { return { @@ -1456,12 +1481,27 @@ async function executeLedgerTool(request: LedgerToolRequest): Promise item.id === request.roomId) : undefined; + if (request.roomId && !room) return { ok: false, code: ErrorCode.INVALID_REQUEST, error: `Unknown room: ${request.roomId}` }; + return { + ok: true, + summary: request.roomId ? { room, warning: inspection.warning } : inspection as unknown as Record, + sessionId: request.sessionId ?? sessionId, + }; + } + case "archive_room": { + const gate = requireMeshMode(); + if (gate) return gate; + try { + const { registry, alreadyArchived } = roomRegistry.archiveRoom(request.roomId); + return { ...roomRegistryResult(registry, request.sessionId, alreadyArchived ? `Room ${request.roomId} was already archived.` : `Room ${request.roomId} archived.`), alreadyArchived }; + } catch (err: any) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "add_room_member": + case "remove_room_member": + case "assign_room_coordinator": { + const gate = requireMeshMode(); + if (gate) return gate; + try { + const registry = request.type === "add_room_member" + ? roomRegistry.addMember(request.roomId, request.participantId) + : request.type === "remove_room_member" + ? roomRegistry.removeMember(request.roomId, request.participantId) + : roomRegistry.assignCoordinator(request.roomId, request.participantId); + return roomRegistryResult(registry, request.sessionId, `Room ${request.roomId} updated.`); + } catch (err: any) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "create_lane": { + const gate = requireMeshMode(); + if (gate) return gate; + try { + return roomRegistryResult( + roomRegistry.createLane(request.roomId, request.laneId, { + label: request.label, + participantId: request.participantId, + worktreePath: request.worktreePath, + permissions: request.permissions?.filter(isPermissionCapability), + }), + request.sessionId, + `Lane ${request.laneId} created in room ${request.roomId}.`, + ); + } catch (err: any) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "enter_lane": { + const gate = requireMeshMode(); + if (gate) return gate; + try { + const participantId = request.participantId ?? participantIdFor(request.source ?? "agent", request.runtimeSessionId); + return roomRegistryResult( + roomRegistry.enterLane(request.roomId, request.laneId, participantId), + request.sessionId, + `Participant ${participantId} entered lane ${request.laneId} in room ${request.roomId}.`, + ); + } catch (err: any) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "lane_info": { + const gate = requireMeshMode(); + if (gate) return gate; + const inspection = roomRegistry.inspect(); + const room = inspection.rooms.find((item) => item.id === request.roomId); + if (!room) return { ok: false, code: ErrorCode.INVALID_REQUEST, error: `Unknown room: ${request.roomId}` }; + const lane = request.laneId ? room.lanes[request.laneId] : undefined; + if (request.laneId && !lane) return { ok: false, code: ErrorCode.INVALID_REQUEST, error: `Unknown lane ${request.laneId} in room ${request.roomId}` }; + return { + ok: true, + summary: request.laneId ? { roomId: request.roomId, lane, warning: inspection.warning } : { roomId: request.roomId, lanes: room.lanes, activeLaneId: room.activeLaneId, warning: inspection.warning }, + sessionId: request.sessionId ?? sessionId, + }; + } + case "complete_lane": { + const gate = requireMeshMode(); + if (gate) return gate; + try { + const registry = roomRegistry.completeLane(request.roomId, request.laneId); + await ledger.append({ + type: "route", + source: "system", + content: `Room heartbeat: lane ${request.laneId} completed in room ${request.roomId}.`, + meta: { heartbeat: true, roomId: request.roomId, laneId: request.laneId }, + }); + return roomRegistryResult(registry, request.sessionId, `Lane ${request.laneId} completed.`); + } catch (err: any) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "archive_lane": { + const gate = requireMeshMode(); + if (gate) return gate; + try { + const { registry, alreadyArchived } = roomRegistry.archiveLane(request.roomId, request.laneId); + return { ...roomRegistryResult(registry, request.sessionId, alreadyArchived ? `Lane ${request.laneId} was already archived.` : `Lane ${request.laneId} archived.`), alreadyArchived }; + } catch (err: any) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "assign_participant_to_lane": + case "remove_participant_from_lane": { + const gate = requireMeshMode(); + if (gate) return gate; + try { + const registry = request.type === "assign_participant_to_lane" + ? roomRegistry.assignParticipantToLane(request.roomId, request.laneId, request.participantId) + : roomRegistry.removeParticipantFromLane(request.roomId, request.laneId, request.participantId); + return roomRegistryResult(registry, request.sessionId, `Lane ${request.laneId} updated.`); + } catch (err: any) { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: err?.message ?? String(err) }; + } + } + case "route_event": { + return recordRouteDecision(request.route, request.sessionId ?? sessionId); + } } } @@ -1660,6 +1985,126 @@ function resolveRuntimeSessionId( return { ok: true, runtimeSessionId }; } +function meshModeEnabled(): boolean { + return isMeshModeEnabled(configService.loadOrDefault()); +} + +function requireMeshMode(): LedgerToolResult | null { + if (meshModeEnabled()) return null; + return { + ok: false, + code: ErrorCode.INVALID_REQUEST, + error: "Mesh mode is disabled. Enable it with `contextrelay mesh enable` or set CONTEXTRELAY_ENABLE_MESH=1.", + }; +} + +function startApprovalExpirySweep(): void { + if (APPROVAL_EXPIRY_SWEEP_INTERVAL_MS <= 0 || approvalExpirySweepTimer) return; + approvalExpirySweepTimer = setInterval(() => { + void sweepExpiredApprovals().catch((err) => { + log(`Approval expiry sweep failed: ${err?.message ?? String(err)}`); + }); + }, APPROVAL_EXPIRY_SWEEP_INTERVAL_MS); + (approvalExpirySweepTimer as any).unref?.(); +} + +async function sweepExpiredApprovals(): Promise { + if (approvalExpirySweepInFlight || !meshModeEnabled()) return; + approvalExpirySweepInFlight = true; + try { + const { expired } = roomRegistry.expireApprovals(); + for (const item of expired) { + const sid = resolveLedgerSessionId({ runtimeSessionId: item.approval.runtimeSessionId }); + await ledger.append({ + sessionId: sid, + type: "approval_expired", + source: "system", + target: "codex", + content: `approval expired: ${item.approval.action} in ${item.roomId}/${item.laneId}`, + meta: { + approval: item.approval, + roomId: item.roomId, + laneId: item.laneId, + runtimeSessionId: item.approval.runtimeSessionId, + idempotencyKey: item.approval.idempotencyKey, + }, + }); + } + if (expired.length > 0) { + log(`Expired ${expired.length} pending approval${expired.length === 1 ? "" : "s"}.`); + } + } finally { + approvalExpirySweepInFlight = false; + } +} + +function resolveLedgerSessionId(request: { sessionId?: string; runtimeSessionId?: string }): string { + if (request.sessionId) return request.sessionId; + if (!meshModeEnabled() || !request.runtimeSessionId) return sessionId; + const resolved = resolveRuntimeSessionId({ runtimeSessionId: request.runtimeSessionId }); + if (!resolved.ok) return sessionId; + const registry = sessionRegistry.load().registry; + const runtime = registry.sessions[request.runtimeSessionId]; + if (runtime?.transcriptSessionId) return runtime.transcriptSessionId; + const transcriptSessionId = request.runtimeSessionId === DEFAULT_SESSION_ID + ? sessionId + : `session_${Date.now()}_${randomUUID().slice(0, 8)}`; + try { + sessionRegistry.ensureTranscriptSessionId(request.runtimeSessionId, transcriptSessionId); + } catch (err: any) { + log(`Failed to persist transcriptSessionId for ${request.runtimeSessionId}: ${err?.message ?? String(err)}`); + } + return transcriptSessionId; +} + +function meshScopeMeta(request: { roomId?: string; laneId?: string }): Record { + return { + ...(request.roomId ? { roomId: request.roomId } : {}), + ...(request.laneId ? { laneId: request.laneId } : {}), + }; +} + +function roomRegistryResult(registry: ReturnType["registry"], sid: string | undefined, message: string): Extract { + const inspection = inspectRoomRegistryData(registry, isForeignRoomRegistryIssue(roomRegistry.lastIssue ?? undefined) ? roomRegistry.lastIssue ?? undefined : undefined); + return { + ok: true, + summary: { ...inspection }, + sessionId: sid ?? sessionId, + message, + }; +} + +async function recordRouteDecision(route: RouteDecision, sid: string): Promise { + const entry = await ledger.append({ + sessionId: sid, + type: "route", + source: "system", + // v1.2.0 route entries are system-visible heartbeat/audit records. A later + // router can set the concrete participant target when routes become more + // than Codex/CLI-mediated coordination events. + target: "claude", + content: `route ${route.decision}: ${route.reason}`, + meta: { + route, + traceId: route.traceId, + roomId: route.from.roomId ?? route.to.roomId, + laneId: route.from.laneId ?? route.to.laneId, + }, + }); + return { ok: true, entry, sessionId: entry.sessionId }; +} + +function isPermissionCapability(value: string): value is import("./agents").PermissionCapability { + return value === "read" + || value === "write" + || value === "shell" + || value === "network" + || value === "git" + || value === "secrets" + || value === "browser" + || value === "external_api"; +} + function resolveRuntimeSessionMeta( request: { runtimeSessionId?: string }, ): { ok: true; meta?: Record } | { ok: false; result: LedgerToolResult } { @@ -1679,7 +2124,7 @@ function resolveLiveRuntimeSession( if (runtimeSessionId === DEFAULT_SESSION_ID) { return { ok: true, session: activeRuntimeSession() }; } - if (!optInNamedSessionsEnabled()) { + if (!optInNamedSessionsEnabled(runtimeSessionId)) { return { ok: false, code: ErrorCode.INVALID_REQUEST, @@ -2450,7 +2895,7 @@ function currentStatus(): DaemonStatus { const taskBoard = deriveTaskBoard(ledger.read(sessionId, 1_000), { claudeResponseTimeoutMs: CLAUDE_RESPONSE_TIMEOUT_MS }); const activeSession = activeRuntimeSession(); const activeCodex = activeSession.codexRuntime; - const registryInspection = inspectSessionRegistry(PROJECT_ROOT, INSTANCE_ID); + const registryInspection = inspectRuntimeSessionRegistry(); const operationPaths = new Map([[DEFAULT_SESSION_ID, PROJECT_ROOT]]); for (const session of registryInspection.sessions) { if (session.worktreePath) operationPaths.set(session.id, session.worktreePath); @@ -2458,6 +2903,8 @@ function currentStatus(): DaemonStatus { const runtimeSessions = buildRuntimeSessionStatusMap(taskBoard, operationPaths); const activeSessionStatus = runtimeSessions[activeSession.id] ?? buildRuntimeSessionStatus(activeSession, taskBoard, operationPaths.get(activeSession.id)); const defaultSessionStatus = runtimeSessions[DEFAULT_SESSION_ID] ?? activeSessionStatus; + const meshEnabled = meshModeEnabled(); + const roomRegistryInspection = roomRegistry.inspect(); return { instanceId: INSTANCE_ID, projectRoot: PROJECT_ROOT, @@ -2495,9 +2942,16 @@ function currentStatus(): DaemonStatus { defaultSession: defaultSessionStatus, registrySessions: registryInspection.sessions, sessionRegistryWarning: registryInspection.warning, + meshModeEnabled: meshEnabled, + roomRegistry: roomRegistryInspection, }; } +function inspectRuntimeSessionRegistry(): SessionRegistryInspection { + const result = sessionRegistry.load(); + return inspectSessionRegistryData(result.registry, result.registry.activeSessionId, result.issue); +} + function viewerEventStreamResponse(): Response { let client: ViewerEventClient | null = null; const stream = new ReadableStream({ @@ -2584,6 +3038,8 @@ function toRecentActivityEntry(entry: LedgerEntry): RecentActivityEntry { ? entry.meta.artifact_status : undefined, runtimeSessionId: typeof entry.meta?.runtimeSessionId === "string" ? entry.meta.runtimeSessionId : undefined, + roomId: typeof entry.meta?.roomId === "string" ? entry.meta.roomId : undefined, + laneId: typeof entry.meta?.laneId === "string" ? entry.meta.laneId : undefined, }; } @@ -2600,6 +3056,7 @@ function isLiveActivityEntry(entry: LedgerEntry): boolean { || entry.type === "backup_result" || entry.type === "release_gate" || entry.type === "artifact" + || entry.type === "route" || entry.type === "runtime_event" || entry.type === "error" || entry.type === "decision" @@ -2611,6 +3068,7 @@ function isTuiActivityEntry(entry: LedgerEntry): boolean { if (entry.type === "finality_proposal" || entry.type === "finality_decision") return true; if (entry.type === "backup_request" || entry.type === "backup_result") return true; if (entry.type === "release_gate") return true; + if (entry.type === "route") return true; if (entry.type !== "runtime_event") return false; return entry.runtimeEvent?.status === "failed" || entry.runtimeEvent?.status === "blocked"; } @@ -2656,6 +3114,12 @@ function currentViewerContext(url: URL): Record { tuiConnected: status.tuiConnected, queuedMessageCount: status.queuedMessageCount, }, + mesh: { + enabled: Boolean(status.meshModeEnabled), + configEnabled: configService.loadOrDefault().features.meshMode.enabled, + envEnabled: envValue("CONTEXTRELAY_ENABLE_MESH") !== undefined, + roomRegistry: status.roomRegistry ?? roomRegistry.inspect(), + }, claudeResponseTimeoutMs: CLAUDE_RESPONSE_TIMEOUT_MS, }), }; @@ -2784,6 +3248,15 @@ async function shutdown(reason: string) { log(`Shutting down daemon (${reason})...`); const stepResults = await runShutdownSteps([ + { + name: "approval_expiry_sweep", + run: () => { + if (approvalExpirySweepTimer) { + clearInterval(approvalExpirySweepTimer); + approvalExpirySweepTimer = null; + } + }, + }, { name: "tui_connection_state", run: () => activeRuntimeSession().tuiConnectionState.dispose(`daemon shutdown (${reason})`), @@ -2874,5 +3347,7 @@ if (daemonLifecycle.wasKilled()) { writePidFile(); log(`Build: commit=${BUILD_INFO.commit.slice(0, 12)} builtAt=${BUILD_INFO.builtAt} bun=${BUILD_INFO.bunVersion}`); +startApprovalExpirySweep(); startControlServer(); +writeStatusFile(); void bootCodex(); diff --git a/src/e2e-cli.test.ts b/src/e2e-cli.test.ts index 8f68a19..23e2f8b 100644 --- a/src/e2e-cli.test.ts +++ b/src/e2e-cli.test.ts @@ -137,8 +137,13 @@ class CliE2EHarness { "utf-8", ); + const { + CONTEXTRELAY_ENABLE_MESH: _inheritedMesh, + CONTEXTRELAY_CLAUDE_DEVELOPMENT_CHANNELS: _inheritedClaudeChannels, + ...baseEnv + } = process.env; const env: NodeJS.ProcessEnv = { - ...process.env, + ...baseEnv, HOME: rootDir, PATH: `${binDir}:${process.env.PATH ?? ""}`, CONTEXTRELAY_REGISTRY_DIR: registryDir, @@ -574,8 +579,7 @@ describe("E2E: CLI surface", () => { .filter((entry) => entry.args[0] !== "--version" && entry.args[0] !== "plugin"); expect(invocations.length).toBe(1); expect(invocations[0]?.args).toEqual([ - "--dangerously-load-development-channels", - "plugin:contextrelay@contextrelay", + "--dangerously-load-development-channels=plugin:contextrelay@contextrelay", "--resume", ]); // The harness sets CONTEXTRELAY_MODE=pull; the CLI must honor that override @@ -615,8 +619,7 @@ describe("E2E: CLI surface", () => { channelsEnabled: true, allowedChannelPlugins: [{ marketplace: "contextrelay", plugin: "contextrelay" }], }), - "--channels", - "plugin:contextrelay@contextrelay", + "--channels=plugin:contextrelay@contextrelay", "--resume", ]); }); @@ -1021,8 +1024,8 @@ describe("E2E: CLI surface", () => { const claudeRun = harness .readShimCalls("claude") - .find((entry) => entry.args[0] === "--dangerously-load-development-channels"); - expect(claudeRun?.args[1]).toBe("plugin:contextrelay@contextrelay"); + .find((entry) => entry.args[0] === "--dangerously-load-development-channels=plugin:contextrelay@contextrelay"); + expect(claudeRun).toBeDefined(); const codexRun = harness .readShimCalls("codex") @@ -1448,7 +1451,7 @@ const daemonIdentityFile = join(stateDir, "daemon-identity"); const killedFile = join(stateDir, "killed"); const proxyUrl = \`ws://127.0.0.1:\${proxyPort}\`; const appServerUrl = \`ws://127.0.0.1:\${appPort}\`; -const registryFile = join(projectRoot, ".contextrelay", "sessions.json"); +const registryFile = join(stateDir, "sessions.json"); function currentDaemonEntry() { const path = process.env.CONTEXTRELAY_DAEMON_ENTRY; diff --git a/src/instance.ts b/src/instance.ts index 303f5e3..28cfde8 100644 --- a/src/instance.ts +++ b/src/instance.ts @@ -62,13 +62,18 @@ export async function resolveProjectInstance(options: ResolveInstanceOptions = { const baseInstanceId = config.instanceId || createInstanceId(projectRoot); const portBaseInstanceId = createPortBaseInstanceId(baseInstanceId, requestedBase); const stateDir = resolvePortBaseStateDir(projectRoot, config.stateDir, requestedBase); + const liveGroup = await readLivePortGroupFromState({ + stateDir, + instanceId: portBaseInstanceId, + projectRoot, + }); if (options.persist === false) { - const group = await resolvePortGroup(projectRoot, config, requestedBase, null, null); + const group = await resolvePortGroup(projectRoot, config, requestedBase, liveGroup, null); return instanceFromParts(projectRoot, portBaseInstanceId, stateDir, group); } return withRegistryLock(async () => { - const group = await resolvePortGroup(projectRoot, config, requestedBase, null, null); + const group = await resolvePortGroup(projectRoot, config, requestedBase, liveGroup, null); const instance = instanceFromParts(projectRoot, portBaseInstanceId, stateDir, group); registerInstanceUnlocked(instance); return instance; diff --git a/src/session/git-worktree.ts b/src/session/git-worktree.ts new file mode 100644 index 0000000..4f120ab --- /dev/null +++ b/src/session/git-worktree.ts @@ -0,0 +1,155 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; + +export interface GitWorktreeRecord { + path: string; + branch?: string; + head?: string; + bare?: boolean; + detached?: boolean; + prunable?: string; +} + +export interface WorktreePlan { + targetPath: string; + branch: string; + base?: string; + canCreate: boolean; + issues: string[]; + existing: GitWorktreeRecord[]; +} + +export interface WorktreeStatus { + worktree: GitWorktreeRecord; + exists: boolean; + dirty: boolean; + prunable: boolean; + detail: string; +} + +type ExecFileSyncLike = typeof execFileSync; + +export interface WorktreeRunnerOptions { + cwd?: string; + execFileSyncFn?: ExecFileSyncLike; +} + +export function listGitWorktrees(options: WorktreeRunnerOptions = {}): GitWorktreeRecord[] { + const exec = options.execFileSyncFn ?? execFileSync; + const output = exec("git", ["worktree", "list", "--porcelain"], { + cwd: options.cwd ?? process.cwd(), + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }) as string; + return parseGitWorktreeList(output); +} + +export function parseGitWorktreeList(output: string): GitWorktreeRecord[] { + const records: GitWorktreeRecord[] = []; + let current: GitWorktreeRecord | null = null; + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) { + if (current) records.push(current); + current = null; + continue; + } + const [key, ...rest] = line.split(" "); + const value = rest.join(" "); + if (key === "worktree") { + if (current) records.push(current); + current = { path: value }; + } else if (current && key === "HEAD") current.head = value; + else if (current && key === "branch") current.branch = value.replace(/^refs\/heads\//, ""); + else if (current && key === "bare") current.bare = true; + else if (current && key === "detached") current.detached = true; + else if (current && key === "prunable") current.prunable = value || "true"; + } + if (current) records.push(current); + return records; +} + +export function planGitWorktreeCreate(options: WorktreeRunnerOptions & { targetPath: string; branch: string; base?: string }): WorktreePlan { + const targetPath = resolve(options.targetPath); + const existing = listGitWorktrees(options); + const issues: string[] = []; + const normalizedTarget = normalizePathForCompare(targetPath); + for (const worktree of existing) { + if (normalizePathForCompare(worktree.path) === normalizedTarget) { + issues.push(`target path is already a git worktree: ${targetPath}`); + } + if (worktree.branch === options.branch) { + issues.push(`branch is already checked out in ${worktree.path}: ${options.branch}`); + } + } + if (existsSync(targetPath) && !existing.some((worktree) => normalizePathForCompare(worktree.path) === normalizedTarget)) { + issues.push(`target path already exists but is not a registered git worktree: ${targetPath}`); + } + return { + targetPath, + branch: options.branch, + ...(options.base ? { base: options.base } : {}), + existing, + issues, + canCreate: issues.length === 0, + }; +} + +export function createGitWorktree(options: WorktreeRunnerOptions & { targetPath: string; branch: string; base?: string; confirm?: boolean }): WorktreePlan { + const plan = planGitWorktreeCreate(options); + if (!options.confirm) throw new Error("Refusing to create git worktree without explicit --confirm."); + if (!plan.canCreate) throw new Error(`Cannot create git worktree: ${plan.issues.join("; ")}`); + const exec = options.execFileSyncFn ?? execFileSync; + const args = ["worktree", "add", "-b", options.branch, plan.targetPath, options.base ?? "HEAD"]; + exec("git", args, { + cwd: options.cwd ?? process.cwd(), + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + return plan; +} + +export function statusGitWorktrees(options: WorktreeRunnerOptions = {}): WorktreeStatus[] { + const exec = options.execFileSyncFn ?? execFileSync; + return listGitWorktrees(options).map((worktree) => { + const exists = existsSync(worktree.path); + let dirty = false; + let detail = exists ? "clean" : "missing"; + if (exists) { + try { + const output = exec("git", ["status", "--porcelain"], { + cwd: worktree.path, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }) as string; + dirty = output.trim().length > 0; + detail = dirty ? "dirty" : "clean"; + } catch (err: any) { + detail = err?.message ?? String(err); + } + } + return { worktree, exists, dirty, prunable: Boolean(worktree.prunable), detail }; + }); +} + +export function removeGitWorktree(options: WorktreeRunnerOptions & { targetPath: string; confirm?: boolean }): void { + if (!options.confirm) throw new Error("Refusing to remove git worktree without explicit --confirm."); + const targetPath = resolve(options.targetPath); + const status = statusGitWorktrees(options).find((item) => normalizePathForCompare(item.worktree.path) === normalizePathForCompare(targetPath)); + if (!status) throw new Error(`Unknown git worktree: ${targetPath}`); + if (status.dirty) throw new Error(`Refusing to remove dirty git worktree: ${targetPath}`); + const exec = options.execFileSyncFn ?? execFileSync; + exec("git", ["worktree", "remove", targetPath], { + cwd: options.cwd ?? process.cwd(), + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function normalizePathForCompare(path: string): string { + try { + return realpathSync.native(path); + } catch { + return resolve(path); + } +} diff --git a/src/session/ledger.ts b/src/session/ledger.ts index e39375b..03009ce 100644 --- a/src/session/ledger.ts +++ b/src/session/ledger.ts @@ -198,6 +198,17 @@ export class SessionLedger { meta: { bridgeMessageId: message.id, roomId: message.roomId, + laneId: message.laneId, + messageId: message.messageId, + traceId: message.traceId, + idempotencyKey: message.idempotencyKey, + deliveryMode: message.deliveryMode, + ack: message.ack, + ttl: message.ttl, + visited: message.visited, + routeFrom: message.from, + routeTo: message.to, + routing: message.routing, message_kind: message.message_kind ?? "chat", auto_handled_handoff: handlesHandoffId && !message.handles_handoff_id ? true : undefined, }, @@ -210,8 +221,8 @@ export class SessionLedger { callerDepth = 0, handlesHandoffId?: string, meta?: Record, + sessionId = this.getOrCreateCurrentSessionId(), ): Promise { - const sessionId = this.getOrCreateCurrentSessionId(); const duplicate = this.findDuplicateActiveHandoff(sessionId, handoff, source, meta?.runtimeSessionId); if (duplicate) return duplicate; diff --git a/src/session/mesh-wizard.ts b/src/session/mesh-wizard.ts new file mode 100644 index 0000000..2b5838b --- /dev/null +++ b/src/session/mesh-wizard.ts @@ -0,0 +1,204 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { LedgerToolRequest, LedgerToolResult } from "../control-protocol"; + +export type MeshWizardMode = "read-only" | "writable"; + +export interface MeshWizardStartOptions { + roomId: string; + codexWorkers: number; + claudeWorkers: number; + mode?: MeshWizardMode; + worktreePrefix?: string; + commandBin?: string; +} + +export interface MeshWizardParticipantPlan { + laneId: string; + participantId: string; + agent: "codex" | "claude"; + runtimeSessionId: string; + worktreePath?: string; + launchCommand: string; +} + +export interface MeshWizardPlan { + roomRequest: Extract; + laneRequests: Array>; + enterRequests: Array>; + launchCommands: string[]; + warnings: string[]; + participants: MeshWizardParticipantPlan[]; +} + +export interface MeshWizardExecutionResult { + ok: boolean; + messages: string[]; + launchCommands: string[]; + warnings: string[]; + error?: string; +} + +const READ_ONLY_PERMISSIONS = ["read"] as const; +const WRITABLE_PERMISSIONS = ["read", "write", "git"] as const; + +export function buildMeshWizardPlan(options: MeshWizardStartOptions): MeshWizardPlan { + const roomId = requireId(options.roomId, "roomId"); + const codexWorkers = requireNonNegativeInteger(options.codexWorkers, "codexWorkers"); + const claudeWorkers = requireNonNegativeInteger(options.claudeWorkers, "claudeWorkers"); + if (codexWorkers + claudeWorkers < 1) { + throw new Error("At least one Codex or Claude worker is required."); + } + const mode = options.mode ?? "read-only"; + if (mode !== "read-only" && mode !== "writable") { + throw new Error(`Invalid mesh wizard mode: ${mode}`); + } + if (mode === "writable" && !options.worktreePrefix) { + throw new Error("Writable mesh wizard mode requires --worktree-prefix ."); + } + + const commandBin = options.commandBin ?? "ctxrelay"; + const participants = [ + ...workerPlans("codex", codexWorkers, roomId, mode, options.worktreePrefix, commandBin), + ...workerPlans("claude", claudeWorkers, roomId, mode, options.worktreePrefix, commandBin), + ]; + const permissions = mode === "writable" ? [...WRITABLE_PERMISSIONS] : [...READ_ONLY_PERMISSIONS]; + const missingWorktrees = mode === "writable" + ? participants.filter((participant) => !participant.worktreePath || !existsSync(participant.worktreePath)) + : []; + if (missingWorktrees.length > 0) { + throw new Error(`Writable mesh lanes require existing worktrees: ${missingWorktrees.map((item) => `${item.laneId}:${item.worktreePath}`).join(", ")}`); + } + + return { + roomRequest: { + type: "create_room", + id: roomId, + coordinatorParticipantId: participants.find((participant) => participant.agent === "codex")?.participantId ?? "human", + }, + laneRequests: participants.map((participant) => ({ + type: "create_lane", + roomId, + laneId: participant.laneId, + participantId: participant.participantId, + permissions, + ...(participant.worktreePath ? { worktreePath: participant.worktreePath } : {}), + })), + enterRequests: participants.map((participant) => ({ + type: "enter_lane", + roomId, + laneId: participant.laneId, + participantId: participant.participantId, + runtimeSessionId: participant.runtimeSessionId, + source: participant.agent, + })), + launchCommands: participants.map((participant) => participant.launchCommand), + warnings: mode === "writable" ? [] : ["Read-only mesh lanes cannot write files until reassigned with writable permissions and worktrees."], + participants, + }; +} + +export async function executeMeshWizardPlan( + plan: MeshWizardPlan, + callLedgerTool: (request: LedgerToolRequest) => Promise, +): Promise { + const messages: string[] = []; + for (const request of [plan.roomRequest, ...plan.laneRequests, ...plan.enterRequests]) { + const result = await callLedgerTool(request); + if (!result.ok) { + if (isAlreadyExistsError(result.error)) { + messages.push(result.error); + continue; + } + return { + ok: false, + messages, + launchCommands: plan.launchCommands, + warnings: plan.warnings, + error: result.error, + }; + } + if (result.message) messages.push(result.message); + } + return { + ok: true, + messages, + launchCommands: plan.launchCommands, + warnings: plan.warnings, + }; +} + +function workerPlans( + agent: "codex" | "claude", + count: number, + roomId: string, + mode: MeshWizardMode, + worktreePrefix: string | undefined, + commandBin: string, +): MeshWizardParticipantPlan[] { + const plans: MeshWizardParticipantPlan[] = []; + for (let index = 1; index <= count; index++) { + const laneId = `${agent}-${index}`; + const runtimeSessionId = index === 1 ? "default" : `${agent}-${index}`; + const participantId = `${runtimeSessionId}:${agent}`; + const worktreePath = mode === "writable" ? deriveWorktreePath(worktreePrefix!, laneId) : undefined; + plans.push({ + laneId, + participantId, + agent, + runtimeSessionId, + ...(worktreePath ? { worktreePath } : {}), + launchCommand: launchCommand(agent, roomId, runtimeSessionId, commandBin, worktreePath), + }); + } + return plans; +} + +function launchCommand(agent: "codex" | "claude", roomId: string, runtimeSessionId: string, commandBin: string, worktreePath?: string): string { + const prompt = shellArg(meshStartupPrompt(agent, roomId, runtimeSessionId)); + const command = runtimeSessionId === "default" + ? `CONTEXTRELAY_ENABLE_MESH=1 ${commandBin} ${agent} ${prompt}` + : `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 CONTEXTRELAY_ENABLE_MESH=1 ${commandBin} ${agent} --session ${shellArg(runtimeSessionId)} ${prompt}`; + return worktreePath ? `cd ${shellArg(worktreePath)} && ${command}` : command; +} + +function meshStartupPrompt(agent: "codex" | "claude", roomId: string, runtimeSessionId: string): string { + const laneId = runtimeSessionId === "default" ? `${agent}-1` : runtimeSessionId; + const participantId = `${runtimeSessionId}:${agent}`; + // Keep the first token away from native Codex subcommand names; this prompt is + // forwarded as a positional TUI prompt after ContextRelay strips --session. + return [ + `You are ${participantId} in ContextRelay mesh room ${roomId}, lane ${laneId}, runtime session ${runtimeSessionId}.`, + "First action: use the ContextRelay append_note tool to write a short ready note with your participant id, lane id, and runtime session id.", + "Use ContextRelay tool calls for worker communication when available; if a tool is unavailable, use the documented [IMPORTANT] CONTEXTRELAY_* fallback marker.", + "After the ready note, stand by for coordinator instructions and do not start unrelated work.", + ].join(" "); +} + +function deriveWorktreePath(prefix: string, laneId: string): string { + if (prefix.endsWith("/") || prefix.endsWith("\\")) return join(prefix, laneId); + return `${prefix}${laneId}`; +} + +function requireId(value: string, label: string): string { + const trimmed = value.trim(); + if (!/^[a-z][a-z0-9_-]{1,63}$/.test(trimmed)) { + throw new Error(`Invalid ${label}: ${value}. Use 2-64 lowercase letters, numbers, dash, or underscore, starting with a letter.`); + } + return trimmed; +} + +function requireNonNegativeInteger(value: number, label: string): number { + if (!Number.isInteger(value) || value < 0 || value > 20) { + throw new Error(`${label} must be an integer from 0 to 20.`); + } + return value; +} + +function isAlreadyExistsError(error: string): boolean { + return /\balready exists\b/i.test(error); +} + +function shellArg(value: string): string { + return /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/src/session/migrations.ts b/src/session/migrations.ts new file mode 100644 index 0000000..d6a817f --- /dev/null +++ b/src/session/migrations.ts @@ -0,0 +1,31 @@ +export interface JsonMigration { + fromVersion: number; + toVersion: number; + apply: (value: Record) => Record; +} + +export function runJsonMigrations( + value: Record, + currentVersion: number, + migrations: JsonMigration[], + versionKey = "schemaVersion", +): Record { + let migrated = { ...value }; + let version = normalizeVersion(migrated[versionKey]); + const ordered = [...migrations].sort((left, right) => left.fromVersion - right.fromVersion); + while (version < currentVersion) { + const migration = ordered.find((item) => item.fromVersion === version); + if (!migration) break; + migrated = migration.apply(migrated); + version = migration.toVersion; + migrated[versionKey] = version; + } + return { + ...migrated, + [versionKey]: currentVersion, + }; +} + +function normalizeVersion(value: unknown): number { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1; +} diff --git a/src/session/registry.ts b/src/session/registry.ts index 63f8d40..1e20d0a 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -30,6 +30,7 @@ export interface RegisteredSession { createdAt: string; lastSeenAt: string; archivedAt?: string; + transcriptSessionId?: string; worktreePath?: string; participants: RegisteredSessionParticipant[]; codexRuntime?: RegisteredCodexRuntime; @@ -55,6 +56,7 @@ export interface SessionInspection { lastSeenAt: string; archivedAt?: string; archived: boolean; + transcriptSessionId?: string; worktreePath?: string; participants: RegisteredSessionParticipant[]; codexRuntime?: RegisteredCodexRuntime; @@ -88,13 +90,14 @@ export class SessionRegistry { private readonly projectRoot: string, private readonly instanceId: string, private readonly now = () => new Date(), + registryPath?: string, ) { - this.path = sessionRegistryPath(projectRoot); + this.path = registryPath ?? sessionRegistryPath(projectRoot); this.lockPath = join(dirname(this.path), ".sessions.lock"); } load(): SessionRegistryLoadResult { - return loadSessionRegistry(this.projectRoot, this.instanceId, this.now); + return loadSessionRegistryFromPath(this.path, this.instanceId, this.now); } withLockedRegistry(fn: (result: SessionRegistryLoadResult) => T): T { @@ -112,7 +115,7 @@ export class SessionRegistry { const now = this.now().toISOString(); const registry = ensureDefaultSession(result.registry, now); if (!isForeignRegistryIssue(result.issue)) { - saveSessionRegistry(this.projectRoot, registry); + saveSessionRegistryToPath(this.path, registry); } return registry; }); @@ -133,7 +136,7 @@ export class SessionRegistry { ...result.registry.sessions[sessionId], lastSeenAt: maxIsoTimestamp(result.registry.sessions[sessionId].lastSeenAt, now), }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return result.registry; }); } @@ -164,7 +167,7 @@ export class SessionRegistry { ...(options.worktreePath ? { worktreePath: canonicalExistingWorktreePath(options.worktreePath) } : {}), participants: DEFAULT_PARTICIPANTS, }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return result.registry; }); } @@ -196,7 +199,7 @@ export class SessionRegistry { worktreePath: nextWorktreePath, participants: DEFAULT_PARTICIPANTS, }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, created: true, @@ -215,7 +218,7 @@ export class SessionRegistry { worktreePath: nextWorktreePath, lastSeenAt: maxIsoTimestamp(existing.lastSeenAt, now), }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, created: false, @@ -233,7 +236,7 @@ export class SessionRegistry { } existing.lastSeenAt = maxIsoTimestamp(existing.lastSeenAt, now); - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, created: false, @@ -262,7 +265,7 @@ export class SessionRegistry { } result.registry.activeSessionId = sessionId; session.lastSeenAt = maxIsoTimestamp(session.lastSeenAt, this.now().toISOString()); - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return result.registry; }); } @@ -297,7 +300,7 @@ export class SessionRegistry { archivedAt: now, lastSeenAt: maxIsoTimestamp(session.lastSeenAt, now), }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, alreadyArchived: false }; }); } @@ -334,7 +337,7 @@ export class SessionRegistry { worktreePath: nextWorktreePath, lastSeenAt: maxIsoTimestamp(session.lastSeenAt, now), }; - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); return { registry: result.registry, alreadyBound: false }; }); } @@ -367,7 +370,34 @@ export class SessionRegistry { lastSeenAt: maxIsoTimestamp(session.codexRuntime?.lastSeenAt ?? now, now), }; session.lastSeenAt = maxIsoTimestamp(session.lastSeenAt, now); - saveSessionRegistry(this.projectRoot, result.registry); + saveSessionRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + ensureTranscriptSessionId(sessionId: string, transcriptSessionId: string): SessionRegistryData { + return this.withLock(() => { + const result = this.load(); + this.lastIssue = result.issue ?? null; + if (isForeignRegistryIssue(result.issue)) { + throw new Error(result.issue); + } + if (!isValidRuntimeSessionId(sessionId)) { + throw new Error(invalidSessionIdMessage(sessionId)); + } + const session = result.registry.sessions[sessionId]; + if (!session) { + throw new Error(`Unknown sessionId: ${sessionId}`); + } + if (session.lifecycle === "archived") { + throw new Error(`Cannot update archived session: ${sessionId}`); + } + if (!session.transcriptSessionId) { + const now = this.now().toISOString(); + session.transcriptSessionId = transcriptSessionId; + session.lastSeenAt = maxIsoTimestamp(session.lastSeenAt, now); + saveSessionRegistryToPath(this.path, result.registry); + } return result.registry; }); } @@ -384,12 +414,24 @@ export function sessionRegistryPath(projectRoot = process.cwd()): string { return join(projectRoot, ".contextrelay", SESSION_REGISTRY_FILE); } +export function sessionRegistryStatePath(stateDir: string): string { + return join(stateDir, SESSION_REGISTRY_FILE); +} + export function loadSessionRegistry( projectRoot: string, instanceId: string, now = () => new Date(), ): SessionRegistryLoadResult { const path = sessionRegistryPath(projectRoot); + return loadSessionRegistryFromPath(path, instanceId, now); +} + +export function loadSessionRegistryFromPath( + path: string, + instanceId: string, + now = () => new Date(), +): SessionRegistryLoadResult { try { const parsed = JSON.parse(readFileSync(path, "utf-8")); return normalizeSessionRegistry(parsed, instanceId, now().toISOString()); @@ -431,6 +473,7 @@ export function inspectSessionRegistryData( lastSeenAt: session.lastSeenAt, ...(session.archivedAt ? { archivedAt: session.archivedAt } : {}), archived: session.lifecycle === "archived", + ...(session.transcriptSessionId ? { transcriptSessionId: session.transcriptSessionId } : {}), ...(session.worktreePath ? { worktreePath: session.worktreePath } : {}), participants: session.participants, ...(session.codexRuntime ? { codexRuntime: session.codexRuntime } : {}), @@ -442,6 +485,10 @@ export function inspectSessionRegistryData( export function saveSessionRegistry(projectRoot: string, registry: SessionRegistryData): void { const path = sessionRegistryPath(projectRoot); + saveSessionRegistryToPath(path, registry); +} + +export function saveSessionRegistryToPath(path: string, registry: SessionRegistryData): void { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); atomicWriteFile(path, JSON.stringify(registry, null, 2) + "\n", { mode: 0o600 }); } @@ -505,6 +552,9 @@ function normalizeSession(id: string, value: unknown, nowIso: string): Registere const lifecycle = normalizeLifecycle(record.lifecycle); const archivedAt = lifecycle === "archived" ? validOptionalIso(record.archivedAt) : null; const worktreePath = normalizeStoredWorktreePath(record.worktreePath); + const transcriptSessionId = typeof record.transcriptSessionId === "string" && record.transcriptSessionId + ? record.transcriptSessionId + : undefined; return { id, label: boundedString(record.label, id === DEFAULT_RUNTIME_SESSION_ID ? "Default" : id), @@ -512,6 +562,7 @@ function normalizeSession(id: string, value: unknown, nowIso: string): Registere createdAt, lastSeenAt: maxIsoTimestamp(createdAt, validIsoOr(record.lastSeenAt, createdAt)), ...(archivedAt ? { archivedAt } : {}), + ...(transcriptSessionId ? { transcriptSessionId } : {}), ...(worktreePath ? { worktreePath } : {}), participants: normalizeParticipants(record.participants), ...(codexRuntime ? { codexRuntime } : {}), diff --git a/src/session/rooms.ts b/src/session/rooms.ts new file mode 100644 index 0000000..e5236f2 --- /dev/null +++ b/src/session/rooms.ts @@ -0,0 +1,871 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../atomic-write"; +import type { PermissionCapability } from "../agents"; +import { runJsonMigrations, type JsonMigration } from "./migrations"; +import { canonicalExistingWorktreePath, normalizeStoredWorktreePath } from "./worktree"; + +export const ROOM_REGISTRY_VERSION = 2; +export const ROOM_REGISTRY_FILE = "rooms.json"; + +export type RoomLifecycle = "open" | "paused" | "archived"; +export type LaneLifecycle = "open" | "complete" | "archived"; + +export interface LanePermissionPolicy { + allowed: PermissionCapability[]; +} + +export type ApprovalDecision = "approved" | "denied" | "expired"; + +export interface PendingApprovalRecord { + approvalId: string; + requestedAt: string; + requesterParticipantId: string; + reviewerParticipantId: string; + action: string; + runtimeSessionId: string; + idempotencyKey: string; + expiresAt: string; + reason?: string; + provenance?: Record; +} + +export interface ExpiredApprovalRecord { + roomId: string; + laneId: string; + approval: PendingApprovalRecord; +} + +export interface RegisteredLane { + id: string; + label: string; + lifecycle: LaneLifecycle; + createdAt: string; + lastSeenAt: string; + completedAt?: string; + archivedAt?: string; + ownerParticipantId?: string; + worktreePath?: string; + permissions: LanePermissionPolicy; + pendingApprovals: Record; +} + +export interface RegisteredRoom { + id: string; + label: string; + lifecycle: RoomLifecycle; + createdAt: string; + lastSeenAt: string; + archivedAt?: string; + pausedAt?: string; + pauseReason?: string; + coordinatorParticipantId: string; + reviewerParticipantId?: string; + members: string[]; + pinnedContext?: string; + activeLaneId?: string; + lanes: Record; +} + +export interface ActiveLaneScope { + roomId: string; + laneId: string; + enteredAt: string; +} + +export interface RoomRegistryData { + schemaVersion: typeof ROOM_REGISTRY_VERSION; + instanceId: string; + activeRoomId?: string; + activeLaneByParticipant: Record; + rooms: Record; +} + +export interface RoomRegistryLoadResult { + registry: RoomRegistryData; + issue?: string; +} + +export interface RoomRegistryInspection { + rooms: RegisteredRoom[]; + activeRoomId?: string; + activeLaneByParticipant: Record; + warning?: string; +} + +export interface RouteDecision { + traceId: string; + from: { roomId?: string; laneId?: string; participantId?: string }; + to: { roomId?: string; laneId?: string; participantId?: string }; + decision: "deliver" | "reject" | "queue"; + reason: string; + timestamp: number; +} + +export interface LaneWorkerState { + roomId: string; + laneId: string; + state: "queued" | "active" | "blocked" | "waiting_approval" | "stale" | "failed" | "complete"; + why: string; + lastActivityAt?: string; + lastApprovalId?: string; + lastError?: string; + ownerParticipantId?: string; + runtimeSessionId?: string; +} + +const ROOM_ID_RE = /^[a-z][a-z0-9_-]{1,63}$/; +const DEFAULT_READ_ONLY_PERMISSIONS: PermissionCapability[] = ["read"]; +const DEFAULT_WRITE_PERMISSIONS: PermissionCapability[] = ["read", "write", "git"]; +const KNOWN_PERMISSIONS = new Set([ + "read", + "write", + "shell", + "network", + "git", + "secrets", + "browser", + "external_api", +]); + +const ROOM_MIGRATIONS: JsonMigration[] = [ + { + fromVersion: 1, + toVersion: 2, + apply: (value) => value, + }, +]; + +export class RoomRegistry { + readonly path: string; + private readonly lockPath: string; + lastIssue: string | null = null; + + constructor( + private readonly projectRoot: string, + private readonly instanceId: string, + private readonly now = () => new Date(), + registryPath?: string, + ) { + this.path = registryPath ?? roomRegistryPath(projectRoot); + this.lockPath = join(dirname(this.path), ".rooms.lock"); + } + + load(): RoomRegistryLoadResult { + return loadRoomRegistryFromPath(this.path, this.instanceId, this.now); + } + + inspect(): RoomRegistryInspection { + const result = this.load(); + this.lastIssue = result.issue ?? null; + return inspectRoomRegistryData(result.registry, result.issue); + } + + createRoom(id: string, options: { label?: string; coordinatorParticipantId?: string; reviewerParticipantId?: string; pinnedContext?: string } = {}): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + if (!isValidRoomId(id)) throw new Error(invalidRoomIdMessage(id)); + if (result.registry.rooms[id]) throw new Error(`Room ${id} already exists.`); + const now = this.now().toISOString(); + const coordinator = boundedString(options.coordinatorParticipantId, "human"); + const reviewer = boundedOptionalString(options.reviewerParticipantId); + result.registry.rooms[id] = { + id, + label: boundedString(options.label, id), + lifecycle: "open", + createdAt: now, + lastSeenAt: now, + coordinatorParticipantId: coordinator, + ...(reviewer ? { reviewerParticipantId: reviewer } : {}), + members: [...new Set([coordinator, ...(reviewer ? [reviewer] : [])].filter((value) => value !== "human"))], + ...(boundedOptionalString(options.pinnedContext) ? { pinnedContext: boundedOptionalString(options.pinnedContext) } : {}), + lanes: {}, + }; + result.registry.activeRoomId = id; + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + selectRoom(roomId: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireRoom(result.registry, roomId); + if (room.lifecycle === "archived") throw new Error(`Cannot select archived room: ${roomId}`); + result.registry.activeRoomId = roomId; + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + archiveRoom(roomId: string): { registry: RoomRegistryData; alreadyArchived: boolean } { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireRoom(result.registry, roomId); + if (room.lifecycle === "archived") return { registry: result.registry, alreadyArchived: true }; + const now = this.now().toISOString(); + room.lifecycle = "archived"; + room.archivedAt = now; + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, now); + if (result.registry.activeRoomId === roomId) delete result.registry.activeRoomId; + for (const [participantId, scope] of Object.entries(result.registry.activeLaneByParticipant)) { + if (scope.roomId === roomId) delete result.registry.activeLaneByParticipant[participantId]; + } + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, alreadyArchived: false }; + }); + } + + addMember(roomId: string, participantId: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const participant = requiredParticipantId(participantId); + room.members = [...new Set([...room.members, participant])].sort(); + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + removeMember(roomId: string, participantId: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const participant = requiredParticipantId(participantId); + if (room.coordinatorParticipantId === participant) { + throw new Error(`Cannot remove coordinator ${participant} from room ${roomId}. Assign another coordinator first.`); + } + if (room.reviewerParticipantId === participant) { + delete room.reviewerParticipantId; + } + room.members = room.members.filter((member) => member !== participant); + for (const lane of Object.values(room.lanes)) { + if (lane.ownerParticipantId === participant) delete lane.ownerParticipantId; + } + const scope = result.registry.activeLaneByParticipant[participant]; + if (scope?.roomId === roomId) delete result.registry.activeLaneByParticipant[participant]; + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + assignCoordinator(roomId: string, participantId: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const participant = requiredParticipantId(participantId); + room.coordinatorParticipantId = participant; + if (participant !== "human") room.members = [...new Set([...room.members, participant])].sort(); + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + requestApproval(roomId: string, laneId: string, options: { + requesterParticipantId: string; + reviewerParticipantId?: string; + action: string; + runtimeSessionId?: string; + idempotencyKey?: string; + expiresAt?: string; + ttlMs?: number; + reason?: string; + provenance?: Record; + }): { registry: RoomRegistryData; approval: PendingApprovalRecord; replayed: boolean } { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const requester = requiredParticipantId(options.requesterParticipantId); + const reviewer = requiredParticipantId(options.reviewerParticipantId ?? room.reviewerParticipantId ?? room.coordinatorParticipantId); + const action = boundedString(options.action, "unknown"); + const runtimeSessionId = boundedString(options.runtimeSessionId, "default"); + const idempotencyKey = boundedString(options.idempotencyKey, randomUUID(), 240); + const existing = Object.values(lane.pendingApprovals).find((approval) => approval.idempotencyKey === idempotencyKey); + if (existing) return { registry: result.registry, approval: existing, replayed: true }; + + const now = this.now(); + const requestedAt = now.toISOString(); + const expiresAt = options.expiresAt + ? validIsoOr(options.expiresAt, new Date(now.getTime() + (options.ttlMs ?? 15 * 60_000)).toISOString()) + : new Date(now.getTime() + (options.ttlMs ?? 15 * 60_000)).toISOString(); + const approval: PendingApprovalRecord = { + approvalId: randomUUID(), + requestedAt, + requesterParticipantId: requester, + reviewerParticipantId: reviewer, + action, + runtimeSessionId, + idempotencyKey, + expiresAt, + ...(boundedOptionalString(options.reason, 2_000) ? { reason: boundedOptionalString(options.reason, 2_000) } : {}), + ...(options.provenance ? { provenance: options.provenance } : {}), + }; + lane.pendingApprovals[approval.approvalId] = approval; + lane.lastSeenAt = maxIsoTimestamp(lane.lastSeenAt, requestedAt); + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, requestedAt); + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, approval, replayed: false }; + }); + } + + decideApproval(roomId: string, laneId: string, approvalId: string, options: { + reviewerParticipantId: string; + decision: Exclude; + }): { registry: RoomRegistryData; approval: PendingApprovalRecord } { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const approval = lane.pendingApprovals[approvalId]; + if (!approval) throw new Error(`Unknown pending approval ${approvalId} in lane ${laneId}.`); + const reviewer = requiredParticipantId(options.reviewerParticipantId); + if (approval.reviewerParticipantId !== reviewer) { + throw new Error(`Approval ${approvalId} is assigned to ${approval.reviewerParticipantId}, not ${reviewer}.`); + } + delete lane.pendingApprovals[approvalId]; + const now = this.now().toISOString(); + lane.lastSeenAt = maxIsoTimestamp(lane.lastSeenAt, now); + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, approval }; + }); + } + + expireApprovals(): { registry: RoomRegistryData; expired: ExpiredApprovalRecord[] } { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const now = this.now(); + const nowIso = now.toISOString(); + const expired: ExpiredApprovalRecord[] = []; + for (const room of Object.values(result.registry.rooms)) { + if (room.lifecycle === "archived") continue; + for (const lane of Object.values(room.lanes)) { + for (const approval of Object.values(lane.pendingApprovals)) { + const expiresAt = Date.parse(approval.expiresAt); + if (!Number.isFinite(expiresAt) || expiresAt > now.getTime()) continue; + expired.push({ roomId: room.id, laneId: lane.id, approval }); + delete lane.pendingApprovals[approval.approvalId]; + lane.lastSeenAt = maxIsoTimestamp(lane.lastSeenAt, nowIso); + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, nowIso); + } + } + } + if (expired.length > 0) saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, expired }; + }); + } + + createLane(roomId: string, laneId: string, options: { label?: string; participantId?: string; worktreePath?: string; permissions?: PermissionCapability[] } = {}): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + if (!isValidRoomId(laneId)) throw new Error(invalidRoomIdMessage(laneId)); + if (room.lanes[laneId]) throw new Error(`Lane ${laneId} already exists in room ${roomId}.`); + const worktreePath = options.worktreePath ? canonicalExistingWorktreePath(options.worktreePath) : undefined; + const permissions = normalizeLanePermissions(options.permissions, worktreePath); + if (permissions.includes("write") && !worktreePath) { + throw new Error(`Lane ${laneId} is write-capable and requires --worktree .`); + } + if (worktreePath && permissions.includes("write")) { + assertNoSharedWritableWorktree(result.registry, roomId, laneId, worktreePath); + } + const now = this.now().toISOString(); + const owner = boundedOptionalString(options.participantId); + room.lanes[laneId] = { + id: laneId, + label: boundedString(options.label, laneId), + lifecycle: "open", + createdAt: now, + lastSeenAt: now, + ...(owner ? { ownerParticipantId: owner } : {}), + ...(worktreePath ? { worktreePath } : {}), + permissions: { allowed: permissions }, + pendingApprovals: {}, + }; + if (owner) room.members = [...new Set([...room.members, owner])].sort(); + room.activeLaneId = laneId; + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + enterLane(roomId: string, laneId: string, participantId: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const participant = requiredParticipantId(participantId); + if (participant !== "human" && !room.members.includes(participant)) { + throw new Error(`Participant ${participant} is not a member of room ${roomId}.`); + } + const now = this.now().toISOString(); + result.registry.activeRoomId = roomId; + result.registry.activeLaneByParticipant[participant] = { roomId, laneId, enteredAt: now }; + room.activeLaneId = laneId; + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, now); + lane.lastSeenAt = maxIsoTimestamp(lane.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + assignParticipantToLane(roomId: string, laneId: string, participantId: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const participant = requiredParticipantId(participantId); + room.members = [...new Set([...room.members, participant])].sort(); + lane.ownerParticipantId = participant; + lane.lastSeenAt = maxIsoTimestamp(lane.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + removeParticipantFromLane(roomId: string, laneId: string, participantId: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const participant = requiredParticipantId(participantId); + if (lane.ownerParticipantId === participant) delete lane.ownerParticipantId; + const scope = result.registry.activeLaneByParticipant[participant]; + if (scope?.roomId === roomId && scope.laneId === laneId) delete result.registry.activeLaneByParticipant[participant]; + lane.lastSeenAt = maxIsoTimestamp(lane.lastSeenAt, this.now().toISOString()); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + completeLane(roomId: string, laneId: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireOpenLane(room, laneId); + const now = this.now().toISOString(); + lane.lifecycle = "complete"; + lane.completedAt = now; + lane.lastSeenAt = maxIsoTimestamp(lane.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + archiveLane(roomId: string, laneId: string): { registry: RoomRegistryData; alreadyArchived: boolean } { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireOpenRoom(result.registry, roomId); + const lane = requireLane(room, laneId); + if (lane.lifecycle === "archived") return { registry: result.registry, alreadyArchived: true }; + const now = this.now().toISOString(); + lane.lifecycle = "archived"; + lane.archivedAt = now; + lane.lastSeenAt = maxIsoTimestamp(lane.lastSeenAt, now); + if (room.activeLaneId === laneId) delete room.activeLaneId; + for (const [participantId, scope] of Object.entries(result.registry.activeLaneByParticipant)) { + if (scope.roomId === roomId && scope.laneId === laneId) delete result.registry.activeLaneByParticipant[participantId]; + } + saveRoomRegistryToPath(this.path, result.registry); + return { registry: result.registry, alreadyArchived: false }; + }); + } + + pauseRoom(roomId: string, reason: string): RoomRegistryData { + return this.withLockedRegistry((result) => { + assertWritableRegistry(result); + const room = requireRoom(result.registry, roomId); + if (room.lifecycle === "archived") throw new Error(`Cannot pause archived room: ${roomId}`); + const now = this.now().toISOString(); + room.lifecycle = "paused"; + room.pausedAt = now; + room.pauseReason = reason; + room.lastSeenAt = maxIsoTimestamp(room.lastSeenAt, now); + saveRoomRegistryToPath(this.path, result.registry); + return result.registry; + }); + } + + private withLockedRegistry(fn: (result: RoomRegistryLoadResult) => T): T { + return withRoomRegistryLock(this.lockPath, () => { + const result = this.load(); + this.lastIssue = result.issue ?? null; + return fn(result); + }); + } +} + +export function roomRegistryPath(projectRoot = process.cwd()): string { + return join(projectRoot, ".contextrelay", ROOM_REGISTRY_FILE); +} + +export function roomRegistryStatePath(stateDir: string): string { + return join(stateDir, ROOM_REGISTRY_FILE); +} + +export function loadRoomRegistry( + projectRoot: string, + instanceId: string, + now = () => new Date(), +): RoomRegistryLoadResult { + const path = roomRegistryPath(projectRoot); + return loadRoomRegistryFromPath(path, instanceId, now); +} + +export function loadRoomRegistryFromPath( + path: string, + instanceId: string, + now = () => new Date(), +): RoomRegistryLoadResult { + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")); + return normalizeRoomRegistry(parsed, instanceId, now().toISOString()); + } catch (err: any) { + if (err?.code === "ENOENT") { + return { registry: createDefaultRoomRegistry(instanceId) }; + } + return { + registry: createDefaultRoomRegistry(instanceId), + issue: `Room registry at ${path} could not be read; recreated empty registry: ${err?.message ?? String(err)}`, + }; + } +} + +export function saveRoomRegistry(projectRoot: string, registry: RoomRegistryData): void { + const path = roomRegistryPath(projectRoot); + saveRoomRegistryToPath(path, registry); +} + +export function saveRoomRegistryToPath(path: string, registry: RoomRegistryData): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + atomicWriteFile(path, JSON.stringify(registry, null, 2) + "\n", { mode: 0o600 }); +} + +export function inspectRoomRegistryData(registry: RoomRegistryData, warning?: string): RoomRegistryInspection { + return { + rooms: Object.values(registry.rooms).sort((left, right) => left.id.localeCompare(right.id)), + ...(registry.activeRoomId ? { activeRoomId: registry.activeRoomId } : {}), + activeLaneByParticipant: registry.activeLaneByParticipant, + warning, + }; +} + +export function normalizeRoomRegistry(value: unknown, expectedInstanceId: string, nowIso: string): RoomRegistryLoadResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { registry: createDefaultRoomRegistry(expectedInstanceId), issue: "Room registry is not an object; recreated empty registry." }; + } + const record = runJsonMigrations(value as Record, ROOM_REGISTRY_VERSION, ROOM_MIGRATIONS); + const fileInstanceId = typeof record.instanceId === "string" ? record.instanceId : null; + if (fileInstanceId && fileInstanceId !== expectedInstanceId) { + return { + registry: createDefaultRoomRegistry(expectedInstanceId), + issue: `Room registry belongs to instance ${fileInstanceId}; expected ${expectedInstanceId}.`, + }; + } + const rooms = normalizeRooms(record.rooms, nowIso); + const activeRoomId = typeof record.activeRoomId === "string" && rooms[record.activeRoomId] + ? record.activeRoomId + : undefined; + return { + registry: { + schemaVersion: ROOM_REGISTRY_VERSION, + instanceId: expectedInstanceId, + ...(activeRoomId ? { activeRoomId } : {}), + activeLaneByParticipant: normalizeActiveLaneByParticipant(record.activeLaneByParticipant, rooms), + rooms, + }, + issue: fileInstanceId ? undefined : "Room registry missing instanceId; repaired registry.", + }; +} + +function createDefaultRoomRegistry(instanceId: string): RoomRegistryData { + return { + schemaVersion: ROOM_REGISTRY_VERSION, + instanceId, + activeLaneByParticipant: {}, + rooms: {}, + }; +} + +function normalizeRooms(value: unknown, nowIso: string): Record { + const rooms: Record = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return rooms; + for (const [id, raw] of Object.entries(value as Record)) { + const normalized = normalizeRoom(id, raw, nowIso); + if (normalized) rooms[id] = normalized; + } + return rooms; +} + +function normalizeRoom(id: string, value: unknown, nowIso: string): RegisteredRoom | null { + if (!isValidRoomId(id) || !value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const createdAt = validIsoOr(record.createdAt, nowIso); + const lanes = normalizeLanes(record.lanes, createdAt); + const activeLaneId = typeof record.activeLaneId === "string" && lanes[record.activeLaneId] ? record.activeLaneId : undefined; + return { + id, + label: boundedString(record.label, id), + lifecycle: normalizeRoomLifecycle(record.lifecycle), + createdAt, + lastSeenAt: maxIsoTimestamp(createdAt, validIsoOr(record.lastSeenAt, createdAt)), + ...(validOptionalIso(record.archivedAt) ? { archivedAt: validOptionalIso(record.archivedAt)! } : {}), + ...(validOptionalIso(record.pausedAt) ? { pausedAt: validOptionalIso(record.pausedAt)! } : {}), + ...(boundedOptionalString(record.pauseReason) ? { pauseReason: boundedOptionalString(record.pauseReason) } : {}), + coordinatorParticipantId: boundedString(record.coordinatorParticipantId, "human"), + ...(boundedOptionalString(record.reviewerParticipantId) ? { reviewerParticipantId: boundedOptionalString(record.reviewerParticipantId) } : {}), + members: normalizeStringList(record.members), + ...(boundedOptionalString(record.pinnedContext, 8_000) ? { pinnedContext: boundedOptionalString(record.pinnedContext, 8_000) } : {}), + ...(activeLaneId ? { activeLaneId } : {}), + lanes, + }; +} + +function normalizeLanes(value: unknown, fallbackIso: string): Record { + const lanes: Record = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return lanes; + for (const [id, raw] of Object.entries(value as Record)) { + const normalized = normalizeLane(id, raw, fallbackIso); + if (normalized) lanes[id] = normalized; + } + return lanes; +} + +function normalizeLane(id: string, value: unknown, fallbackIso: string): RegisteredLane | null { + if (!isValidRoomId(id) || !value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const createdAt = validIsoOr(record.createdAt, fallbackIso); + const worktreePath = normalizeStoredWorktreePath(record.worktreePath); + return { + id, + label: boundedString(record.label, id), + lifecycle: normalizeLaneLifecycle(record.lifecycle), + createdAt, + lastSeenAt: maxIsoTimestamp(createdAt, validIsoOr(record.lastSeenAt, createdAt)), + ...(validOptionalIso(record.completedAt) ? { completedAt: validOptionalIso(record.completedAt)! } : {}), + ...(validOptionalIso(record.archivedAt) ? { archivedAt: validOptionalIso(record.archivedAt)! } : {}), + ...(boundedOptionalString(record.ownerParticipantId) ? { ownerParticipantId: boundedOptionalString(record.ownerParticipantId) } : {}), + ...(worktreePath ? { worktreePath } : {}), + permissions: { allowed: normalizePermissionList((record.permissions as Record | undefined)?.allowed, worktreePath) }, + pendingApprovals: normalizePendingApprovals(record.pendingApprovals), + }; +} + +function normalizePendingApprovals(value: unknown): Record { + const approvals: Record = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return approvals; + for (const [id, raw] of Object.entries(value as Record)) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const record = raw as Record; + const approvalId = typeof record.approvalId === "string" && record.approvalId ? record.approvalId : id; + const requestedAt = validOptionalIso(record.requestedAt); + const expiresAt = validOptionalIso(record.expiresAt); + if (!requestedAt || !expiresAt) continue; + const requesterParticipantId = boundedOptionalString(record.requesterParticipantId); + const reviewerParticipantId = boundedOptionalString(record.reviewerParticipantId); + const action = boundedOptionalString(record.action); + const runtimeSessionId = boundedOptionalString(record.runtimeSessionId); + const idempotencyKey = boundedOptionalString(record.idempotencyKey, 240); + if (!requesterParticipantId || !reviewerParticipantId || !action || !runtimeSessionId || !idempotencyKey) continue; + approvals[approvalId] = { + approvalId, + requestedAt, + requesterParticipantId, + reviewerParticipantId, + action, + runtimeSessionId, + idempotencyKey, + expiresAt, + ...(boundedOptionalString(record.reason, 2_000) ? { reason: boundedOptionalString(record.reason, 2_000) } : {}), + ...(record.provenance && typeof record.provenance === "object" && !Array.isArray(record.provenance) ? { provenance: record.provenance as Record } : {}), + }; + } + return approvals; +} + +function normalizeActiveLaneByParticipant(value: unknown, rooms: Record): Record { + const result: Record = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return result; + for (const [participantId, raw] of Object.entries(value as Record)) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const record = raw as Record; + const roomId = typeof record.roomId === "string" ? record.roomId : ""; + const laneId = typeof record.laneId === "string" ? record.laneId : ""; + const room = rooms[roomId]; + if (!room?.lanes[laneId]) continue; + result[participantId] = { + roomId, + laneId, + enteredAt: validIsoOr(record.enteredAt, new Date(0).toISOString()), + }; + } + return result; +} + +export function isValidRoomId(value: string): boolean { + return ROOM_ID_RE.test(value); +} + +export function participantIdFor(source: string, runtimeSessionId?: string): string { + return `${runtimeSessionId || "default"}:${source}`; +} + +export function runtimeSessionHasOpenReadOnlyLane(registry: RoomRegistryData, runtimeSessionId: string): boolean { + const participantPrefix = `${runtimeSessionId}:`; + for (const [participantId, scope] of Object.entries(registry.activeLaneByParticipant)) { + if (!participantId.startsWith(participantPrefix)) continue; + const room = registry.rooms[scope.roomId]; + const lane = room?.lanes[scope.laneId]; + if (!room || room.lifecycle !== "open" || !lane || lane.lifecycle !== "open") continue; + if (!lane.permissions.allowed.includes("write")) return true; + } + return false; +} + +function requireRoom(registry: RoomRegistryData, roomId: string): RegisteredRoom { + if (!isValidRoomId(roomId)) throw new Error(invalidRoomIdMessage(roomId)); + const room = registry.rooms[roomId]; + if (!room) throw new Error(`Unknown room: ${roomId}`); + return room; +} + +function requireOpenRoom(registry: RoomRegistryData, roomId: string): RegisteredRoom { + const room = requireRoom(registry, roomId); + if (room.lifecycle === "archived") throw new Error(`Room ${roomId} is archived.`); + if (room.lifecycle === "paused") throw new Error(`Room ${roomId} is paused${room.pauseReason ? `: ${room.pauseReason}` : ""}.`); + return room; +} + +function requireLane(room: RegisteredRoom, laneId: string): RegisteredLane { + if (!isValidRoomId(laneId)) throw new Error(invalidRoomIdMessage(laneId)); + const lane = room.lanes[laneId]; + if (!lane) throw new Error(`Unknown lane ${laneId} in room ${room.id}.`); + return lane; +} + +function requireOpenLane(room: RegisteredRoom, laneId: string): RegisteredLane { + const lane = requireLane(room, laneId); + if (lane.lifecycle === "archived") throw new Error(`Lane ${laneId} is archived.`); + if (lane.lifecycle === "complete") throw new Error(`Lane ${laneId} is complete.`); + return lane; +} + +function assertWritableRegistry(result: RoomRegistryLoadResult): void { + if (isForeignRoomRegistryIssue(result.issue)) throw new Error(result.issue); +} + +function assertNoSharedWritableWorktree(registry: RoomRegistryData, roomId: string, laneId: string, worktreePath: string): void { + const normalized = normalizeStoredWorktreePath(worktreePath); + if (!normalized) return; + for (const room of Object.values(registry.rooms)) { + if (room.lifecycle === "archived") continue; + for (const lane of Object.values(room.lanes)) { + if (room.id === roomId && lane.id === laneId) continue; + if (lane.lifecycle === "archived") continue; + if (!lane.permissions.allowed.includes("write")) continue; + if (normalizeStoredWorktreePath(lane.worktreePath) === normalized) { + throw new Error(`Lane ${lane.id} in room ${room.id} already owns writable worktree ${normalized}.`); + } + } + } +} + +function normalizeLanePermissions(value: unknown, worktreePath?: string): PermissionCapability[] { + if (Array.isArray(value)) return normalizePermissionList(value, worktreePath); + return worktreePath ? [...DEFAULT_WRITE_PERMISSIONS] : [...DEFAULT_READ_ONLY_PERMISSIONS]; +} + +function normalizePermissionList(value: unknown, worktreePath?: string): PermissionCapability[] { + if (!Array.isArray(value)) return worktreePath ? [...DEFAULT_WRITE_PERMISSIONS] : [...DEFAULT_READ_ONLY_PERMISSIONS]; + const normalized = value.filter((item): item is PermissionCapability => typeof item === "string" && KNOWN_PERMISSIONS.has(item as PermissionCapability)); + const unique = [...new Set(normalized)]; + return unique.length > 0 ? unique : [...DEFAULT_READ_ONLY_PERMISSIONS]; +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()))].sort(); +} + +function normalizeRoomLifecycle(value: unknown): RoomLifecycle { + if (value === "paused" || value === "archived") return value; + return "open"; +} + +function normalizeLaneLifecycle(value: unknown): LaneLifecycle { + if (value === "complete" || value === "archived") return value; + return "open"; +} + +function requiredParticipantId(value: string): string { + const normalized = boundedOptionalString(value); + if (!normalized) throw new Error("participantId must be a non-empty string."); + return normalized; +} + +function boundedString(value: unknown, fallback: string, max = 120): string { + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= max ? trimmed : fallback; +} + +function boundedOptionalString(value: unknown, max = 120): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= max ? trimmed : undefined; +} + +function validIsoOr(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const millis = Date.parse(value); + return Number.isFinite(millis) ? new Date(millis).toISOString() : fallback; +} + +function validOptionalIso(value: unknown): string | null { + if (typeof value !== "string") return null; + const millis = Date.parse(value); + return Number.isFinite(millis) ? new Date(millis).toISOString() : null; +} + +function maxIsoTimestamp(left: string, right: string): string { + return Date.parse(right) > Date.parse(left) ? right : left; +} + +function invalidRoomIdMessage(value: string): string { + return `Invalid room or lane id: ${value}. Use 2-64 lowercase letters, numbers, dash, or underscore, starting with a letter.`; +} + +function withRoomRegistryLock(lockPath: string, fn: () => T): T { + const started = Date.now(); + const timeoutMs = 5_000; + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + while (true) { + try { + mkdirSync(lockPath, { mode: 0o700 }); + break; + } catch (err: any) { + if (err?.code !== "EEXIST") throw err; + if (Date.now() - started > timeoutMs) { + throw new Error(`Timed out waiting for ContextRelay room registry lock: ${lockPath}`); + } + Bun.sleepSync(25); + } + } + + try { + return fn(); + } finally { + rmSync(lockPath, { recursive: true, force: true }); + } +} + +export function isForeignRoomRegistryIssue(issue: string | undefined): boolean { + return issue?.startsWith("Room registry belongs to instance ") === true; +} diff --git a/src/session/terminal-launcher.ts b/src/session/terminal-launcher.ts new file mode 100644 index 0000000..42d4e56 --- /dev/null +++ b/src/session/terminal-launcher.ts @@ -0,0 +1,175 @@ +import { spawnSync as defaultSpawnSync } from "node:child_process"; +import type { MeshLaunchBackend } from "../config-service"; + +type SpawnSyncLike = ( + command: string, + args: string[], + options: { stdio: "ignore" }, +) => { error?: Error; status: number | null; signal: NodeJS.Signals | null }; + +export interface TerminalLaunchResult { + ok: boolean; + launched: number; + skipped?: string; + error?: string; + sessionName?: string; + attachCommand?: string; + alreadyRunning?: boolean; +} + +export interface TerminalLaunchOptions { + cwd?: string; + platform?: NodeJS.Platform; + spawnSyncFn?: SpawnSyncLike; + backend?: MeshLaunchBackend; + roomId?: string; +} + +export function launchWorkerTerminals(commands: string[], options: TerminalLaunchOptions = {}): TerminalLaunchResult { + const filtered = commands.map((command) => command.trim()).filter(Boolean); + if (filtered.length === 0) return { ok: true, launched: 0 }; + + const backend = options.backend ?? "tmux"; + if (backend === "print-only") { + return { ok: false, launched: 0, skipped: "print-only backend selected. Run the printed commands manually." }; + } + if (backend === "tmux") { + return launchTmuxWorkers(filtered, options); + } + + const platform = options.platform ?? process.platform; + if (platform !== "darwin") { + return { + ok: false, + launched: 0, + skipped: `Automatic terminal launch is currently supported on macOS only. Run the printed commands manually.`, + }; + } + + let launched = 0; + const errors: string[] = []; + const spawnSync: SpawnSyncLike = options.spawnSyncFn ?? defaultSpawnSync; + for (const command of filtered) { + const script = [ + `cd ${shellArg(options.cwd ?? process.cwd())}`, + command, + ].join(" && "); + const result = spawnSync("osascript", [ + "-e", + `tell application "Terminal" to do script ${appleScriptString(script)}`, + "-e", + `tell application "Terminal" to activate`, + ], { + stdio: "ignore", + }); + if (result.error) { + errors.push(result.error.message); + continue; + } + if (typeof result.status === "number" && result.status !== 0) { + errors.push(`osascript exited with code ${result.status}`); + continue; + } + if (result.signal) { + errors.push(`osascript exited with signal ${result.signal}`); + continue; + } + launched++; + } + + if (errors.length > 0) { + return { ok: false, launched, error: errors.join("; ") }; + } + return { ok: true, launched }; +} + +export function tmuxSessionName(roomId: string): string { + return `ctxrelay-mesh-${roomId}`; +} + +export function tmuxAttachCommand(roomId: string): string { + return `tmux attach -t ${tmuxSessionName(roomId)}`; +} + +export function detectTmux(spawnSyncFn: SpawnSyncLike = defaultSpawnSync): { available: boolean; detail: string } { + const result = spawnSyncFn("tmux", ["-V"], { stdio: "ignore" }); + if (result.error) return { available: false, detail: result.error.message }; + if (typeof result.status === "number" && result.status !== 0) return { available: false, detail: `tmux -V exited with code ${result.status}` }; + if (result.signal) return { available: false, detail: `tmux -V exited with signal ${result.signal}` }; + return { available: true, detail: "tmux available" }; +} + +export function tmuxSessionIsContextRelayOwned(roomId: string, spawnSyncFn: SpawnSyncLike = defaultSpawnSync): boolean { + const sessionName = tmuxSessionName(roomId); + const result = spawnSyncFn("tmux", ["show-environment", "-t", sessionName, "CONTEXTRELAY_TMUX_ROOM"], { stdio: "ignore" }); + return !result.error && result.status === 0; +} + +export function tmuxSessionExists(roomId: string, spawnSyncFn: SpawnSyncLike = defaultSpawnSync): boolean { + const sessionName = tmuxSessionName(roomId); + const result = spawnSyncFn("tmux", ["has-session", "-t", sessionName], { stdio: "ignore" }); + return !result.error && result.status === 0; +} + +function launchTmuxWorkers(commands: string[], options: TerminalLaunchOptions): TerminalLaunchResult { + const spawnSync: SpawnSyncLike = options.spawnSyncFn ?? defaultSpawnSync; + const roomId = options.roomId ?? "mesh"; + const sessionName = tmuxSessionName(roomId); + const attachCommand = tmuxAttachCommand(roomId); + const detected = detectTmux(spawnSync); + if (!detected.available) { + return { ok: false, launched: 0, skipped: `tmux unavailable: ${detected.detail}` }; + } + + if (tmuxSessionExists(roomId, spawnSync)) { + if (tmuxSessionIsContextRelayOwned(roomId, spawnSync)) { + return { + ok: true, + launched: 0, + alreadyRunning: true, + sessionName, + attachCommand, + skipped: `tmux session ${sessionName} is already running.`, + }; + } + return { + ok: false, + launched: 0, + sessionName, + attachCommand, + error: `Refusing to use existing tmux session ${sessionName}: it is not marked as ContextRelay-created for room ${roomId}.`, + }; + } + + const cwd = options.cwd ?? process.cwd(); + const errors: string[] = []; + let launched = 0; + commands.forEach((command, index) => { + const args = index === 0 + ? ["new-session", "-d", "-s", sessionName, "-n", `worker-${index + 1}`, `cd ${shellArg(cwd)} && ${command}`] + : ["new-window", "-t", sessionName, "-n", `worker-${index + 1}`, `cd ${shellArg(cwd)} && ${command}`]; + const result = spawnSync("tmux", args, { stdio: "ignore" }); + if (result.error) errors.push(result.error.message); + else if (typeof result.status === "number" && result.status !== 0) errors.push(`tmux ${args[0]} exited with code ${result.status}`); + else if (result.signal) errors.push(`tmux ${args[0]} exited with signal ${result.signal}`); + else { + if (index === 0) { + const marker = spawnSync("tmux", ["set-environment", "-t", sessionName, "CONTEXTRELAY_TMUX_ROOM", roomId], { stdio: "ignore" }); + if (marker.error) errors.push(marker.error.message); + else if (typeof marker.status === "number" && marker.status !== 0) errors.push(`tmux set-environment exited with code ${marker.status}`); + } + launched++; + } + }); + + if (errors.length > 0) return { ok: false, launched, error: errors.join("; "), sessionName, attachCommand }; + return { ok: true, launched, sessionName, attachCommand }; +} + +function appleScriptString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function shellArg(value: string): string { + return /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/src/session/tmux-installer.ts b/src/session/tmux-installer.ts new file mode 100644 index 0000000..7c51008 --- /dev/null +++ b/src/session/tmux-installer.ts @@ -0,0 +1,152 @@ +import { spawnSync as defaultSpawnSync } from "node:child_process"; + +type SpawnSyncLike = ( + command: string, + args: string[], + options: { stdio: "ignore" } | { stdio: "inherit" }, +) => { error?: Error; status: number | null; signal: NodeJS.Signals | null }; + +export interface TmuxInstallPlan { + manager: "brew" | "apt" | "dnf" | "yum" | "pacman" | "zypper" | "wsl-apt"; + label: string; + command: string; + args: string[]; + displayCommand: string; +} + +export interface TmuxInstallPlanResult { + ok: boolean; + plan?: TmuxInstallPlan; + detail?: string; +} + +export function resolveTmuxInstallPlan(options: { + platform?: NodeJS.Platform; + spawnSyncFn?: SpawnSyncLike; +} = {}): TmuxInstallPlanResult { + const platform = options.platform ?? process.platform; + const spawnSync = options.spawnSyncFn ?? defaultSpawnSync; + + if (platform === "darwin") { + if (!commandAvailable("brew", spawnSync)) { + return { ok: false, detail: "Homebrew was not found. Install Homebrew first, then run: brew install tmux" }; + } + return { + ok: true, + plan: { + manager: "brew", + label: "Homebrew", + command: "brew", + args: ["install", "tmux"], + displayCommand: "brew install tmux", + }, + }; + } + + if (platform === "linux") { + if (commandAvailable("apt-get", spawnSync)) { + return shellPlan("apt", "APT", "sudo apt-get update && sudo apt-get install -y tmux"); + } + if (commandAvailable("dnf", spawnSync)) { + return { + ok: true, + plan: { + manager: "dnf", + label: "DNF", + command: "sudo", + args: ["dnf", "install", "-y", "tmux"], + displayCommand: "sudo dnf install -y tmux", + }, + }; + } + if (commandAvailable("yum", spawnSync)) { + return { + ok: true, + plan: { + manager: "yum", + label: "Yum", + command: "sudo", + args: ["yum", "install", "-y", "tmux"], + displayCommand: "sudo yum install -y tmux", + }, + }; + } + if (commandAvailable("pacman", spawnSync)) { + return { + ok: true, + plan: { + manager: "pacman", + label: "Pacman", + command: "sudo", + args: ["pacman", "-Sy", "--needed", "tmux"], + displayCommand: "sudo pacman -Sy --needed tmux", + }, + }; + } + if (commandAvailable("zypper", spawnSync)) { + return { + ok: true, + plan: { + manager: "zypper", + label: "Zypper", + command: "sudo", + args: ["zypper", "install", "-y", "tmux"], + displayCommand: "sudo zypper install -y tmux", + }, + }; + } + return { ok: false, detail: "No supported Linux package manager found. Install tmux with your distro package manager." }; + } + + if (platform === "win32") { + if (commandAvailable("wsl", spawnSync)) { + return { + ok: true, + plan: { + manager: "wsl-apt", + label: "WSL APT", + command: "wsl", + args: ["sh", "-lc", "sudo apt-get update && sudo apt-get install -y tmux"], + displayCommand: "wsl sh -lc 'sudo apt-get update && sudo apt-get install -y tmux'", + }, + }; + } + return { + ok: false, + detail: "tmux is supported on Windows through WSL. Install WSL first, then install tmux inside the WSL distro.", + }; + } + + return { ok: false, detail: `No automatic tmux installer is known for platform ${platform}.` }; +} + +export function installTmux(plan: TmuxInstallPlan, options: { spawnSyncFn?: SpawnSyncLike } = {}): { ok: boolean; detail: string } { + const spawnSync = options.spawnSyncFn ?? defaultSpawnSync; + const result = spawnSync(plan.command, plan.args, { stdio: "inherit" }); + if (result.error) return { ok: false, detail: result.error.message }; + if (typeof result.status === "number" && result.status !== 0) { + return { ok: false, detail: `${plan.displayCommand} exited with code ${result.status}` }; + } + if (result.signal) return { ok: false, detail: `${plan.displayCommand} exited with signal ${result.signal}` }; + return { ok: true, detail: "tmux install command completed" }; +} + +function shellPlan(manager: "apt", label: string, command: string): TmuxInstallPlanResult { + return { + ok: true, + plan: { + manager, + label, + command: "sh", + args: ["-lc", command], + displayCommand: command, + }, + }; +} + +function commandAvailable(command: string, spawnSync: SpawnSyncLike): boolean { + const result = spawnSync(command, ["--version"], { stdio: "ignore" }); + if (!result.error && result.status === 0) return true; + const fallback = spawnSync("which", [command], { stdio: "ignore" }); + return !fallback.error && fallback.status === 0; +} diff --git a/src/session/worker-state.ts b/src/session/worker-state.ts new file mode 100644 index 0000000..39e75ef --- /dev/null +++ b/src/session/worker-state.ts @@ -0,0 +1,96 @@ +import type { LedgerEntry } from "../types"; +import type { LaneWorkerState, RegisteredLane, RegisteredRoom, RoomRegistryInspection } from "./rooms"; + +const STALE_AFTER_MS = 5 * 60 * 1000; + +export function deriveWorkerInbox( + registry: RoomRegistryInspection | undefined, + entries: LedgerEntry[], + now = Date.now(), +): LaneWorkerState[] { + if (!registry) return []; + const latestErrorByLane = latestEntryByLane(entries, (entry) => entry.type === "error" || entry.runtimeEvent?.status === "failed" || entry.runtimeEvent?.status === "blocked"); + const latestRouteByLane = latestEntryByLane(entries, (entry) => entry.type === "route"); + const result: LaneWorkerState[] = []; + for (const room of registry.rooms) { + if (room.lifecycle === "archived") continue; + for (const lane of Object.values(room.lanes)) { + result.push(deriveLaneState(room, lane, latestErrorByLane, latestRouteByLane, now)); + } + } + return result.sort((left, right) => `${left.roomId}/${left.laneId}`.localeCompare(`${right.roomId}/${right.laneId}`)); +} + +function deriveLaneState( + room: RegisteredRoom, + lane: RegisteredLane, + errors: Map, + routes: Map, + now: number, +): LaneWorkerState { + const key = laneKey(room.id, lane.id); + const pending = Object.values(lane.pendingApprovals); + const latestError = errors.get(key); + const latestRoute = routes.get(key); + if (lane.lifecycle === "complete") { + return baseState(room.id, lane, "complete", "lane marked complete"); + } + if (lane.lifecycle === "archived") { + return baseState(room.id, lane, "complete", "lane archived"); + } + if (pending.length > 0) { + const approval = pending.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt))[0]; + return { + ...baseState(room.id, lane, "waiting_approval", `waiting for ${approval.reviewerParticipantId} to decide ${approval.action}`), + lastApprovalId: approval.approvalId, + runtimeSessionId: approval.runtimeSessionId, + }; + } + if (latestError) { + return { + ...baseState(room.id, lane, "failed", latestError.content || "latest lane event failed"), + lastError: latestError.content, + lastActivityAt: new Date(latestError.timestamp).toISOString(), + }; + } + const lastSeen = Date.parse(lane.lastSeenAt); + if (Number.isFinite(lastSeen) && now - lastSeen > STALE_AFTER_MS) { + return baseState(room.id, lane, "stale", `no lane activity since ${lane.lastSeenAt}`); + } + if (latestRoute) { + return { + ...baseState(room.id, lane, "active", "latest route delivered or queued"), + lastActivityAt: new Date(latestRoute.timestamp).toISOString(), + }; + } + return lane.ownerParticipantId + ? baseState(room.id, lane, "queued", "assigned but no route activity yet") + : baseState(room.id, lane, "blocked", "no owner participant assigned"); +} + +function baseState(roomId: string, lane: RegisteredLane, state: LaneWorkerState["state"], why: string): LaneWorkerState { + return { + roomId, + laneId: lane.id, + state, + why, + lastActivityAt: lane.lastSeenAt, + ...(lane.ownerParticipantId ? { ownerParticipantId: lane.ownerParticipantId } : {}), + }; +} + +function latestEntryByLane(entries: LedgerEntry[], predicate: (entry: LedgerEntry) => boolean): Map { + const result = new Map(); + for (const entry of entries) { + if (!predicate(entry)) continue; + const roomId = typeof entry.meta?.roomId === "string" ? entry.meta.roomId : undefined; + const laneId = typeof entry.meta?.laneId === "string" ? entry.meta.laneId : undefined; + if (!roomId || !laneId) continue; + result.set(laneKey(roomId, laneId), entry); + } + return result; +} + +function laneKey(roomId: string, laneId: string): string { + return `${roomId}/${laneId}`; +} diff --git a/src/types.ts b/src/types.ts index 2258558..c7d963c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,30 @@ export type MessageSource = "claude" | "codex"; export type LedgerSource = MessageSource | "human" | "system"; export type MessageKind = "chat" | "handoff" | "decision" | "error" | "summary" | "note" | "backup_request" | "backup_result" | "finality"; +export type DeliveryMode = "online-only" | "store-if-offline"; +export type RouteAckStatus = "requested" | "delivered" | "queued" | "dropped" | "failed"; + +export interface RouteEndpoint { + participantId?: string; + runtimeSessionId?: string; + roomId?: string; + laneId?: string; +} + +export interface RoutingEnvelope { + messageId: string; + traceId: string; + idempotencyKey: string; + from: RouteEndpoint; + to: RouteEndpoint; + roomId?: string; + laneId?: string; + deliveryMode: DeliveryMode; + ack: RouteAckStatus; + ttl: number; + visited: string[]; +} + export type RuntimeEventKind = | "command" | "file_change" @@ -57,6 +81,7 @@ export interface LedgerArtifact { summary: string; status?: "passed" | "failed" | "blocked" | "unknown"; evidence?: string[]; + subagentAssisted?: boolean; } export interface BridgeMessage { @@ -66,11 +91,22 @@ export interface BridgeMessage { timestamp: number; /** V1 uses roomId as the shared session id. It is not a separate multi-room router yet. */ roomId?: string; + laneId?: string; target?: MessageSource; caller_agent?: MessageSource; caller_depth?: number; handles_handoff_id?: string; message_kind?: MessageKind; + messageId?: string; + traceId?: string; + idempotencyKey?: string; + from?: RouteEndpoint; + to?: RouteEndpoint; + deliveryMode?: DeliveryMode; + ack?: RouteAckStatus; + ttl?: number; + visited?: string[]; + routing?: RoutingEnvelope; } export interface HandoffPayload { @@ -87,7 +123,7 @@ export interface LedgerEntry { id: string; timestamp: number; sessionId: string; - type: "session_started" | "message" | "handoff" | "note" | "error" | "decision" | "summary" | "backup_request" | "backup_result" | "finality_proposal" | "finality_decision" | "runtime_event" | "artifact" | "release_gate"; + type: "session_started" | "message" | "handoff" | "note" | "error" | "decision" | "summary" | "backup_request" | "backup_result" | "finality_proposal" | "finality_decision" | "runtime_event" | "artifact" | "release_gate" | "route" | "approval_requested" | "approval_decided" | "approval_expired"; source: LedgerSource; target?: MessageSource; content: string; diff --git a/src/unit-test/cli.test.ts b/src/unit-test/cli.test.ts index 9680b00..ebc198b 100644 --- a/src/unit-test/cli.test.ts +++ b/src/unit-test/cli.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,6 +8,8 @@ import { buildClaudeArgs, checkOwnedFlagConflicts, parseClaudeSessionFlag } from import { buildCodexArgs, buildCodexEnv, parseCodexSessionFlag } from "../cli/codex"; import { parseKillArgs } from "../cli/kill"; import { buildRegistrySessionListOutput, formatSessionListOutput, parseSessionArgs } from "../cli/session"; +import { ensureTmuxForMesh, meshBackendDefault, parseMeshStartOptions } from "../cli/mesh"; +import { withInstanceEnv } from "../cli/mesh-control"; import { runAutonomy } from "../cli/autonomy"; import { runFinalize } from "../cli/finalize"; import { runCoordinator } from "../cli/coordinator"; @@ -120,8 +123,7 @@ describe("CLI: owned flag conflict detection", () => { describe("CLI: claude argument construction", () => { test("uses development channel loading by default", () => { expect(buildClaudeArgs(["--resume"], {})).toEqual([ - "--dangerously-load-development-channels", - "plugin:contextrelay@contextrelay", + "--dangerously-load-development-channels=plugin:contextrelay@contextrelay", "--resume", ]); }); @@ -133,16 +135,34 @@ describe("CLI: claude argument construction", () => { channelsEnabled: true, allowedChannelPlugins: [{ marketplace: "contextrelay", plugin: "contextrelay" }], }), - "--channels", - "plugin:contextrelay@contextrelay", + "--channels=plugin:contextrelay@contextrelay", "--resume", ]); }); test("treats 1 as development channel loading", () => { expect(buildClaudeArgs(["--resume"], { CONTEXTRELAY_CLAUDE_DEVELOPMENT_CHANNELS: "1" })).toEqual([ - "--dangerously-load-development-channels", - "plugin:contextrelay@contextrelay", + "--dangerously-load-development-channels=plugin:contextrelay@contextrelay", + "--resume", + ]); + }); + + test("defaults mesh launches to approved channels unless explicitly overridden", () => { + const approved = [ + "--settings", + JSON.stringify({ + channelsEnabled: true, + allowedChannelPlugins: [{ marketplace: "contextrelay", plugin: "contextrelay" }], + }), + "--channels=plugin:contextrelay@contextrelay", + "--resume", + ]; + expect(buildClaudeArgs(["--resume"], { CONTEXTRELAY_ENABLE_MESH: "1" })).toEqual(approved); + expect(buildClaudeArgs(["--resume"], { + CONTEXTRELAY_ENABLE_MESH: "1", + CONTEXTRELAY_CLAUDE_DEVELOPMENT_CHANNELS: "1", + })).toEqual([ + "--dangerously-load-development-channels=plugin:contextrelay@contextrelay", "--resume", ]); }); @@ -158,6 +178,38 @@ describe("CLI: claude argument construction", () => { }); }); + test("preserves a mesh startup prompt after the claude session flag", () => { + const prompt = "You are claude-2:claude in ContextRelay mesh room review."; + const parsed = parseClaudeSessionFlag(["--session", "claude-2", prompt]); + expect(parsed).toEqual({ + sessionId: "claude-2", + args: [prompt], + }); + expect(buildClaudeArgs(parsed.args, {})).toEqual([ + "--dangerously-load-development-channels=plugin:contextrelay@contextrelay", + prompt, + ]); + }); + + test("keeps prompts and user flags outside variadic claude channel options", () => { + const prompt = "You are default:claude in ContextRelay mesh room review."; + expect(buildClaudeArgs([prompt, "--model", "opus"], {})).toEqual([ + "--dangerously-load-development-channels=plugin:contextrelay@contextrelay", + prompt, + "--model", + "opus", + ]); + expect(buildClaudeArgs([prompt], { CONTEXTRELAY_CLAUDE_DEVELOPMENT_CHANNELS: "0" })).toEqual([ + "--settings", + JSON.stringify({ + channelsEnabled: true, + allowedChannelPlugins: [{ marketplace: "contextrelay", plugin: "contextrelay" }], + }), + "--channels=plugin:contextrelay@contextrelay", + prompt, + ]); + }); + test("rejects claude session flag without an id", () => { expect(() => parseClaudeSessionFlag(["--session"])).toThrow("--session requires a session id"); expect(() => parseClaudeSessionFlag(["--session="])).toThrow("--session requires a session id"); @@ -227,6 +279,19 @@ describe("CLI: codex argument construction", () => { }); }); + test("preserves a mesh startup prompt after the codex session flag", () => { + const prompt = "You are codex-2:codex in ContextRelay mesh room review."; + const parsed = parseCodexSessionFlag(["--session", "codex-2", prompt]); + expect(parsed).toEqual({ + sessionId: "codex-2", + args: [prompt], + }); + expect(buildCodexArgs(parsed.args, proxy)).toEqual([ + ...bridgeArgs, + prompt, + ]); + }); + test("rejects codex session flag without an id", () => { expect(() => parseCodexSessionFlag(["--session"])).toThrow("--session requires a session id"); expect(() => parseCodexSessionFlag(["--session="])).toThrow("--session requires a session id"); @@ -409,6 +474,202 @@ function runtimeStatus(sessionId: string, tuiConnected: boolean, claudeConnected }; } +describe("CLI: mesh start command", () => { + test("parses mesh wizard options", () => { + expect(parseMeshStartOptions([ + "review", + "--codex-workers", + "2", + "--claude-workers=1", + "--writable", + "--worktree-prefix", + "../repo-", + "--enable-mesh", + ])).toEqual({ + roomId: "review", + codexWorkers: 2, + claudeWorkers: 1, + mode: "writable", + worktreePrefix: "../repo-", + enableMesh: true, + launchTerminals: true, + backend: "tmux", + assumeYes: false, + }); + }); + + test("defaults mesh wizard to one read-only Claude and Codex worker", () => { + expect(parseMeshStartOptions(["review"])).toEqual({ + roomId: "review", + codexWorkers: 1, + claudeWorkers: 1, + mode: "read-only", + enableMesh: false, + launchTerminals: true, + backend: "tmux", + assumeYes: false, + }); + }); + + test("uses configured mesh launch backend as the start default", () => { + expect(parseMeshStartOptions(["review"], { backend: "print-only" })).toMatchObject({ + roomId: "review", + backend: "print-only", + }); + }); + + test("can disable automatic terminal launch for scripts", () => { + expect(parseMeshStartOptions(["review", "--no-launch-terminals"])).toMatchObject({ + roomId: "review", + launchTerminals: false, + }); + }); + + test("parses mesh tmux installer confirmation flag", () => { + expect(parseMeshStartOptions(["review", "--yes"])).toMatchObject({ + roomId: "review", + backend: "tmux", + assumeYes: true, + }); + }); + + test("mesh backend default forces legacy terminal-windows config to tmux", () => { + expect(meshBackendDefault("terminal-windows")).toBe("tmux"); + expect(meshBackendDefault("tmux")).toBe("tmux"); + expect(meshBackendDefault("print-only")).toBe("print-only"); + }); + + test("JSON mesh start path never prompts or installs tmux", async () => { + let prompted = false; + let installed = false; + const ready = await ensureTmuxForMesh({ + json: true, + assumeYes: true, + stdinIsTTY: true, + stdoutIsTTY: true, + detectTmuxFn: () => ({ available: false, detail: "missing" }), + resolveInstallPlanFn: () => ({ + ok: true, + plan: { + manager: "brew", + label: "Homebrew", + command: "brew", + args: ["install", "tmux"], + displayCommand: "brew install tmux", + }, + }), + confirmTmuxInstallFn: async () => { + prompted = true; + return true; + }, + installTmuxFn: () => { + installed = true; + return { ok: true, detail: "installed" }; + }, + }); + + expect(ready).toBe(false); + expect(prompted).toBe(false); + expect(installed).toBe(false); + }); + + test("interactive mesh start can install tmux only after consent", async () => { + let installed = false; + const ready = await ensureTmuxForMesh({ + json: false, + assumeYes: false, + stdinIsTTY: true, + stdoutIsTTY: true, + detectTmuxFn: (() => { + let calls = 0; + return () => { + calls++; + return calls > 1 ? { available: true, detail: "tmux available" } : { available: false, detail: "missing" }; + }; + })(), + resolveInstallPlanFn: () => ({ + ok: true, + plan: { + manager: "brew", + label: "Homebrew", + command: "brew", + args: ["install", "tmux"], + displayCommand: "brew install tmux", + }, + }), + confirmTmuxInstallFn: async () => true, + installTmuxFn: () => { + installed = true; + return { ok: true, detail: "installed" }; + }, + }); + + expect(ready).toBe(true); + expect(installed).toBe(true); + }); + + test("rejects malformed mesh wizard worker counts", () => { + expect(() => parseMeshStartOptions(["review", "--codex-workers", "-1"])).toThrow("--codex-workers"); + expect(() => parseMeshStartOptions(["review", "--claude-workers", "1.5"])).toThrow("--claude-workers"); + }); + + test("pins launched mesh worker commands to the resolved project instance", () => { + const command = withInstanceEnv("CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex", { + instanceId: "ctx_test", + projectRoot: "/tmp/contextrelay project", + stateDir: "/tmp/contextrelay project/.contextrelay/state", + appPort: 4500, + proxyPort: 4501, + controlPort: 4502, + env: {}, + }); + + expect(command).toStartWith("env "); + expect(command).toContain("CONTEXTRELAY_INSTANCE_ID=ctx_test"); + expect(command).toContain("CONTEXTRELAY_CONTROL_PORT=4502"); + expect(command).toContain("CODEX_WS_PORT=4500"); + expect(command).toContain("CODEX_PROXY_PORT=4501"); + expect(command).toContain("CONTEXTRELAY_PROJECT_ROOT='/tmp/contextrelay project'"); + expect(command).toEndWith("sh -lc 'CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex'"); + }); + + test("pins instance env across compound mesh worker commands", () => { + const command = withInstanceEnv("cd /tmp/worktree && CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex", { + instanceId: "ctx_test", + projectRoot: "/tmp/project", + stateDir: "/tmp/project/.contextrelay/state", + appPort: 4500, + proxyPort: 4501, + controlPort: 4502, + env: {}, + }); + + expect(command).toContain("CONTEXTRELAY_CONTROL_PORT=4502"); + expect(command).toEndWith("sh -lc 'cd /tmp/worktree && CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex'"); + }); + + test("preserves shell-sensitive prompt text through instance env wrapping", () => { + const prompt = "You are worker 'quoted' \"$HOME\" `uname`"; + const inner = `node -e 'process.stdout.write(JSON.stringify({root:process.env.CONTEXTRELAY_PROJECT_ROOT,arg:process.argv[1]}))' ${shellArg(prompt)}`; + const command = withInstanceEnv(inner, { + instanceId: "ctx_test", + projectRoot: "/tmp/contextrelay project", + stateDir: "/tmp/contextrelay project/.contextrelay/state", + appPort: 4500, + proxyPort: 4501, + controlPort: 4502, + env: {}, + }); + const result = spawnSync("sh", ["-lc", command], { encoding: "utf-8" }); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + root: "/tmp/contextrelay project", + arg: prompt, + }); + }); +}); + describe("CLI: kill command", () => { test("parses session-scoped kill flags", () => { expect(parseKillArgs(["--session", "side"])).toEqual({ all: false, sessionId: "side" }); @@ -533,3 +794,7 @@ describe("CLI: autonomy/finalize/coordinator config", () => { expect(config.permissions.agentOverrides.reviewer).toBeUndefined(); }); }); + +function shellArg(value: string): string { + return /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/src/unit-test/codex-mcp.test.ts b/src/unit-test/codex-mcp.test.ts index b62f215..137722d 100644 --- a/src/unit-test/codex-mcp.test.ts +++ b/src/unit-test/codex-mcp.test.ts @@ -22,6 +22,10 @@ describe("Codex MCP adapter", () => { expect(names).toContain("ask_claude_backup"); expect(names).toContain("backup_status"); expect(names).toContain("propose_final"); + expect(names).toContain("mesh_status"); + expect(names).toContain("create_room"); + expect(names).toContain("enter_lane"); + expect(names).toContain("complete_lane"); const serialized = JSON.stringify(CODEX_MCP_TOOLS); expect(serialized).not.toContain('"source"'); diff --git a/src/unit-test/config-service.test.ts b/src/unit-test/config-service.test.ts index fd4e4b7..c1bc8fd 100644 --- a/src/unit-test/config-service.test.ts +++ b/src/unit-test/config-service.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { ConfigService, DEFAULT_CONFIG, resolveHookCompactionConfig } from "../config-service"; +import { ConfigService, DEFAULT_CONFIG, isMeshModeEnabled, resolveHookCompactionConfig } from "../config-service"; describe("ConfigService", () => { let tempDir: string; @@ -38,6 +38,9 @@ describe("ConfigService", () => { expect(config.turnCoordination.attentionWindowSeconds).toBe(15); expect(config.turnCoordination.bufferStatusDuringAttention).toBe(false); expect(config.turnCoordination.hookCompaction).toEqual({ mode: "verbose" }); + expect(config.features.meshMode.enabled).toBe(false); + expect(isMeshModeEnabled(config, {} as NodeJS.ProcessEnv)).toBe(false); + expect(isMeshModeEnabled(config, { CONTEXTRELAY_ENABLE_MESH: "1" } as NodeJS.ProcessEnv)).toBe(true); expect(resolveHookCompactionConfig(config, {} as NodeJS.ProcessEnv)).toEqual({ mode: "verbose", previewLimit: 5, @@ -49,11 +52,56 @@ describe("ConfigService", () => { expect(config.permissions.readonly).toBe(false); expect(config.permissions.allowed).toContain("external_api"); expect(config.permissions.agentOverrides).toEqual({}); + expect(config.activeProfile).toBe("reviewer"); + expect(config.profiles.builder.description).toContain("Writable"); + expect(config.wizardPresets.implement.mode).toBe("writable"); + expect(config.launch.backend).toBe("tmux"); + }); + + test("load normalizes profiles, presets, and launch backend additively", () => { + const svc = new ConfigService(tempDir); + const configPath = join(tempDir, ".contextrelay", "config.json"); + mkdirSync(join(tempDir, ".contextrelay"), { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + version: "1.0", + activeProfile: "custom_builder", + profiles: { + custom_builder: { + description: "Custom builder", + overrides: { + permissions: { + readonly: false, + allowed: ["read", "write"], + agentOverrides: {}, + }, + }, + }, + BadName: { description: "ignored" }, + }, + wizardPresets: { + implement: { profile: "custom_builder", codexWorkers: 2, claudeWorkers: 1, mode: "writable" }, + }, + launch: { backend: "tmux" }, + }) + "\n", + "utf-8", + ); + + const loaded = svc.load(); + expect(loaded).not.toBeNull(); + expect(loaded!.activeProfile).toBe("custom_builder"); + expect(loaded!.profiles.custom_builder.description).toBe("Custom builder"); + expect(loaded!.profiles.BadName).toBeUndefined(); + expect(loaded!.wizardPresets.implement.profile).toBe("custom_builder"); + expect(loaded!.wizardPresets.implement.codexWorkers).toBe(2); + expect(loaded!.launch.backend).toBe("tmux"); }); test("save and load round-trips correctly", () => { const svc = new ConfigService(tempDir); - const config = { ...DEFAULT_CONFIG, idleShutdownSeconds: 60 }; + const config = structuredClone({ ...DEFAULT_CONFIG, idleShutdownSeconds: 60 }); + config.features.meshMode.enabled = true; svc.save(config); expect(svc.hasConfig()).toBe(true); @@ -62,6 +110,7 @@ describe("ConfigService", () => { expect(loaded).not.toBeNull(); expect(loaded!.idleShutdownSeconds).toBe(60); expect(loaded!.version).toBe("1.0"); + expect(loaded!.features.meshMode.enabled).toBe(true); }); test("load normalizes older daemon config into codex config", () => { diff --git a/src/unit-test/control-protocol.test.ts b/src/unit-test/control-protocol.test.ts index 9613535..bc54590 100644 --- a/src/unit-test/control-protocol.test.ts +++ b/src/unit-test/control-protocol.test.ts @@ -108,6 +108,29 @@ describe("validateControlClientMessage", () => { expect(r.ok).toBe(true); }); + test("accepts additive routing envelope fields on bridge messages", () => { + const r = validateControlClientMessage({ + type: "relay_message", + requestId: "req_routing", + message: { + ...validBridgeMessage, + source: "codex", + target: "claude", + laneId: "tests", + messageId: "msg-1", + traceId: "trace-1", + idempotencyKey: "idem-1", + from: { participantId: "default:codex", runtimeSessionId: "default", roomId: "review", laneId: "tests" }, + to: { participantId: "default:claude", runtimeSessionId: "default", roomId: "review", laneId: "tests" }, + deliveryMode: "online-only", + ack: "requested", + ttl: 4, + visited: ["default:codex"], + }, + }); + expect(r.ok).toBe(true); + }); + test("rejects claude_to_codex with bad message.source", () => { const r = validateControlClientMessage({ type: "claude_to_codex", diff --git a/src/unit-test/e2e-reconnect.test.ts b/src/unit-test/e2e-reconnect.test.ts index 74c36ba..dbae705 100644 --- a/src/unit-test/e2e-reconnect.test.ts +++ b/src/unit-test/e2e-reconnect.test.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import { DaemonClient } from "../daemon-client"; import { appendLocalAuthToken, readLocalAuthToken } from "../local-auth"; import { StateDirResolver } from "../state-dir"; -import { saveSessionRegistry } from "../session/registry"; +import { saveSessionRegistryToPath, sessionRegistryStatePath } from "../session/registry"; /** * E2E tests: daemon lifecycle + client reconnect @@ -22,7 +22,7 @@ const TEST_PROXY_PORT = 14501; const TEST_STATE_DIR = `/tmp/contextrelay-e2e-test-${TEST_CONTROL_PORT}`; const TEST_PROJECT_DIR = `/tmp/contextrelay-e2e-project-${TEST_CONTROL_PORT}`; const TEST_PID_FILE = join(TEST_STATE_DIR, "daemon.pid"); -const TEST_SESSION_REGISTRY_FILE = join(TEST_PROJECT_DIR, ".contextrelay", "sessions.json"); +const TEST_SESSION_REGISTRY_FILE = sessionRegistryStatePath(TEST_STATE_DIR); const HEALTH_URL = `http://127.0.0.1:${TEST_CONTROL_PORT}/healthz`; const WS_URL = `ws://127.0.0.1:${TEST_CONTROL_PORT}/ws`; const DAEMON_PATH = fileURLToPath(new URL("../daemon.ts", import.meta.url)); @@ -540,7 +540,7 @@ describe("E2E: daemon lifecycle + reconnect", () => { { agentId: "codex", role: "coordinator" }, ], }; - saveSessionRegistry(TEST_PROJECT_DIR, registry); + saveSessionRegistryToPath(TEST_SESSION_REGISTRY_FILE, registry); const archivedRuntimeSession = await client.callLedgerTool({ type: "append_note", source: "claude", @@ -562,7 +562,7 @@ describe("E2E: daemon lifecycle + reconnect", () => { expect(archivedLiveReply.code).toBe("INVALID_REQUEST"); expect(archivedLiveReply.error).toContain("archived runtimeSessionId"); - saveSessionRegistry(TEST_PROJECT_DIR, { ...registry, instanceId: "ctx_other" }); + saveSessionRegistryToPath(TEST_SESSION_REGISTRY_FILE, { ...registry, instanceId: "ctx_other" }); const foreignRuntimeSession = await client.callLedgerTool({ type: "append_note", source: "claude", @@ -584,7 +584,7 @@ describe("E2E: daemon lifecycle + reconnect", () => { expect(foreignLiveReply.code).toBe("INVALID_REQUEST"); expect(foreignLiveReply.error).toContain("belongs to instance"); } finally { - saveSessionRegistry(TEST_PROJECT_DIR, originalRegistry); + saveSessionRegistryToPath(TEST_SESSION_REGISTRY_FILE, originalRegistry); } const runtimeStatusPromise = new Promise((resolve) => { diff --git a/src/unit-test/git-worktree.test.ts b/src/unit-test/git-worktree.test.ts new file mode 100644 index 0000000..510cdbf --- /dev/null +++ b/src/unit-test/git-worktree.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { createGitWorktree, parseGitWorktreeList, planGitWorktreeCreate, removeGitWorktree, statusGitWorktrees } from "../session/git-worktree"; + +const PORCELAIN = `worktree /repo/main +HEAD abc123 +branch refs/heads/main + +worktree /repo/feature +HEAD def456 +branch refs/heads/feature +`; + +describe("git worktree helpers", () => { + test("parses porcelain worktree output", () => { + expect(parseGitWorktreeList(PORCELAIN)).toEqual([ + { path: "/repo/main", head: "abc123", branch: "main" }, + { path: "/repo/feature", head: "def456", branch: "feature" }, + ]); + }); + + test("plan detects branch collisions", () => { + const plan = planGitWorktreeCreate({ + targetPath: "/repo/other", + branch: "feature", + execFileSyncFn: (() => PORCELAIN) as any, + }); + expect(plan.canCreate).toBe(false); + expect(plan.issues.join("\n")).toContain("branch is already checked out"); + }); + + test("create and remove require explicit confirmation", () => { + expect(() => createGitWorktree({ + targetPath: "/repo/new", + branch: "new", + execFileSyncFn: (() => PORCELAIN) as any, + })).toThrow("--confirm"); + expect(() => removeGitWorktree({ + targetPath: "/repo/feature", + execFileSyncFn: (() => PORCELAIN) as any, + })).toThrow("--confirm"); + }); + + test("status reports dirty worktrees through injected git runner", () => { + const status = statusGitWorktrees({ + execFileSyncFn: ((command: string, args: string[]) => { + if (command === "git" && args[0] === "worktree") return PORCELAIN; + return " M src/file.ts\n"; + }) as any, + }); + expect(status[0].dirty).toBe(false); + expect(status[0].exists).toBe(false); + }); +}); diff --git a/src/unit-test/instance.test.ts b/src/unit-test/instance.test.ts index e61a2d8..0680aec 100644 --- a/src/unit-test/instance.test.ts +++ b/src/unit-test/instance.test.ts @@ -118,6 +118,48 @@ describe("project instance resolver", () => { })); }); + test("explicit port base reuses its live status port group", async () => { + const project = join(tempRoot, "port-base-live-status"); + await resolveProjectInstance({ cwd: project, portBase: 23140 }); + const first = await resolveProjectInstance({ cwd: project, portBase: 23150 }); + const daemonIdentity = "port-base-live-daemon"; + mkdirSync(first.stateDir, { recursive: true }); + writeFileSync(join(first.stateDir, "token"), "live-token\n", "utf-8"); + writeFileSync(join(first.stateDir, "daemon-identity"), `${daemonIdentity}\n`, "utf-8"); + writeFileSync(join(first.stateDir, "status.json"), JSON.stringify({ + proxyUrl: `ws://127.0.0.1:${first.proxyPort}`, + appServerUrl: `ws://127.0.0.1:${first.appPort}`, + controlPort: first.controlPort, + pid: process.pid, + instanceId: first.instanceId, + projectRoot: first.projectRoot, + stateDir: first.stateDir, + daemonIdentity, + }, null, 2)); + const server = await listenHealth(first.controlPort, { + pid: process.pid, + instanceId: first.instanceId, + projectRoot: first.projectRoot, + stateDir: first.stateDir, + controlPort: first.controlPort, + proxyUrl: `ws://127.0.0.1:${first.proxyPort}`, + appServerUrl: `ws://127.0.0.1:${first.appPort}`, + daemonIdentity, + }); + + try { + const second = await resolveProjectInstance({ cwd: project, portBase: 23150 }); + + expect(second.instanceId).toBe(first.instanceId); + expect(second.stateDir).toBe(first.stateDir); + expect(second.appPort).toBe(23150); + expect(second.proxyPort).toBe(23151); + expect(second.controlPort).toBe(23152); + } finally { + await closeServer(server); + } + }); + test("explicit runtime env honors the spawned instance id without rewriting project config", async () => { const project = join(tempRoot, "spawned-env"); const stateDir = join(tempRoot, "spawned-state"); diff --git a/src/unit-test/legacy-compat-mesh-disabled.test.ts b/src/unit-test/legacy-compat-mesh-disabled.test.ts new file mode 100644 index 0000000..7718149 --- /dev/null +++ b/src/unit-test/legacy-compat-mesh-disabled.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ConfigService, isMeshModeEnabled } from "../config-service"; +import { SessionLedger } from "../session/ledger"; +import { SessionRegistry, sessionRegistryPath } from "../session/registry"; +import { RoomRegistry, roomRegistryPath } from "../session/rooms"; + +describe("legacy compatibility with mesh disabled", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "contextrelay-legacy-mesh-disabled-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("default config keeps mesh disabled and session registry shape compatible", () => { + const config = new ConfigService(tempDir).loadOrDefault(); + expect(isMeshModeEnabled(config, {} as NodeJS.ProcessEnv)).toBe(false); + + const sessions = new SessionRegistry(tempDir, "ctx_test").ensureDefaultSession(); + expect(sessions.sessions.default.id).toBe("default"); + expect(sessions.sessions.default.transcriptSessionId).toBeUndefined(); + expect(JSON.parse(readFileSync(sessionRegistryPath(tempDir), "utf-8")).sessions.default.transcriptSessionId).toBeUndefined(); + }); + + test("room inspection does not create mesh state or move the legacy current pointer", async () => { + const ledger = new SessionLedger(tempDir); + const currentSessionId = ledger.getOrCreateCurrentSessionId(); + const currentPath = join(tempDir, ".contextrelay", "current"); + + const rooms = new RoomRegistry(tempDir, "ctx_test").inspect(); + expect(rooms.rooms).toEqual([]); + expect(existsSync(roomRegistryPath(tempDir))).toBe(false); + expect(readFileSync(currentPath, "utf-8").trim()).toBe(currentSessionId); + + const entry = await ledger.append({ + type: "note", + source: "codex", + content: "legacy write path", + }); + + expect(entry.sessionId).toBe(currentSessionId); + expect(ledger.read(currentSessionId).map((item) => item.type)).toEqual(["session_started", "note"]); + expect(existsSync(roomRegistryPath(tempDir))).toBe(false); + }); +}); diff --git a/src/unit-test/mesh-wizard.test.ts b/src/unit-test/mesh-wizard.test.ts new file mode 100644 index 0000000..3fc1524 --- /dev/null +++ b/src/unit-test/mesh-wizard.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildMeshWizardPlan, executeMeshWizardPlan } from "../session/mesh-wizard"; +import { ErrorCode } from "../errors"; + +const codexReadyPrompt = shellArg(meshReadyPrompt("default:codex", "review", "codex-1", "default")); +const claudeReadyPrompt = shellArg(meshReadyPrompt("default:claude", "review", "claude-1", "default")); +const codex2ReadyPrompt = shellArg(meshReadyPrompt("codex-2:codex", "review", "codex-2", "codex-2")); +const claude2ReadyPrompt = shellArg(meshReadyPrompt("claude-2:claude", "review", "claude-2", "claude-2")); + +describe("mesh wizard planner", () => { + test("builds read-only lanes for default Claude and Codex workers", () => { + const plan = buildMeshWizardPlan({ + roomId: "review", + codexWorkers: 1, + claudeWorkers: 1, + commandBin: "ctxrelay", + }); + + expect(plan.roomRequest).toMatchObject({ type: "create_room", id: "review", coordinatorParticipantId: "default:codex" }); + expect(plan.laneRequests.map((request) => request.laneId)).toEqual(["codex-1", "claude-1"]); + expect(plan.laneRequests.every((request) => request.permissions?.join(",") === "read")).toBe(true); + expect(plan.enterRequests.map((request) => request.participantId)).toEqual(["default:codex", "default:claude"]); + expect(plan.launchCommands).toEqual([ + `CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex ${codexReadyPrompt}`, + `CONTEXTRELAY_ENABLE_MESH=1 ctxrelay claude ${claudeReadyPrompt}`, + ]); + }); + + test("uses named runtime sessions for additional workers", () => { + const plan = buildMeshWizardPlan({ + roomId: "review", + codexWorkers: 2, + claudeWorkers: 2, + commandBin: "ctxrelay", + }); + + expect(plan.participants.map((participant) => participant.participantId)).toEqual([ + "default:codex", + "codex-2:codex", + "default:claude", + "claude-2:claude", + ]); + expect(plan.launchCommands).toEqual([ + `CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex ${codexReadyPrompt}`, + `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex --session codex-2 ${codex2ReadyPrompt}`, + `CONTEXTRELAY_ENABLE_MESH=1 ctxrelay claude ${claudeReadyPrompt}`, + `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 CONTEXTRELAY_ENABLE_MESH=1 ctxrelay claude --session claude-2 ${claude2ReadyPrompt}`, + ]); + }); + + test("writable mode requires existing prefixed worktrees", () => { + expect(() => buildMeshWizardPlan({ + roomId: "review", + codexWorkers: 1, + claudeWorkers: 0, + mode: "writable", + })).toThrow("requires --worktree-prefix"); + + const tempDir = mkdtempSync(join(tmpdir(), "contextrelay-mesh-wizard-")); + try { + expect(() => buildMeshWizardPlan({ + roomId: "review", + codexWorkers: 1, + claudeWorkers: 0, + mode: "writable", + worktreePrefix: join(tempDir, "repo-"), + })).toThrow("require existing worktrees"); + + mkdirSync(join(tempDir, "codex-1")); + const plan = buildMeshWizardPlan({ + roomId: "review", + codexWorkers: 1, + claudeWorkers: 0, + mode: "writable", + worktreePrefix: `${tempDir}/`, + }); + expect(plan.laneRequests[0].permissions).toEqual(["read", "write", "git"]); + expect(plan.laneRequests[0].worktreePath).toBe(join(tempDir, "codex-1")); + expect(plan.launchCommands[0]).toBe(`cd ${join(tempDir, "codex-1")} && CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex ${codexReadyPrompt}`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("quotes writable worktree paths in printed launch commands", () => { + const tempDir = mkdtempSync(join(tmpdir(), "contextrelay mesh wizard ")); + try { + mkdirSync(join(tempDir, "codex-1")); + const plan = buildMeshWizardPlan({ + roomId: "review", + codexWorkers: 1, + claudeWorkers: 0, + mode: "writable", + worktreePrefix: `${tempDir}/`, + }); + expect(plan.launchCommands[0]).toContain(`cd '${join(tempDir, "codex-1").replace(/'/g, "'\\''")}' && CONTEXTRELAY_ENABLE_MESH=1 ctxrelay codex`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("executor ignores already-existing room and lane errors", async () => { + const plan = buildMeshWizardPlan({ roomId: "review", codexWorkers: 1, claudeWorkers: 0 }); + const result = await executeMeshWizardPlan(plan, async (request) => { + if (request.type === "create_room" || request.type === "create_lane") { + return { ok: false, code: ErrorCode.INVALID_REQUEST, error: `${request.type} already exists` }; + } + return { ok: true, sessionId: "session_1234567890_abcdefab", message: `${request.type} ok` }; + }); + + expect(result.ok).toBe(true); + expect(result.messages).toContain("create_room already exists"); + expect(result.messages).toContain("create_lane already exists"); + expect(result.messages).toContain("enter_lane ok"); + }); +}); + +function meshReadyPrompt(participantId: string, roomId: string, laneId: string, runtimeSessionId: string): string { + return [ + `You are ${participantId} in ContextRelay mesh room ${roomId}, lane ${laneId}, runtime session ${runtimeSessionId}.`, + "First action: use the ContextRelay append_note tool to write a short ready note with your participant id, lane id, and runtime session id.", + "Use ContextRelay tool calls for worker communication when available; if a tool is unavailable, use the documented [IMPORTANT] CONTEXTRELAY_* fallback marker.", + "After the ready note, stand by for coordinator instructions and do not start unrelated work.", + ].join(" "); +} + +function shellArg(value: string): string { + return /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/src/unit-test/room-registry.test.ts b/src/unit-test/room-registry.test.ts new file mode 100644 index 0000000..02b942c --- /dev/null +++ b/src/unit-test/room-registry.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RoomRegistry, loadRoomRegistry, roomRegistryPath, roomRegistryStatePath, runtimeSessionHasOpenReadOnlyLane } from "../session/rooms"; + +describe("RoomRegistry", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "contextrelay-room-registry-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("creates rooms with schema version and instance binding", () => { + const registry = new RoomRegistry(tempDir, "ctx_test", fixedNow("2026-05-19T10:00:00.000Z")); + const data = registry.createRoom("release", { label: "Release", coordinatorParticipantId: "default:codex" }); + + expect(data.schemaVersion).toBe(2); + expect(data.instanceId).toBe("ctx_test"); + expect(data.activeRoomId).toBe("release"); + expect(data.rooms.release.coordinatorParticipantId).toBe("default:codex"); + expect(JSON.parse(readFileSync(roomRegistryPath(tempDir), "utf-8")).schemaVersion).toBe(2); + }); + + test("can store room registry in an instance state directory", () => { + const projectRegistry = new RoomRegistry(tempDir, "ctx_test", fixedNow("2026-05-19T10:00:00.000Z")); + projectRegistry.createRoom("legacy"); + + const registryPath = roomRegistryStatePath(join(tempDir, ".contextrelay", "state", "instances", "port-5100")); + const registry = new RoomRegistry(tempDir, "ctx_test_p5100", fixedNow("2026-05-19T10:01:00.000Z"), registryPath); + const data = registry.createRoom("review"); + + expect(registry.path).toBe(registryPath); + expect(data.instanceId).toBe("ctx_test_p5100"); + expect(Object.keys(data.rooms)).toEqual(["review"]); + expect(JSON.parse(readFileSync(roomRegistryPath(tempDir), "utf-8")).instanceId).toBe("ctx_test"); + expect(JSON.parse(readFileSync(registryPath, "utf-8")).instanceId).toBe("ctx_test_p5100"); + }); + + test("creates, enters, completes, and archives lanes", () => { + const registry = new RoomRegistry(tempDir, "ctx_test", fixedNow("2026-05-19T10:00:00.000Z")); + registry.createRoom("release", { coordinatorParticipantId: "default:codex" }); + registry.createLane("release", "tests", { participantId: "side:codex", worktreePath: tempDir }); + registry.enterLane("release", "tests", "side:codex"); + let data = registry.completeLane("release", "tests"); + + expect(data.rooms.release.lanes.tests.lifecycle).toBe("complete"); + expect(data.activeLaneByParticipant["side:codex"]).toMatchObject({ roomId: "release", laneId: "tests" }); + + data = registry.archiveLane("release", "tests").registry; + expect(data.rooms.release.lanes.tests.lifecycle).toBe("archived"); + expect(data.activeLaneByParticipant["side:codex"]).toBeUndefined(); + }); + + test("write-capable lanes cannot share a worktree", () => { + const registry = new RoomRegistry(tempDir, "ctx_test", fixedNow("2026-05-19T10:00:00.000Z")); + registry.createRoom("release"); + registry.createLane("release", "impl", { worktreePath: tempDir }); + + expect(() => registry.createLane("release", "tests", { worktreePath: tempDir })).toThrow("already owns writable worktree"); + }); + + test("identifies active read-only mesh lanes for named runtime sessions", () => { + const registry = new RoomRegistry(tempDir, "ctx_test", fixedNow("2026-05-19T10:00:00.000Z")); + registry.createRoom("review"); + registry.createLane("review", "codex-2", { participantId: "codex-2:codex", permissions: ["read"] }); + let data = registry.enterLane("review", "codex-2", "codex-2:codex"); + + expect(runtimeSessionHasOpenReadOnlyLane(data, "codex-2")).toBe(true); + expect(runtimeSessionHasOpenReadOnlyLane(data, "other")).toBe(false); + + registry.createLane("review", "impl", { participantId: "impl:codex", worktreePath: tempDir }); + data = registry.enterLane("review", "impl", "impl:codex"); + expect(runtimeSessionHasOpenReadOnlyLane(data, "impl")).toBe(false); + }); + + test("stores, decides, and expires lane approvals with reviewer binding", () => { + const registry = new RoomRegistry(tempDir, "ctx_test", fixedNow("2026-05-19T10:00:00.000Z")); + registry.createRoom("release", { coordinatorParticipantId: "default:codex", reviewerParticipantId: "default:claude" }); + registry.createLane("release", "impl", { participantId: "default:codex" }); + + const requested = registry.requestApproval("release", "impl", { + requesterParticipantId: "default:codex", + action: "write", + idempotencyKey: "idem-1", + ttlMs: 1, + }); + expect(requested.replayed).toBe(false); + expect(requested.approval.reviewerParticipantId).toBe("default:claude"); + expect(Object.keys(requested.registry.rooms.release.lanes.impl.pendingApprovals)).toHaveLength(1); + + const replayed = registry.requestApproval("release", "impl", { + requesterParticipantId: "default:codex", + action: "write", + idempotencyKey: "idem-1", + }); + expect(replayed.replayed).toBe(true); + expect(replayed.approval.approvalId).toBe(requested.approval.approvalId); + + expect(() => registry.decideApproval("release", "impl", requested.approval.approvalId, { + reviewerParticipantId: "other:claude", + decision: "approved", + })).toThrow("assigned to default:claude"); + + const decided = registry.decideApproval("release", "impl", requested.approval.approvalId, { + reviewerParticipantId: "default:claude", + decision: "approved", + }); + expect(Object.keys(decided.registry.rooms.release.lanes.impl.pendingApprovals)).toHaveLength(0); + + const expiringRegistry = new RoomRegistry(tempDir, "ctx_test", fixedNow("2026-05-19T10:00:00.000Z")); + expiringRegistry.requestApproval("release", "impl", { + requesterParticipantId: "default:codex", + reviewerParticipantId: "default:claude", + action: "shell", + expiresAt: "2026-05-19T09:59:59.000Z", + }); + const expired = expiringRegistry.expireApprovals(); + expect(expired.expired).toHaveLength(1); + expect(Object.keys(expired.registry.rooms.release.lanes.impl.pendingApprovals)).toHaveLength(0); + }); + + test("foreign room registries are not overwritten", () => { + const registry = new RoomRegistry(tempDir, "ctx_test"); + registry.createRoom("release"); + const path = roomRegistryPath(tempDir); + const data = JSON.parse(readFileSync(path, "utf-8")); + data.instanceId = "ctx_other"; + writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8"); + + const loaded = loadRoomRegistry(tempDir, "ctx_test"); + expect(loaded.issue).toContain("belongs to instance ctx_other"); + expect(loaded.registry.rooms).toEqual({}); + }); +}); + +function fixedNow(iso: string): () => Date { + return () => new Date(iso); +} diff --git a/src/unit-test/session-ledger.test.ts b/src/unit-test/session-ledger.test.ts index eda462b..0d621fd 100644 --- a/src/unit-test/session-ledger.test.ts +++ b/src/unit-test/session-ledger.test.ts @@ -43,6 +43,23 @@ describe("SessionLedger", () => { expect(entries.at(-1)?.content).toContain("auth bug"); }); + test("appendHandoff can write to an explicit transcript session", async () => { + const currentSessionId = ledger.getOrCreateCurrentSessionId(); + const scopedSessionId = "session_1234567890_abcdef12"; + const entry = await ledger.appendHandoff({ + from: "claude", + to: "codex", + reason: "scoped lane", + ask: "work in the scoped transcript", + context_refs: [], + next_speaker: "codex", + }, "claude", 0, undefined, { runtimeSessionId: "side" }, scopedSessionId); + + expect(entry.sessionId).toBe(scopedSessionId); + expect(ledger.read(scopedSessionId).map((item) => item.id)).toContain(entry.id); + expect(ledger.read(currentSessionId).map((item) => item.id)).not.toContain(entry.id); + }); + test("clear removes current session history and leaves a fresh marker", async () => { const first = await ledger.append({ type: "note", diff --git a/src/unit-test/session-registry.test.ts b/src/unit-test/session-registry.test.ts index c2d036a..c39de45 100644 --- a/src/unit-test/session-registry.test.ts +++ b/src/unit-test/session-registry.test.ts @@ -8,6 +8,7 @@ import { inspectSessionRegistry, loadSessionRegistry, sessionRegistryPath, + sessionRegistryStatePath, } from "../session/registry"; describe("SessionRegistry", () => { @@ -47,6 +48,17 @@ describe("SessionRegistry", () => { expect(existsSync(sessionRegistryPath(tempDir))).toBe(true); }); + test("can store runtime session registry in an instance state directory", () => { + const registryPath = sessionRegistryStatePath(join(tempDir, ".contextrelay", "state", "instances", "port-5100")); + const registry = new SessionRegistry(tempDir, "ctx_test_p5100", fixedNow("2026-05-18T10:00:00.000Z"), registryPath); + const data = registry.ensureDefaultSession(); + + expect(registry.path).toBe(registryPath); + expect(data.instanceId).toBe("ctx_test_p5100"); + expect(JSON.parse(readFileSync(registryPath, "utf-8")).instanceId).toBe("ctx_test_p5100"); + expect(existsSync(sessionRegistryPath(tempDir))).toBe(false); + }); + test("corrupt registry falls back to a recreated default registry", () => { const path = sessionRegistryPath(tempDir); mkdirSync(dirname(path), { recursive: true }); @@ -131,6 +143,17 @@ describe("SessionRegistry", () => { expect(existsSync(sessionRegistryPath(tempDir))).toBe(false); }); + test("adds transcriptSessionId without changing runtime session id", () => { + const registry = new SessionRegistry(tempDir, "ctx_test", fixedNow("2026-05-18T10:06:00.000Z")); + registry.createSession("review", { label: "Review" }); + + const data = registry.ensureTranscriptSessionId("review", "session_1770000000000_abcdef12"); + + expect(data.sessions.review.id).toBe("review"); + expect(data.sessions.review.transcriptSessionId).toBe("session_1770000000000_abcdef12"); + expect(inspectSessionRegistry(tempDir, "ctx_test").sessions.find((session) => session.id === "review")?.transcriptSessionId).toBe("session_1770000000000_abcdef12"); + }); + test("read-only inspection lists sessions with active marker", () => { writeJson(sessionRegistryPath(tempDir), { version: 1, diff --git a/src/unit-test/terminal-launcher.test.ts b/src/unit-test/terminal-launcher.test.ts new file mode 100644 index 0000000..76d0b73 --- /dev/null +++ b/src/unit-test/terminal-launcher.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; +import { detectTmux, launchWorkerTerminals, tmuxSessionExists, tmuxSessionIsContextRelayOwned } from "../session/terminal-launcher"; + +describe("worker terminal launcher", () => { + test("skips automatic launch on unsupported platforms", () => { + const result = launchWorkerTerminals(["ctxrelay codex"], { platform: "linux", backend: "terminal-windows" }); + expect(result.ok).toBe(false); + expect(result.launched).toBe(0); + expect(result.skipped).toContain("macOS"); + }); + + test("treats an empty command list as a no-op", () => { + expect(launchWorkerTerminals([], { platform: "linux" })).toEqual({ ok: true, launched: 0 }); + }); + + test("defaults automatic mesh launch to tmux", () => { + const calls: string[][] = []; + const result = launchWorkerTerminals(["ctxrelay codex"], { + roomId: "review", + spawnSyncFn: (_command, args) => { + calls.push(args); + if (args[0] === "has-session") return { status: 1, signal: null }; + return { status: 0, signal: null }; + }, + }); + + expect(result.ok).toBe(true); + expect(result.attachCommand).toBe("tmux attach -t ctxrelay-mesh-review"); + expect(calls.some((args) => args.includes("new-session"))).toBe(true); + }); + + test("counts successful macOS osascript launches without opening Terminal in tests", () => { + const calls: string[][] = []; + const result = launchWorkerTerminals(["ctxrelay codex", "ctxrelay claude"], { + backend: "terminal-windows", + platform: "darwin", + spawnSyncFn: (_command, args) => { + calls.push(args as string[]); + return { status: 0, signal: null }; + }, + }); + + expect(result).toEqual({ ok: true, launched: 2 }); + expect(calls).toHaveLength(2); + expect(calls[0].join("\n")).toContain("ctxrelay codex"); + }); + + test("reports macOS osascript failures instead of claiming terminals opened", () => { + const result = launchWorkerTerminals(["ctxrelay codex"], { + backend: "terminal-windows", + platform: "darwin", + spawnSyncFn: () => ({ status: 1, signal: null }), + }); + + expect(result.ok).toBe(false); + expect(result.launched).toBe(0); + expect(result.error).toContain("osascript exited with code 1"); + }); + + test("print-only backend never launches commands", () => { + const result = launchWorkerTerminals(["ctxrelay codex"], { backend: "print-only" }); + expect(result.ok).toBe(false); + expect(result.skipped).toContain("print-only"); + }); + + test("tmux backend is explicit and marks ContextRelay-owned sessions", () => { + const calls: string[][] = []; + const spawnSyncFn = (_command: string, args: string[]) => { + calls.push(args); + if (args[0] === "has-session") return { status: 1, signal: null }; + return { status: 0, signal: null }; + }; + expect(detectTmux(spawnSyncFn).available).toBe(true); + const result = launchWorkerTerminals(["ctxrelay codex"], { backend: "tmux", roomId: "review", spawnSyncFn }); + expect(result.ok).toBe(true); + expect(calls.some((args) => args.includes("set-environment") && args.includes("CONTEXTRELAY_TMUX_ROOM"))).toBe(true); + expect(tmuxSessionIsContextRelayOwned("review", spawnSyncFn)).toBe(true); + }); + + test("tmux backend skips duplicate launch for existing ContextRelay-owned sessions", () => { + const calls: string[][] = []; + const spawnSyncFn = (_command: string, args: string[]) => { + calls.push(args); + if (args[0] === "has-session") return { status: 0, signal: null }; + if (args[0] === "show-environment") return { status: 0, signal: null }; + return { status: 0, signal: null }; + }; + const result = launchWorkerTerminals(["ctxrelay codex"], { backend: "tmux", roomId: "review", spawnSyncFn }); + + expect(result).toMatchObject({ + ok: true, + launched: 0, + alreadyRunning: true, + sessionName: "ctxrelay-mesh-review", + attachCommand: "tmux attach -t ctxrelay-mesh-review", + }); + expect(calls.some((args) => args.includes("new-session"))).toBe(false); + expect(tmuxSessionExists("review", spawnSyncFn)).toBe(true); + }); + + test("tmux backend refuses existing sessions not owned by ContextRelay", () => { + const calls: string[][] = []; + const spawnSyncFn = (_command: string, args: string[]) => { + calls.push(args); + if (args[0] === "has-session") return { status: 0, signal: null }; + if (args[0] === "show-environment") return { status: 1, signal: null }; + return { status: 0, signal: null }; + }; + const result = launchWorkerTerminals(["ctxrelay codex"], { backend: "tmux", roomId: "review", spawnSyncFn }); + + expect(result.ok).toBe(false); + expect(result.launched).toBe(0); + expect(result.error).toContain("not marked as ContextRelay-created"); + expect(calls.some((args) => args.includes("new-session"))).toBe(false); + }); + + test("tmux backend reports partial window launch failures with attach metadata", () => { + const spawnSyncFn = (_command: string, args: string[]) => { + if (args[0] === "has-session") return { status: 1, signal: null }; + if (args[0] === "new-window") return { status: 1, signal: null }; + return { status: 0, signal: null }; + }; + const result = launchWorkerTerminals(["ctxrelay codex", "ctxrelay claude"], { backend: "tmux", roomId: "review", spawnSyncFn }); + + expect(result.ok).toBe(false); + expect(result.launched).toBe(1); + expect(result.error).toContain("tmux new-window exited with code 1"); + expect(result.sessionName).toBe("ctxrelay-mesh-review"); + expect(result.attachCommand).toBe("tmux attach -t ctxrelay-mesh-review"); + }); +}); diff --git a/src/unit-test/tmux-installer.test.ts b/src/unit-test/tmux-installer.test.ts new file mode 100644 index 0000000..53f5be4 --- /dev/null +++ b/src/unit-test/tmux-installer.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { installTmux, resolveTmuxInstallPlan, type TmuxInstallPlan } from "../session/tmux-installer"; + +describe("tmux installer planner", () => { + test("uses Homebrew on macOS when brew exists", () => { + const result = resolveTmuxInstallPlan({ + platform: "darwin", + spawnSyncFn: (command) => ({ status: command === "brew" ? 0 : 1, signal: null }), + }); + + expect(result.ok).toBe(true); + expect(result.plan?.displayCommand).toBe("brew install tmux"); + }); + + test("uses apt on Linux when apt-get exists", () => { + const result = resolveTmuxInstallPlan({ + platform: "linux", + spawnSyncFn: (command) => ({ status: command === "apt-get" ? 0 : 1, signal: null }), + }); + + expect(result.ok).toBe(true); + expect(result.plan?.manager).toBe("apt"); + expect(result.plan?.displayCommand).toContain("apt-get install -y tmux"); + }); + + test("uses WSL apt on native Windows when WSL exists", () => { + const result = resolveTmuxInstallPlan({ + platform: "win32", + spawnSyncFn: (command) => ({ status: command === "wsl" ? 0 : 1, signal: null }), + }); + + expect(result.ok).toBe(true); + expect(result.plan?.manager).toBe("wsl-apt"); + }); + + test("returns manual guidance when no installer is detected", () => { + const result = resolveTmuxInstallPlan({ + platform: "linux", + spawnSyncFn: () => ({ status: 1, signal: null }), + }); + + expect(result.ok).toBe(false); + expect(result.detail).toContain("No supported Linux package manager"); + }); + + test("runs the selected installer command", () => { + const calls: string[][] = []; + const plan: TmuxInstallPlan = { + manager: "brew", + label: "Homebrew", + command: "brew", + args: ["install", "tmux"], + displayCommand: "brew install tmux", + }; + const result = installTmux(plan, { + spawnSyncFn: (_command, args) => { + calls.push(args); + return { status: 0, signal: null }; + }, + }); + + expect(result.ok).toBe(true); + expect(calls).toEqual([["install", "tmux"]]); + }); +}); diff --git a/src/unit-test/tui.test.ts b/src/unit-test/tui.test.ts index ad4f0f4..6865bc2 100644 --- a/src/unit-test/tui.test.ts +++ b/src/unit-test/tui.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { displayWidth, formatActivityEntry, formatHookCompactionMode, formatRuntimeSessionRows, resolveTuiViewport, shouldUseInteractiveTui, truncateDisplayWidth } from "../cli/tui"; +import { buildMeshLaunchNotice, displayWidth, formatActivityEntry, formatHookCompactionMode, formatMeshRoomRows, formatMeshWizardTerminalMessage, formatRuntimeSessionRows, resolveTuiViewport, shouldUseInteractiveTui, truncateDisplayWidth } from "../cli/tui"; import { DEFAULT_CONFIG } from "../config-service"; describe("TUI terminal detection", () => { @@ -46,6 +46,110 @@ describe("TUI terminal detection", () => { })).toBe("compact"); }); + test("formats mesh room rows from room registry status", () => { + const rows = formatMeshRoomRows({ + meshModeEnabled: true, + roomRegistry: { + activeRoomId: "review", + activeLaneByParticipant: { "default:codex": { roomId: "review", laneId: "frontend", enteredAt: "2026-05-19T10:00:00.000Z" } }, + rooms: [ + { + id: "review", + label: "Review", + lifecycle: "open", + createdAt: "2026-05-19T10:00:00.000Z", + lastSeenAt: "2026-05-19T10:00:00.000Z", + coordinatorParticipantId: "default:codex", + members: ["default:codex"], + activeLaneId: "frontend", + lanes: { + frontend: { + id: "frontend", + label: "Frontend", + lifecycle: "open", + createdAt: "2026-05-19T10:00:00.000Z", + lastSeenAt: "2026-05-19T10:00:00.000Z", + ownerParticipantId: "default:codex", + permissions: { allowed: ["read"] }, + }, + }, + }, + ], + }, + }); + + expect(rows[0].text).toContain("*review open lanes:1/1 lane:frontend"); + expect(rows[0].color).toBe("green"); + expect(rows[1].text).toContain(">frontend open owner:default:codex"); + }); + + test("formats mesh wizard tmux launch result with attach command", () => { + expect(formatMeshWizardTerminalMessage({ + roomId: "review", + backend: "tmux", + terminalResult: { ok: true, launched: 2, sessionName: "ctxrelay-mesh-review", attachCommand: "tmux attach -t ctxrelay-mesh-review" }, + launchCommands: [], + })).toBe("mesh room review ready; tmux session ctxrelay-mesh-review started; attach: tmux attach -t ctxrelay-mesh-review"); + }); + + test("formats already-running tmux sessions with attach command", () => { + expect(formatMeshWizardTerminalMessage({ + roomId: "review", + backend: "tmux", + terminalResult: { ok: true, launched: 0, alreadyRunning: true, sessionName: "ctxrelay-mesh-review", attachCommand: "tmux attach -t ctxrelay-mesh-review" }, + launchCommands: [], + })).toBe("mesh room review ready; tmux session ctxrelay-mesh-review already running; attach: tmux attach -t ctxrelay-mesh-review"); + }); + + test("formats mesh wizard terminal fallback with worker commands", () => { + expect(formatMeshWizardTerminalMessage({ + roomId: "review", + backend: "tmux", + terminalResult: { ok: false, launched: 0, skipped: "tmux is required for automatic mesh launch and is not installed." }, + launchCommands: ["CONTEXTRELAY_ENABLE_MESH=1 contextrelay codex"], + })).toContain("worker commands: CONTEXTRELAY_ENABLE_MESH=1 contextrelay codex"); + }); + + test("formats mesh wizard partial launch failures distinctly from skips", () => { + expect(formatMeshWizardTerminalMessage({ + roomId: "review", + backend: "tmux", + terminalResult: { ok: false, launched: 2, error: "tmux new-window exited with code 1" }, + launchCommands: ["worker 1", "worker 2", "worker 3"], + })).toContain("terminal launch failed (launched 2 of 3): tmux new-window exited with code 1"); + }); + + test("formats mesh wizard non-tmux launch success", () => { + expect(formatMeshWizardTerminalMessage({ + roomId: "review", + backend: "terminal-windows", + terminalResult: { ok: true, launched: 3 }, + launchCommands: [], + })).toBe("mesh room review ready; opened 3 worker terminals"); + }); + + test("builds durable mesh launch notices for attach and manual commands", () => { + expect(buildMeshLaunchNotice({ + roomId: "review", + backend: "tmux", + terminalResult: { ok: true, launched: 0, alreadyRunning: true, sessionName: "ctxrelay-mesh-review", attachCommand: "tmux attach -t ctxrelay-mesh-review" }, + launchCommands: [], + })).toEqual({ + summary: "tmux session ctxrelay-mesh-review already running", + attachCommand: "tmux attach -t ctxrelay-mesh-review", + }); + + expect(buildMeshLaunchNotice({ + roomId: "review", + backend: "tmux", + terminalResult: { ok: false, launched: 0, skipped: "tmux unavailable" }, + launchCommands: ["ctxrelay codex"], + })).toEqual({ + summary: "worker launch skipped; run manually", + workerCommands: ["ctxrelay codex"], + }); + }); + test("truncates activity rows to display width", () => { const entry = { timestamp: Date.UTC(2026, 0, 1, 13, 54, 38), @@ -63,6 +167,17 @@ describe("TUI terminal detection", () => { expect(narrow.endsWith("…")).toBe(true); }); + test("formats mesh scope in activity rows", () => { + const row = formatActivityEntry({ + timestamp: Date.UTC(2026, 0, 1, 13, 54, 38), + type: "route", + content: "route deliver: handoff", + roomId: "review", + laneId: "frontend", + }, 96); + expect(row).toContain("[review/frontend]"); + }); + test("display-width truncation handles emoji, CJK, and ANSI sequences", () => { const value = "\u001b[31mfailed\u001b[0m 🎯 日本語 message that is too long"; const truncated = truncateDisplayWidth(value, 20); diff --git a/src/unit-test/viewer-html.test.ts b/src/unit-test/viewer-html.test.ts new file mode 100644 index 0000000..cc378bc --- /dev/null +++ b/src/unit-test/viewer-html.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { viewerHtml } from "../viewer"; + +describe("viewerHtml", () => { + test("renders read-only mesh panels without browser mesh mutation endpoint", () => { + const html = viewerHtml(); + expect(html).toContain("Mesh Rooms"); + expect(html).toContain("read-only Command Deck"); + expect(html).toContain("data-copy"); + expect(html).not.toContain("/api/viewer/mesh"); + expect(html).not.toContain("method: \"POST\""); + expect(html).not.toContain("method: 'POST'"); + }); +}); diff --git a/src/unit-test/viewer-model.test.ts b/src/unit-test/viewer-model.test.ts index 847be0b..529877b 100644 --- a/src/unit-test/viewer-model.test.ts +++ b/src/unit-test/viewer-model.test.ts @@ -21,8 +21,22 @@ function minimalConfig(): ContextRelayConfig { return { version: "1.0", instanceId: "test-instance", + activeProfile: "reviewer", + profiles: {}, + wizardPresets: { + review: { profile: "reviewer", codexWorkers: 1, claudeWorkers: 1, mode: "read-only" }, + debate: { profile: "critic", codexWorkers: 1, claudeWorkers: 1, mode: "read-only" }, + plan: { profile: "researcher", codexWorkers: 1, claudeWorkers: 1, mode: "read-only" }, + implement: { profile: "builder", codexWorkers: 1, claudeWorkers: 1, mode: "writable" }, + debug: { profile: "tester", codexWorkers: 1, claudeWorkers: 1, mode: "writable" }, + custom: { codexWorkers: 1, claudeWorkers: 1, mode: "read-only" }, + }, + launch: { backend: "terminal-windows" }, stateDir: "/tmp/state", controlPort: 4502, + features: { + meshMode: { enabled: false }, + }, codex: { appPort: 6464, proxyPort: 6465 }, autonomy: { enabled: false, @@ -71,7 +85,7 @@ describe("buildViewerModel", () => { connectionHealth: baseConnectionHealth, }); expect(Object.keys(model).sort()).toEqual( - ["agents", "artifacts", "connectionHealth", "policy", "taskBoard", "timeline"], + ["agents", "artifacts", "connectionHealth", "mesh", "policy", "taskBoard", "timeline"], ); }); @@ -98,10 +112,61 @@ describe("buildViewerModel", () => { expect(keys).toContain("type"); expect(keys).toContain("source"); expect(keys).toContain("content"); + expect(keys).toContain("roomId"); + expect(keys).toContain("laneId"); expect(keys).not.toContain("meta"); expect(keys).not.toContain("caller_agent"); }); + test("exposes mesh model and route scope without leaking arbitrary meta", () => { + const model = buildViewerModel({ + entries: [ + entry({ + id: "route-1", + timestamp: 100, + type: "route", + source: "system", + content: "route deliver: review handoff", + meta: { + roomId: "review", + laneId: "frontend", + traceId: "trace-1", + route: { + traceId: "trace-1", + from: { roomId: "review", laneId: "frontend", participantId: "default:codex" }, + to: { roomId: "review", laneId: "frontend", participantId: "default:claude" }, + decision: "deliver", + reason: "handoff", + timestamp: 100, + }, + hidden: "not exposed", + }, + }), + ], + config: minimalConfig(), + agents: descriptors(), + connectionHealth: baseConnectionHealth, + mesh: { + enabled: true, + configEnabled: true, + envEnabled: true, + roomRegistry: { + activeRoomId: "review", + activeLaneByParticipant: {}, + rooms: [], + }, + }, + }); + + expect(model.mesh).toMatchObject({ enabled: true, configEnabled: true, envEnabled: true }); + const route = (model.timeline as Array>)[0]; + expect(route.roomId).toBe("review"); + expect(route.laneId).toBe("frontend"); + expect(route.traceId).toBe("trace-1"); + expect(route.routeDecision).toMatchObject({ decision: "deliver" }); + expect(route).not.toHaveProperty("meta"); + }); + test("artifacts only include entries that carry an artifact payload", () => { const entries = [ entry({ id: "noart", type: "message", source: "claude", content: "no artifact" }), diff --git a/src/viewer-model.ts b/src/viewer-model.ts index c8e2c01..ffacb6e 100644 --- a/src/viewer-model.ts +++ b/src/viewer-model.ts @@ -3,7 +3,9 @@ import type { ContextRelayConfig } from "./config-service"; import type { AgentDescriptor } from "./agent-descriptors"; import type { LedgerEntry, MessageSource } from "./types"; import { deriveTaskBoard } from "./session/task-board"; +import { deriveWorkerInbox } from "./session/worker-state"; import { buildPolicyState } from "./session/policy"; +import type { RoomRegistryInspection } from "./session/rooms"; export interface ViewerModelInput { entries: LedgerEntry[]; @@ -12,6 +14,12 @@ export interface ViewerModelInput { backupInFlight?: Partial>; lastBackupResult?: BackupResultSummary | null; claudeResponseTimeoutMs?: number; + mesh?: { + enabled: boolean; + configEnabled: boolean; + envEnabled: boolean; + roomRegistry: RoomRegistryInspection; + }; connectionHealth: { bridgeReady: boolean; codexTurnInProgress?: boolean; @@ -44,6 +52,12 @@ export function buildViewerModel(input: ViewerModelInput): Record
+

Mesh Rooms

Loading...

Task Board

Loading...

Timeline

Loading...
@@ -135,8 +141,10 @@ export function viewerHtml(): string { card("Turn Watchdog", status.lastTurnWatchdog ? new Date(status.lastTurnWatchdog.firedAt).toLocaleTimeString() : "none", !status.lastTurnWatchdog), card("Session", status.sessionId || "none"), card("Ledger entries", status.ledgerEntries ?? 0), + card("Mesh", context.mesh?.enabled ? "enabled" : "off", !!context.mesh?.enabled), card("Autonomy", status.autonomyEnabled ? "on" : "off", !!status.autonomyEnabled), ].join(""); + document.getElementById("mesh").innerHTML = renderMesh(context.mesh); document.getElementById("tasks").innerHTML = renderTasks(context.taskBoard); document.getElementById("agents").innerHTML = (context.agents || []).map(renderAgent).join("") || '
No agent descriptors.
'; document.getElementById("artifacts").innerHTML = [...(context.artifacts || [])].sort(descTs).slice(0, 8).map(renderArtifact).join("") || '
No artifacts.
'; @@ -174,6 +182,52 @@ export function viewerHtml(): string { return '
' + esc(lane.status) + ' owner ' + sourceBadge(lane.owner) + handled + ' · ' + esc(lane.id) + '
' + esc(lane.ask) + '
' + evidence + (lane.blocker ? '
' + esc(lane.blocker) + '
' : '') + '
'; }).join(""); } + function renderMesh(mesh) { + if (!mesh) return '
Mesh status is unavailable.
'; + const registry = mesh.roomRegistry || {}; + const rooms = registry.rooms || []; + const gate = mesh.enabled + ? 'enabled' + : 'disabled'; + const config = mesh.configEnabled ? 'config on' : 'config off'; + const warning = registry.warning ? '
' + esc(registry.warning) + '
' : ''; + const header = '
' + gate + ' · ' + esc(config) + ' · read-only Command Deck
'; + const helpCommand = 'ctxrelay room list'; + const help = '
' + esc(helpCommand) + '
'; + if (!rooms.length) { + const create = 'ctxrelay room create review --coordinator default:codex'; + return header + warning + '
No mesh rooms yet.
' + esc(create) + '
'; + } + return header + warning + renderWorkerInbox(mesh.workerInbox || []) + help + rooms.map((room) => renderMeshRoom(room, registry)).join(""); + } + function renderWorkerInbox(workerInbox) { + if (!workerInbox.length) return ''; + return '
Worker inbox
' + + workerInbox.slice(0, 8).map((item) => '
' + esc(item.state) + ' ' + esc(item.roomId + '/' + item.laneId) + '
' + esc(item.why) + '
').join("") + + '
'; + } + function renderMeshRoom(room, registry) { + const active = registry.activeRoomId === room.id ? ' · active' : ''; + const members = (room.members || []).join(", ") || "none"; + const lanes = Object.values(room.lanes || {}).sort((a, b) => String(a.id).localeCompare(String(b.id))); + const roomCommand = 'ctxrelay room select ' + shellArg(room.id); + const laneCreate = 'ctxrelay lane create ' + shellArg(room.id) + ' frontend --worker default:codex'; + return '
' + esc(room.lifecycle) + ' room ' + esc(room.id) + '' + esc(active) + '
' + + '
' + esc('coordinator: ' + (room.coordinatorParticipantId || 'human') + '\\nreviewer: ' + (room.reviewerParticipantId || 'none') + '\\nmembers: ' + members) + '
' + + '
' + esc(roomCommand) + '' + esc(laneCreate) + '
' + + (lanes.length ? lanes.map((lane) => renderMeshLane(room.id, lane, room.activeLaneId)).join("") : '
No lanes in this room.
') + + '
'; + } + function renderMeshLane(roomId, lane, activeLaneId) { + const active = activeLaneId === lane.id ? ' · active' : ''; + const permissions = (lane.permissions?.allowed || []).join(", ") || "read"; + const pendingApprovals = Object.keys(lane.pendingApprovals || {}).length; + const enter = 'ctxrelay lane enter ' + shellArg(roomId) + ' ' + shellArg(lane.id) + ' --worker default:codex'; + const complete = 'ctxrelay lane complete ' + shellArg(roomId) + ' ' + shellArg(lane.id); + return '
' + esc(lane.lifecycle) + ' lane ' + esc(lane.id) + '' + esc(active) + '
' + + '
' + esc('owner: ' + (lane.ownerParticipantId || 'none') + '\\nworktree: ' + (lane.worktreePath || 'none') + '\\npermissions: ' + permissions + '\\npending approvals: ' + pendingApprovals) + '
' + + '
' + esc(enter) + '' + esc(complete) + '
'; + } function renderAgent(agent) { return '
' + sourceBadge(agent.id) + ' · ' + esc(agent.status) + '
' + esc((agent.capabilities || []).join(", ")) + '
'; } @@ -181,14 +235,36 @@ export function viewerHtml(): string { return '
' + esc(new Date(artifact.timestamp).toLocaleString()) + ' · ' + esc(artifact.kind) + ' · ' + esc(artifact.status || "unknown") + '
' + esc(artifact.title + "\\n" + artifact.summary) + '
'; } function renderEntry(entry) { - return '
' + esc(new Date(entry.timestamp).toLocaleString()) + ' · ' + esc(entry.type) + ' · ' + sourceBadge(entry.source) + (entry.target ? ' → ' + sourceBadge(entry.target) : '') + ' · ' + esc(entry.id) + '
' + esc(entry.content || "") + '
'; + const scope = (entry.roomId || entry.laneId) + ? ' · room ' + esc(entry.roomId || '-') + (entry.laneId ? ' · lane ' + esc(entry.laneId) : '') + : ''; + return '
' + esc(new Date(entry.timestamp).toLocaleString()) + ' · ' + esc(entry.type) + ' · ' + sourceBadge(entry.source) + (entry.target ? ' → ' + sourceBadge(entry.target) : '') + scope + ' · ' + esc(entry.id) + '
' + esc(entry.content || "") + '
'; } function sourceBadge(source) { return '' + esc(source) + ''; } + function shellArg(value) { + const text = String(value || ""); + return /^[a-zA-Z0-9_./:-]+$/.test(text) ? text : "'" + text.replace(/'/g, "'\\\\''") + "'"; + } + async function copyCommand(command, button) { + try { + await navigator.clipboard.writeText(command); + const previous = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { button.textContent = previous; }, 1200); + } catch { + window.prompt("Copy command", command); + } + } document.getElementById("refresh").addEventListener("click", load); document.getElementById("mode").addEventListener("click", () => { showAllHistory = !showAllHistory; load(); }); document.getElementById("clear-history").addEventListener("click", clearHistory); + document.addEventListener("click", (event) => { + const button = event.target.closest("button[data-copy]"); + if (!button) return; + copyCommand(button.getAttribute("data-copy") || "", button); + }); if (window.EventSource) { const events = new EventSource("/api/viewer/events"); events.addEventListener("viewer_update", (event) => {