diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e987cec..b64e7b1 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.3", + "version": "1.1.4", "author": { "name": "ProofOfWork / Danillo Felix", "email": "danillo@proofofwork.agency" diff --git a/CHANGELOG.md b/CHANGELOG.md index eea1917..0718b50 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.1.4] — 2026-05-19 + +### Added +- Added explicit first-use named-session creation from either side: + `ctxrelay codex --session ` and `ctxrelay claude --session ` + can create, bind, and launch a named runtime session when + `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1` is set. +- Added a daemon control operation for idempotent named-session registry + ensuring, so first-use creation stays explicit and does not weaken strict + runtime launch validation. +- Added runtime operation-path visibility to status, session list, and the + native TUI, using `path:` wording instead of git-worktree-specific shorthand. +- Added a background-daemon CLI smoke test that exercises project-scoped + commands, session lifecycle commands, named-session first-use notices, + mismatch rejection, archived-session rejection, and the typo cleanup path. + +### Changed +- Reworked the README first-read experience with clearer product positioning, + common workflows, quickstart, multi-session examples, and a visible safety + model while keeping the full command/tool/environment reference searchable. +- Improved duplicate-default Claude attach messaging so users are pointed to + explicit named sessions instead of only being told to kill the current pair. +- Clarified that a session's stored "worktree" is the folder of operation that + ContextRelay validates; ContextRelay does not create git worktrees. +- Preserved the default session's compatibility behavior while allowing + explicit named sessions to be created from either Claude-first or Codex-first + launch flows. + +### Fixed +- Kept typo recovery visible in first-use notices: + `contextrelay kill --session && contextrelay session archive `. + ## [1.1.3] — 2026-05-19 ### Added diff --git a/README.md b/README.md index a3be8b1..87a990e 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,30 @@ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -ContextRelay is a local multi-agent coding orchestrator for Claude Code and Codex. It runs around your repo as a loopback daemon, native terminal dashboard, provider adapters, and durable ledger for handoffs, decisions, artifacts, backup-agent results, and finality proposals. +**Run Claude Code and Codex as one local coding team.** -Use it when one agent should plan or review while another implements, tests, debugs, or gives an independent second opinion. Everything runs on loopback around your repo: local daemon, local state, token-protected endpoints, project-scoped ports, and human-controlled autonomy defaults. ContextRelay is not a provider product, not endorsed by OpenAI or Anthropic, and not an OS sandbox. +ContextRelay is a local multi-agent coding orchestrator for Claude Code and Codex. It gives your repo a loopback daemon, native terminal dashboard, provider adapters, shared ledger, structured handoffs, review loops, backup-agent requests, and opt-in multi-session routing. + +Use it when you want one model to plan or review while another implements, tests, debugs, or gives an independent second opinion. Everything runs around your local project: local daemon, local state, token-protected endpoints, project-scoped ports, and human-controlled autonomy defaults. ContextRelay is not a provider product, not endorsed by OpenAI or Anthropic, and not an OS sandbox. ![ContextRelay native terminal dashboard](docs/assets/contextrelay-tui.svg) -## Start Here +## Why ContextRelay + +Coding with multiple AI assistants is increasingly common, but moving between them is usually copy-paste and lost context. One assistant may be better at architecture review, another may be better at fast implementation, and the human still needs a clear control plane for what happened, who owns the next step, and when the work is actually done. + +ContextRelay turns that into a local workflow instead of a copy-paste ritual: + +- Pair Claude Code and Codex in the same repo with live, token-protected relay paths. +- Ask for structured handoffs instead of vague chat messages. +- Run bounded deliberations when a design choice needs a second opinion. +- Keep a durable ledger of notes, artifacts, test reports, decisions, runtime events, and finality proposals. +- Launch separate named pairs for implementation, review, or debugging lanes from the same project daemon. +- Inspect everything from a native terminal dashboard or a local browser Command Deck. + +Agents share ledger entries and messages, not hidden reasoning. + +## Quickstart ### Prerequisites @@ -20,7 +37,7 @@ Use it when one agent should plan or review while another implements, tests, deb Bun is required because the ContextRelay daemon and plugin server run on Bun. -### Install From npm +### Install ```bash npm install -g @proofofwork-agency/contextrelay @@ -42,7 +59,7 @@ ctxrelay status ctxrelay instances ``` -Stop it: +Stop the project daemon or a named runtime session: ```bash ctxrelay kill @@ -50,7 +67,7 @@ ctxrelay kill --all ctxrelay kill --session ``` -### Install From Source +### Develop From Source ```bash git clone https://github.com/proofofwork-agency/contextrelay.git @@ -65,7 +82,19 @@ ctxrelay codex-mcp install ctxrelay pair ``` -## What You Get +## Common Workflows + +Use ContextRelay for workflows that get awkward in one chat window: + +| Workflow | What happens | +|----------|--------------| +| Planner + builder | Claude can shape the task, Codex can implement, and both can leave durable notes in one ledger. | +| Independent review | One agent can make a change while the other reviews the risk, tests, or architecture with a bounded handoff. | +| Debug lane | Keep the main pair focused while a named session investigates a failure in another checkout folder. | +| Release gate | Record build, test, package, and finality evidence before publishing. | +| Human-controlled autonomy | Read-only backup agents and auto-finality stay off unless explicitly enabled. | + +## Product Surface - Native ContextRelay TUI for agents, session health, launch controls, and fixed hotkeys. - Live Claude Code <-> Codex routing in the same local repo. @@ -77,8 +106,6 @@ ctxrelay pair - Read-only backup agents gated by explicit autonomy settings. - Manual coordinator policy for git ownership: Claude, Codex, or human. -ContextRelay does not share hidden model reasoning. Agents only share what they write through messages, tool calls, and ledger entries. - ## How It Works ```text @@ -100,7 +127,56 @@ The daemon writes session data to: .contextrelay/sessions/.jsonl ``` -## Current Release Highlights +Codex TUI flags are owned by ContextRelay when using `ctxrelay codex`: `--remote` and `--enable tui_app_server`. Claude channel flags are owned by ContextRelay when using `ctxrelay claude`: `--channels` and `--dangerously-load-development-channels`. + +`ctxrelay claude` and `ctxrelay pair` use Claude's development channel path by default because ContextRelay is a custom channel during Claude Code's channel research preview. Normal local installs do not need to set any channel environment variable. Set `CONTEXTRELAY_CLAUDE_DEVELOPMENT_CHANNELS=0` only if `plugin:contextrelay@contextrelay` is available through an approved channel allowlist in your environment. + +## Multi-Session Mode + +Experimental named sessions let one project daemon host more than one Claude+Codex pair. Use them for a main implementation lane plus a review or debugging lane, or to point independent agent conversations at different checkout folders without starting separate ContextRelay daemons. + +The default pair stays simple: + +```bash +ctxrelay claude +ctxrelay codex +``` + +Additional pairs are explicit. The first `--session ` launch creates the session if it does not exist, remembers the current folder as that session's operation path, starts the daemon-side named runtime/proxy, and tells you how to start the other side. + +Codex-first: + +```bash +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay codex --session review +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay claude --session review +``` + +Claude-first: + +```bash +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay claude --session review +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay codex --session review +``` + +Pre-create a session when you want a label or explicit folder: + +```bash +ctxrelay session create review --worktree ../repo-review +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. + +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. + +## 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. + +Provider-native Claude Code and Codex approval systems still apply inside those tools. ContextRelay records mediated permission decisions and runtime events, but it does not replace the provider approval model. + +## What's New - `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. @@ -108,16 +184,21 @@ The daemon writes session data to: - `AGENTS.md` and `CLAUDE.md` managed blocks are audience-specific: Codex files describe Codex tools and fallback markers; Claude files describe Claude plugin tools. - Claude-to-Codex messages sent while Codex is busy are queued instead of failing immediately. - `ctxrelay status` reuses the live daemon port group recorded in local state even when public health omits private daemon details. -- v1.1.3 adds opt-in hook compaction controls through `ctxrelay hook-compaction` and the TUI `Token mode` toggle; `verbose` remains the default and preserves legacy hook output. -- v1.1.x ships opt-in named runtime sessions behind `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`: `ctxrelay session create`, `ctxrelay codex --session `, and `ctxrelay claude --session ` can run independent Claude+Codex pairs inside one daemon. -- Named sessions can be archived, rebound to worktrees, stopped with `ctxrelay kill --session `, guarded against launching two named Codex runtimes on the same bound worktree, and keep runtime identity across Codex MCP relay paths and bridge recovery. +- v1.1.4 ships explicit first-use named runtime sessions behind `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`: `ctxrelay codex --session ` or `ctxrelay claude --session ` can create a named pair inside the same daemon. +- v1.1.4 shows each named session's operation path in status, session list, and the native TUI, and documents the Claude-first and Codex-first launch flows. +- v1.1.3 added opt-in hook compaction controls through `ctxrelay hook-compaction` and the TUI `Token mode` toggle; `verbose` remains the default and preserves legacy hook output. +- Named sessions remember their folder of operation, show that path in runtime status, can be archived, rebound, stopped with `ctxrelay kill --session `, guarded against launching two named Codex runtimes on the same bound folder, and keep runtime identity across Codex MCP relay paths and bridge recovery. - 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. -## Tool Surface +## Reference + +The sections below keep the full command, tool, state, and environment surface searchable from the README. + +### Tool Surface All ContextRelay tool calls exposed by the current codebase are listed here. -### Claude MCP Tools +#### Claude MCP Tools Claude receives these tools from the ContextRelay Claude Code plugin. @@ -141,7 +222,7 @@ 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` | -### Codex MCP Tools +#### Codex MCP Tools Codex receives these tools after: @@ -193,7 +274,7 @@ blocked unknown ``` -### Claude Slash Commands +#### Claude Slash Commands ContextRelay also ships Claude Code slash commands. They are wrappers around the MCP tools above. @@ -206,7 +287,7 @@ ContextRelay also ships Claude Code slash commands. They are wrappers around the | `/contextrelay:deliberate ` | Run one bounded Claude-Codex deliberation and synthesize consensus, disagreement, and next action. | | `/contextrelay:finalize` | Prepare a finality proposal from current ledger evidence. | -### Codex Fallback Markers +#### Codex Fallback Markers When Codex MCP tools are unavailable, Codex can still use exact marker commands at the start of a message: @@ -241,7 +322,7 @@ Read-only backup triggers are explicit and separate: Backup triggers only run when autonomy is enabled. -## CLI Reference +### CLI Reference Use `contextrelay`, `context-relay`, or `ctxrelay`; all point to the same CLI. @@ -251,8 +332,8 @@ Use `contextrelay`, `context-relay`, or `ctxrelay`; all point to the same CLI. | `ctxrelay tui [--no-start] [--force]` | Open the full-screen native terminal dashboard. `--force` renders even when stdin is not detected as an interactive TTY. | | `ctxrelay init [--instructions project|global|both|skip]` | Check dependencies, create project config, install the Claude plugin, and optionally write instruction blocks. | | `ctxrelay dev` | Register the local plugin marketplace and sync the local plugin for development. | -| `ctxrelay claude [--session ] [args...]` | Start Claude Code with the ContextRelay plugin channel enabled. Named sessions require `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1` and a launched runtime. | -| `ctxrelay codex [--session ] [args...]` | Start Codex TUI connected to the ContextRelay daemon and proxy. Named sessions require `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1` and cannot launch concurrently with another named runtime bound to the same worktree. | +| `ctxrelay claude [--session ] [args...]` | Start Claude Code with the ContextRelay plugin channel enabled. Explicit named sessions require `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`; a missing session is created and bound to the current folder on first use. | +| `ctxrelay codex [--session ] [args...]` | Start Codex TUI connected to the ContextRelay daemon and proxy. Explicit named sessions require `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`; a missing session is created and bound to the current folder on first use. | | `ctxrelay codex-mcp install|remove|status|server` | Manage Codex MCP registration. `server` is the stdio entrypoint Codex launches. | | `ctxrelay pair [--dry-run] [--no-tui] [--port-base ]` | Start Claude and Codex in separate terminal sessions, then keep the ContextRelay control TUI open. Use `--no-tui` when launching from an existing dashboard. | | `ctxrelay doctor [--no-auth]` | Diagnose binaries, auth, state, plugin registration, daemon health, tokens, and stale locks. | @@ -290,21 +371,7 @@ The config shape is `turnCoordination.hookCompaction = { mode, previewLimit, pre By default, ContextRelay starts one Claude + Codex pair per project instance. If a pair is already active, `p` and `ctxrelay pair` skip the duplicate launch and record a pair-launch runtime event in the ledger. -Experimental named sessions can run a second independent pair in the same daemon. Create a named session, launch Codex for it, then attach Claude to that session: - -```bash -ctxrelay session create review --worktree ../repo-review -CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay codex --session review -CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay claude --session review -``` - -Use separate worktrees for sessions that can write files. ContextRelay prevents two launched named Codex runtimes from sharing the same bound worktree; the compatibility `default` session is not part of that guard. Named runtime sessions do not provide separate transcript ledgers, backup state, finality state, or viewer history yet. - -Codex TUI flags are owned by ContextRelay when using `ctxrelay codex`: `--remote` and `--enable tui_app_server`. Claude channel flags are owned by ContextRelay when using `ctxrelay claude`: `--channels` and `--dangerously-load-development-channels`. - -`ctxrelay claude` and `ctxrelay pair` use Claude's development channel path by default because ContextRelay is a custom channel during Claude Code's channel research preview. Normal local installs do not need to set any channel environment variable. Set `CONTEXTRELAY_CLAUDE_DEVELOPMENT_CHANNELS=0` only if `plugin:contextrelay@contextrelay` is available through an approved channel allowlist in your environment. - -## Coordinator And Git Policy +### Coordinator And Git Policy The coordinator owns planning, task routing, and git-write responsibility. Default: @@ -334,7 +401,7 @@ ctxrelay coordinator human Restart active Claude/Codex sessions after changing coordinator so startup instructions refresh. -## Permission Policy +### Permission Policy ContextRelay models mediated actions as: @@ -356,7 +423,7 @@ ctxrelay permissions reset Permission decisions are recorded as runtime events in the shared ledger when mediated commands run. -## Safety Defaults +### Safety Defaults - Loopback-only daemon endpoints. - Local auth tokens for control APIs and viewer bootstrap. @@ -369,7 +436,7 @@ Permission decisions are recorded as runtime events in the shared ledger when me - Git writes should be performed only by the coordinator or the human. - Agents share ledger entries and messages, not hidden reasoning. -## State And Ports +### State And Ports Project-local state: @@ -418,7 +485,7 @@ Default first port group: Additional projects increment by 10: `4510/4511/4512`, `4520/4521/4522`, and so on. Use `ctxrelay pair --port-base 4700` to request a specific group. -## Environment Variables +### Environment Variables Most users do not need to set these. The CLI exports project instance values automatically. @@ -476,7 +543,7 @@ Claude Code supplies `CLAUDE_PLUGIN_ROOT` for bundled hook script paths and may Test harnesses use `CONTEXTRELAY_FAKE_DAEMON_LAUNCH_LOG`, `CONTEXTRELAY_FAKE_DAEMON_DELAY_MS`, `CONTEXTRELAY_SHIM_LOG_DIR`, and `CONTEXTRELAY_CODEX_SHIM_HOLD_MS` internally. They are not runtime configuration knobs. -## Viewer And Metrics +### 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. diff --git a/docs/CONTEXTRELAY_V1.md b/docs/CONTEXTRELAY_V1.md index 44aec45..293ee00 100644 --- a/docs/CONTEXTRELAY_V1.md +++ b/docs/CONTEXTRELAY_V1.md @@ -39,12 +39,32 @@ V1 defaults to one Claude + Codex pair per project instance. If either side is already connected, the TUI `p` action and `ctxrelay pair` skip the duplicate launch, surface a reason, and record a concise `pair_launch` runtime event. -v1.1 adds opt-in named runtime sessions inside the same daemon. A named session -can launch its own Codex runtime and attach Claude when -`CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`, and named Codex runtimes are guarded -against sharing a bound worktree. Named sessions do not yet provide independent -transcript ledgers, viewer state, backup state, finality state, or room/mesh -routing. +v1.1.4 adds explicit first-use named runtime sessions inside the same daemon. A named session +can be created by explicit first use from either side: + +```bash +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay claude --session review +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay codex --session review +``` + +or: + +```bash +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay codex --session review +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay claude --session review +``` + +The first launch records the current folder as the session's operation path, +starts the daemon-side named runtime/proxy, and lets the other agent attach +later. This allows a default implementation pair plus a named review/debug pair +to run in one project daemon while keeping live routing and foreground +attachments separate. Runtime status exposes each session's operation path so +the operator can see where that pair is allowed to work. + +Named Codex runtimes are guarded against sharing a bound operation folder. +ContextRelay remembers and validates the folder path but does not create git +worktrees. Named sessions do not yet provide independent transcript ledgers, +viewer state, backup state, finality state, or room/mesh routing. ## External API Workers diff --git a/docs/SESSION-LIFECYCLE.md b/docs/SESSION-LIFECYCLE.md index 2331f1b..5225666 100644 --- a/docs/SESSION-LIFECYCLE.md +++ b/docs/SESSION-LIFECYCLE.md @@ -1,7 +1,7 @@ # Session Lifecycle -This document defines ContextRelay's runtime-session lifecycle. v1.1 implements -registry-backed named sessions plus opt-in named Codex/Claude runtime routing +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. @@ -69,15 +69,43 @@ not daemon process state. Creation paths: - `ctxrelay session create ` creates a named session. -- `ctxrelay codex --session ` launches or attaches the named Codex runtime - when `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. -- `ctxrelay claude --session ` attaches Claude to a launched named runtime - when `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. +- `ctxrelay codex --session ` ensures the named session exists, binds a + missing or unbound session to the current folder, launches or attaches the + named Codex runtime when `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. +- `ctxrelay claude --session ` ensures the named session exists, binds a + missing or unbound session to the current folder, starts the daemon-side named + runtime/proxy, and attaches Claude when + `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. - A control API request creates a session before attaching participants. - A recovery flow can recreate a known session from persisted state. Implicit creation on unknown runtime-session IDs should be rejected for write -paths. Read paths may return a clear "session not found" result. +paths unless the user explicitly supplied `--session ` to a launch command. +Read paths may return a clear "session not found" result. + +The stored `worktreePath` is the session's folder of operation. It can be a git +worktree, a normal clone, or another checkout folder; ContextRelay remembers and +validates the path but does not create git worktrees. + +Typical launch flows: + +```bash +# Default pair. +ctxrelay claude +ctxrelay codex + +# Named pair, Claude first. +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay claude --session review +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay codex --session review + +# Named pair, Codex first. +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay codex --session debug +CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ctxrelay claude --session debug +``` + +`ctxrelay status --json`, the TUI Runtime Sessions panel, and +`ctxrelay session list` expose the operation path for live or registered +sessions so the operator can tell which folder each named pair is working in. ## Active Session @@ -130,7 +158,7 @@ Detach rules: ## Ledger Scope Target model: ledger entries are session-scoped and a session boundary is a -collaboration boundary. Current v1.1.x behavior carries `runtimeSessionId` +collaboration boundary. Current v1.1.4 behavior carries `runtimeSessionId` metadata through named-session routing, but transcript ledger files, backup, viewer, and finality state remain mostly global/default-biased. @@ -239,12 +267,13 @@ separate trust boundary inside one instance. gate is `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`. 11. Enable multiple live sessions behind an opt-in flag only after Claude and Codex runtime attachments are session-scoped end-to-end. With - `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`, `ctxrelay claude --session ` - attaches Claude to an already-launched runtime session and live - `runtimeSessionId` routing targets that session's Claude/Codex attachments. - Live messages never create sessions or runtimes implicitly; a valid but - unlaunched session returns a not-ready error that points the user to - `ctxrelay codex --session `. + `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`, explicit `ctxrelay claude --session + ` and `ctxrelay codex --session ` first use can ensure the registry + session, bind its operation path, and start the daemon-side named + runtime/proxy. Live `runtimeSessionId` routing targets that session's + Claude/Codex attachments. Live messages still never create sessions or + runtimes implicitly; creation is limited to explicit launch commands or + control-plane requests. 12. Add UI affordances for switching active session. The first affordance is CLI/TUI discoverability: `ctxrelay session list|create|select|archive`, offline registry inspection, and a read-only TUI runtime-session panel. diff --git a/package.json b/package.json index 397a7b9..cee7a8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@proofofwork-agency/contextrelay", - "version": "1.1.3", + "version": "1.1.4", "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 962aa72..bb20622 100644 --- a/plugins/contextrelay/.claude-plugin/plugin.json +++ b/plugins/contextrelay/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "contextrelay", - "version": "1.1.3", + "version": "1.1.4", "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 1457f95..4877d02 100755 --- a/plugins/contextrelay/server/bridge-server.js +++ b/plugins/contextrelay/server/bridge-server.js @@ -15808,6 +15808,35 @@ function validateControlServerMessage(value) { return { ok: false, reason: "probe.purpose must be 'claude_liveness'" }; } return { ok: true, message: value }; + case "ensure_session_registry_result": + if (typeof value.requestId !== "string") { + return { ok: false, reason: "ensure_session_registry_result.requestId must be a string" }; + } + if (typeof value.success !== "boolean") { + return { ok: false, reason: "ensure_session_registry_result.success must be a boolean" }; + } + if (value.success === true) { + if (!isObject2(value.session)) { + return { ok: false, reason: "ensure_session_registry_result.session must be an object" }; + } + if (typeof value.session.sessionId !== "string") { + return { ok: false, reason: "ensure_session_registry_result.session.sessionId must be a string" }; + } + if (typeof value.session.created !== "boolean" || typeof value.session.bound !== "boolean") { + return { ok: false, reason: "ensure_session_registry_result.session flags must be booleans" }; + } + if (typeof value.session.worktreePath !== "string") { + return { ok: false, reason: "ensure_session_registry_result.session.worktreePath must be a string" }; + } + return { ok: true, message: value }; + } + if (!isKnownErrorCode(value.code)) { + return { ok: false, reason: "ensure_session_registry_result.code must be a known ErrorCode" }; + } + if (typeof value.error !== "string") { + return { ok: false, reason: "ensure_session_registry_result.error must be a string" }; + } + return { ok: true, message: value }; case "ensure_codex_runtime_result": if (typeof value.requestId !== "string") { return { ok: false, reason: "ensure_codex_runtime_result.requestId must be a string" }; @@ -16035,6 +16064,7 @@ class DaemonClient extends EventEmitter2 { pendingReplies = new Map; pendingRelays = new Map; pendingLedgerTools = new Map; + pendingSessionRegistries = new Map; pendingCodexRuntimes = new Map; pendingCodexRuntimeKills = new Map; constructor(url) { @@ -16103,6 +16133,7 @@ class DaemonClient extends EventEmitter2 { this.rejectPendingReplies("Daemon connection closed"); this.rejectPendingRelays("Daemon connection closed"); this.rejectPendingLedgerTools("Daemon connection closed"); + this.rejectPendingSessionRegistries("Daemon connection closed"); this.rejectPendingCodexRuntimes("Daemon connection closed"); this.rejectPendingCodexRuntimeKills("Daemon connection closed"); } @@ -16140,6 +16171,20 @@ class DaemonClient extends EventEmitter2 { this.send({ type: "ledger_tool", requestId, request }); }); } + async ensureSessionRegistry(sessionId, worktreePath) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return { success: false, code: ErrorCode2.BRIDGE_NOT_READY, error: `${DISPLAY_NAME} daemon is not connected.` }; + } + const requestId = `session_registry_${Date.now()}_${this.nextRequestId++}`; + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingSessionRegistries.delete(requestId); + resolve({ success: false, code: ErrorCode2.TIMEOUT, error: `Timed out waiting for ${DISPLAY_NAME} daemon session registry response.` }); + }, 1e4); + this.pendingSessionRegistries.set(requestId, { resolve, timer }); + this.send({ type: "ensure_session_registry", requestId, sessionId, worktreePath }); + }); + } async ensureCodexRuntime(sessionId) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return { success: false, code: ErrorCode2.BRIDGE_NOT_READY, error: `${DISPLAY_NAME} daemon is not connected.` }; @@ -16238,6 +16283,15 @@ class DaemonClient extends EventEmitter2 { pending.resolve(message.result); return; } + case "ensure_session_registry_result": { + const pending = this.pendingSessionRegistries.get(message.requestId); + if (!pending) + return; + clearTimeout(pending.timer); + this.pendingSessionRegistries.delete(message.requestId); + pending.resolve(message.success ? { success: true, session: message.session } : { success: false, code: message.code, error: message.error }); + return; + } case "ensure_codex_runtime_result": { const pending = this.pendingCodexRuntimes.get(message.requestId); if (!pending) @@ -16273,6 +16327,7 @@ class DaemonClient extends EventEmitter2 { this.rejectPendingReplies(pendingError); this.rejectPendingRelays(pendingError); this.rejectPendingLedgerTools(pendingError); + this.rejectPendingSessionRegistries(pendingError); this.rejectPendingCodexRuntimes(pendingError); this.rejectPendingCodexRuntimeKills(pendingError); if (event.code === CLOSE_CODE_REPLACED || event.code === CLOSE_CODE_EVICTED_STALE) { @@ -16305,6 +16360,13 @@ class DaemonClient extends EventEmitter2 { this.pendingLedgerTools.delete(requestId); } } + rejectPendingSessionRegistries(error2) { + for (const [requestId, pending] of this.pendingSessionRegistries.entries()) { + clearTimeout(pending.timer); + pending.resolve({ success: false, code: ErrorCode2.BRIDGE_NOT_READY, error: error2 }); + this.pendingSessionRegistries.delete(requestId); + } + } rejectPendingCodexRuntimes(error2) { for (const [requestId, pending] of this.pendingCodexRuntimes.entries()) { clearTimeout(pending.timer); @@ -16936,7 +16998,7 @@ function parseDaemonEntryFingerprint(value) { function disabledReplyError(reason) { switch (reason) { case "rejected": - return `${DISPLAY_NAME} rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run \`${PRIMARY_BIN} kill\` to reset.`; + return `${DISPLAY_NAME} rejected this session \u2014 another Claude Code session is already connected to default. Close the other session first, or start a separate named session with \`CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ${PRIMARY_BIN} claude --session \`.`; case "killed": return `${DISPLAY_NAME} is disabled by \`${PRIMARY_BIN} kill\`. Restart Claude Code (\`${PRIMARY_BIN} claude\`), switch to a new conversation, or run \`/resume\` to reconnect.`; } @@ -17021,7 +17083,7 @@ daemonClient.on("rejected", async (code) => { log(`Daemon rejected this session (close code ${code}) \u2014 another Claude session is already connected or was evicted`); daemonDisabled = true; daemonDisabledReason = "rejected"; - const message = code === CLOSE_CODE_EVICTED_STALE ? `\u26A0\uFE0F ${DISPLAY_NAME} daemon evicted this session as stale because a newer Claude Code session took over. The previous bridge stopped answering liveness probes.` : `\u26A0\uFE0F ${DISPLAY_NAME} daemon rejected this session \u2014 another Claude Code session is already connected. Close the other session first, run \`${PRIMARY_BIN} detach-claude\` to clear a stale attachment, or run \`${PRIMARY_BIN} kill\` to reset everything.`; + const message = code === CLOSE_CODE_EVICTED_STALE ? `\u26A0\uFE0F ${DISPLAY_NAME} daemon evicted this session as stale because a newer Claude Code session took over. The previous bridge stopped answering liveness probes.` : `\u26A0\uFE0F ${DISPLAY_NAME} daemon rejected this session \u2014 another Claude Code session is already connected to default. Close the other session first, run \`${PRIMARY_BIN} detach-claude\` to clear a stale attachment, or start a separate named session with \`CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ${PRIMARY_BIN} claude --session \`.`; await claude.pushNotification(systemMessage("system_bridge_replaced", message)); await daemonClient.disconnect(); }); diff --git a/plugins/contextrelay/server/daemon.js b/plugins/contextrelay/server/daemon.js index a222da7..21007b8 100755 --- a/plugins/contextrelay/server/daemon.js +++ b/plugins/contextrelay/server/daemon.js @@ -3161,6 +3161,17 @@ function validateControlClientMessage(value) { return { ok: false, reason: "probe_ack.requestId must be a string" }; } return { ok: true, message: value }; + case "ensure_session_registry": + if (typeof value.requestId !== "string") { + return { ok: false, reason: "ensure_session_registry.requestId must be a string" }; + } + if (typeof value.sessionId !== "string") { + return { ok: false, reason: "ensure_session_registry.sessionId must be a string" }; + } + if (typeof value.worktreePath !== "string") { + return { ok: false, reason: "ensure_session_registry.worktreePath must be a string" }; + } + return { ok: true, message: value }; case "ensure_codex_runtime": if (typeof value.requestId !== "string") { return { ok: false, reason: "ensure_codex_runtime.requestId must be a string" }; @@ -5064,7 +5075,7 @@ function builtInCapabilities(agentId) { } // src/session/registry.ts -import { mkdirSync as mkdirSync5, readFileSync as readFileSync9, rmSync as rmSync3 } from "fs"; +import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync9, rmSync as rmSync3 } from "fs"; import { dirname as dirname3, join as join9 } from "path"; // src/session/worktree.ts @@ -5094,6 +5105,10 @@ function normalizeStoredWorktreePath(value) { return resolved; } } +function isPathWithinOrEqual(path, root) { + const rel = relative(root, path); + return rel === "" || !rel.startsWith("..") && !isAbsolute(rel); +} function findSharedWorktreeRuntimeConflict(options) { if (options.sessionId === DEFAULT_RUNTIME_SESSION_ID) return null; @@ -5212,6 +5227,74 @@ class SessionRegistry { return result.registry; }); } + ensureSession(sessionId, options) { + return this.withLock(() => { + const result = this.load(); + this.lastIssue = result.issue ?? null; + if (isForeignRegistryIssue(result.issue)) { + throw new Error(result.issue); + } + if (sessionId === DEFAULT_RUNTIME_SESSION_ID2) { + throw new Error("Session default already exists and cannot be ensured as a named session."); + } + if (!isValidRuntimeSessionId(sessionId)) { + throw new Error(invalidSessionIdMessage(sessionId)); + } + const nextWorktreePath = canonicalExistingWorktreePath(options.worktreePath); + const now = this.now().toISOString(); + const existing = result.registry.sessions[sessionId]; + if (!existing) { + result.registry.sessions[sessionId] = { + id: sessionId, + label: sessionId, + lifecycle: "active", + createdAt: now, + lastSeenAt: now, + worktreePath: nextWorktreePath, + participants: DEFAULT_PARTICIPANTS + }; + saveSessionRegistry(this.projectRoot, result.registry); + return { + registry: result.registry, + created: true, + bound: true, + worktreePath: nextWorktreePath + }; + } + if (existing.lifecycle === "archived") { + throw new Error(`Cannot launch archived runtime session: ${sessionId}`); + } + if (!existing.worktreePath) { + result.registry.sessions[sessionId] = { + ...existing, + worktreePath: nextWorktreePath, + lastSeenAt: maxIsoTimestamp(existing.lastSeenAt, now) + }; + saveSessionRegistry(this.projectRoot, result.registry); + return { + registry: result.registry, + created: false, + bound: true, + worktreePath: nextWorktreePath + }; + } + const boundPath = normalizeStoredWorktreePath(existing.worktreePath); + if (!boundPath || !existsSync9(boundPath)) { + throw new Error(`Runtime session ${sessionId} is bound to missing worktree ${existing.worktreePath}. Recreate the worktree or archive this session and create a new one.`); + } + if (!isPathWithinOrEqual(nextWorktreePath, boundPath)) { + 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); + return { + registry: result.registry, + created: false, + bound: false, + worktreePath: boundPath + }; + }); + } selectActiveSession(sessionId) { return this.withLock(() => { const result = this.load(); @@ -6387,6 +6470,9 @@ async function handleControlMessage(ws, raw) { case "ledger_tool": handleLedgerTool(ws, message.requestId, message.request); return; + case "ensure_session_registry": + handleEnsureSessionRegistry(ws, message.requestId, message.sessionId, message.worktreePath); + return; case "ensure_codex_runtime": handleEnsureCodexRuntime(ws, message.requestId, message.sessionId); return; @@ -6532,6 +6618,44 @@ async function handleControlMessage(ws, raw) { } } } +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()) { + throw new ControlRequestError(ErrorCode.INVALID_REQUEST, "Named runtime sessions are experimental. Set CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 to enable launch plumbing."); + } + try { + const result = sessionRegistry.ensureSession(sessionId2, { worktreePath }); + return { + sessionId: sessionId2, + created: result.created, + bound: result.bound, + worktreePath: result.worktreePath + }; + } catch (err) { + throw new ControlRequestError(ErrorCode.INVALID_REQUEST, err?.message ?? String(err)); + } +} +async function handleEnsureSessionRegistry(ws, requestId, sessionId2, worktreePath) { + try { + const session = ensureSessionRegistryForLaunch(sessionId2, worktreePath); + sendProtocolMessage(ws, { + type: "ensure_session_registry_result", + requestId, + success: true, + session + }); + } catch (err) { + sendProtocolMessage(ws, { + type: "ensure_session_registry_result", + requestId, + success: false, + code: isControlRequestError(err) ? err.code : ErrorCode.INTERNAL_ERROR, + error: err?.message ?? String(err) + }); + } +} async function handleEnsureCodexRuntime(ws, requestId, sessionId2) { try { const runtime = await ensureCodexRuntime(sessionId2); @@ -7631,13 +7755,14 @@ function buildRuntimeSessionParticipants(session, snapshot = session.tuiConnecti }) ]; } -function buildRuntimeSessionStatus(session, taskBoard) { +function buildRuntimeSessionStatus(session, taskBoard, operationPath) { const snapshot = session.tuiConnectionState.snapshot(); const claudeSnapshot = session.claudeAttachmentState.snapshot(); const queuedMessageCount = runtimeQueuedMessageCount(session); const codexRuntime = session.codexRuntime; return { sessionId: session.id, + ...operationPath ? { operationPath } : {}, bridgeReady: session.tuiConnectionState.canReply(), codexTurnInProgress: codexRuntime.turnInProgress, codexState: currentCodexState(snapshot.tuiConnected, codexRuntime), @@ -7654,10 +7779,10 @@ function buildRuntimeSessionStatus(session, taskBoard) { participants: buildRuntimeSessionParticipants(session, snapshot, claudeSnapshot) }; } -function buildRuntimeSessionStatusMap(taskBoard) { +function buildRuntimeSessionStatusMap(taskBoard, operationPaths) { const statuses = {}; for (const [id, session] of sessions) { - statuses[id] = buildRuntimeSessionStatus(session, taskBoard); + statuses[id] = buildRuntimeSessionStatus(session, taskBoard, operationPaths.get(id)); } return statuses; } @@ -7667,10 +7792,15 @@ function currentStatus() { const taskBoard = deriveTaskBoard(ledger.read(sessionId, 1000), { claudeResponseTimeoutMs: CLAUDE_RESPONSE_TIMEOUT_MS }); const activeSession = activeRuntimeSession(); const activeCodex = activeSession.codexRuntime; - const runtimeSessions = buildRuntimeSessionStatusMap(taskBoard); - const activeSessionStatus = runtimeSessions[activeSession.id] ?? buildRuntimeSessionStatus(activeSession, taskBoard); - const defaultSessionStatus = runtimeSessions[DEFAULT_SESSION_ID] ?? activeSessionStatus; const registryInspection = inspectSessionRegistry(PROJECT_ROOT, INSTANCE_ID); + const operationPaths = new Map([[DEFAULT_SESSION_ID, PROJECT_ROOT]]); + for (const session of registryInspection.sessions) { + if (session.worktreePath) + operationPaths.set(session.id, session.worktreePath); + } + const runtimeSessions = buildRuntimeSessionStatusMap(taskBoard, operationPaths); + const activeSessionStatus = runtimeSessions[activeSession.id] ?? buildRuntimeSessionStatus(activeSession, taskBoard, operationPaths.get(activeSession.id)); + const defaultSessionStatus = runtimeSessions[DEFAULT_SESSION_ID] ?? activeSessionStatus; return { instanceId: INSTANCE_ID, projectRoot: PROJECT_ROOT, diff --git a/src/bridge-disabled-state.ts b/src/bridge-disabled-state.ts index 97c54ed..3352c71 100644 --- a/src/bridge-disabled-state.ts +++ b/src/bridge-disabled-state.ts @@ -5,7 +5,7 @@ export type BridgeDisabledReason = "killed" | "rejected"; export function disabledReplyError(reason: BridgeDisabledReason): string { switch (reason) { case "rejected": - return `${DISPLAY_NAME} rejected this session — another Claude Code session is already connected. Close the other session first, or run \`${PRIMARY_BIN} kill\` to reset.`; + return `${DISPLAY_NAME} rejected this session — another Claude Code session is already connected to default. Close the other session first, or start a separate named session with \`CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ${PRIMARY_BIN} claude --session \`.`; case "killed": return `${DISPLAY_NAME} is disabled by \`${PRIMARY_BIN} kill\`. Restart Claude Code (\`${PRIMARY_BIN} claude\`), switch to a new conversation, or run \`/resume\` to reconnect.`; } diff --git a/src/bridge.ts b/src/bridge.ts index 263cf0e..29134b1 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -120,7 +120,7 @@ daemonClient.on("rejected", async (code) => { daemonDisabledReason = "rejected"; const message = code === CLOSE_CODE_EVICTED_STALE ? `⚠️ ${DISPLAY_NAME} daemon evicted this session as stale because a newer Claude Code session took over. The previous bridge stopped answering liveness probes.` - : `⚠️ ${DISPLAY_NAME} daemon rejected this session — another Claude Code session is already connected. Close the other session first, run \`${PRIMARY_BIN} detach-claude\` to clear a stale attachment, or run \`${PRIMARY_BIN} kill\` to reset everything.`; + : `⚠️ ${DISPLAY_NAME} daemon rejected this session — another Claude Code session is already connected to default. Close the other session first, run \`${PRIMARY_BIN} detach-claude\` to clear a stale attachment, or start a separate named session with \`CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ${PRIMARY_BIN} claude --session \`.`; await claude.pushNotification(systemMessage( "system_bridge_replaced", message, diff --git a/src/cli/claude.ts b/src/cli/claude.ts index ede688a..d4f7a7a 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -5,14 +5,11 @@ import { StateDirResolver } from "../state-dir"; import { DISPLAY_NAME, PRIMARY_BIN } from "../branding"; import { envValue, envValueFrom } from "../env"; import { buildInstanceEnv, resolveProjectInstance } from "../instance"; -import { isForeignRegistryIssue, loadSessionRegistry } from "../session/registry"; -import { validateSessionLaunchRequest } from "../session/runtime-launch"; -import { validateSessionWorktreeLaunch } from "../session/worktree"; +import { ensureNamedSessionRuntimeViaDaemon, maybePrintNamedSessionCreatedNotice } from "./session-runtime"; /** 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 NAMED_SESSION_OPT_IN_ENV = "CONTEXTRELAY_ALLOW_NAMED_SESSIONS"; export async function runClaude(args: string[]) { if (args.includes("--help") || args.includes("-h")) { @@ -39,7 +36,21 @@ export async function runClaude(args: string[]) { const fullArgs = buildClaudeArgs(sessionFlag.args); if (sessionFlag.sessionId) { - validateClaudeRuntimeSession(instance.projectRoot, instance.instanceId, sessionFlag.sessionId); + console.error("[contextrelay] Ensuring daemon is running..."); + try { + await lifecycle.ensureRunning(); + console.error("[contextrelay] Daemon is ready."); + const ensured = await ensureNamedSessionRuntimeViaDaemon({ + lifecycle, + stateDir, + sessionId: sessionFlag.sessionId, + worktreePath: process.cwd(), + }); + maybePrintNamedSessionCreatedNotice(ensured.session, "codex"); + } catch (err: any) { + console.error(`[contextrelay] Failed to ensure named Claude session: ${err.message}`); + process.exit(1); + } } const callerMode = envValue("CONTEXTRELAY_MODE"); @@ -74,7 +85,8 @@ Usage: Starts Claude Code with ContextRelay channel flags injected automatically. ContextRelay owns --channels and --dangerously-load-development-channels. -Named sessions require CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 and a launched Codex runtime. +Named sessions require CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1. A missing named +session is created and bound to the current folder on first use. Use the native "claude" command directly when you need full control over those flags. `.trim()); } @@ -106,32 +118,6 @@ export function parseClaudeSessionFlag(args: string[]): { sessionId?: string; ar return { sessionId, args: remaining }; } -function validateClaudeRuntimeSession(projectRoot: string, instanceId: string, runtimeSessionId: string): void { - const registry = loadSessionRegistry(projectRoot, instanceId); - if (isForeignRegistryIssue(registry.issue)) { - console.error(`[contextrelay] ${registry.issue}`); - process.exit(1); - } - const validation = validateSessionLaunchRequest({ - sessionId: runtimeSessionId, - optInEnabled: process.env[NAMED_SESSION_OPT_IN_ENV] === "1", - registry: registry.registry, - }); - if (!validation.ok) { - console.error(`[contextrelay] ${validation.error}`); - process.exit(1); - } - const worktreeValidation = validateSessionWorktreeLaunch({ - registry: registry.registry, - sessionId: runtimeSessionId, - command: "claude", - }); - if (!worktreeValidation.ok) { - console.error(`[contextrelay] ${worktreeValidation.error}`); - process.exit(1); - } -} - export function buildClaudeArgs(args: string[], env: NodeJS.ProcessEnv = process.env): string[] { // Channel entry format: "server:" for MCP-based channels, // or "plugin:@" for plugin-based channels. diff --git a/src/cli/codex.ts b/src/cli/codex.ts index 06ffb12..2629e67 100644 --- a/src/cli/codex.ts +++ b/src/cli/codex.ts @@ -5,17 +5,14 @@ import { StateDirResolver } from "../state-dir"; import { DaemonLifecycle } from "../daemon-lifecycle"; import { checkOwnedFlagConflicts } from "./claude"; import { DISPLAY_NAME, PRIMARY_BIN } from "../branding"; -import { appendLocalAuthToken, readLocalAuthToken } from "../local-auth"; +import { readLocalAuthToken } from "../local-auth"; import { buildInstanceEnv, resolveProjectInstance } from "../instance"; -import { DaemonClient } from "../daemon-client"; -import { DEFAULT_RUNTIME_SESSION_ID, isForeignRegistryIssue, loadSessionRegistry } from "../session/registry"; -import { validateSessionLaunchRequest } from "../session/runtime-launch"; -import { validateSessionWorktreeLaunch } from "../session/worktree"; +import { DEFAULT_RUNTIME_SESSION_ID } from "../session/registry"; +import { ensureNamedSessionRuntimeViaDaemon, maybePrintNamedSessionCreatedNotice } from "./session-runtime"; /** Flags that ContextRelay owns for codex command. */ const OWNED_FLAGS = ["--remote", "--remote-auth-token-env"]; const REMOTE_AUTH_TOKEN_ENV = "CONTEXTRELAY_CODEX_PROXY_TOKEN"; -const NAMED_SESSION_OPT_IN_ENV = "CONTEXTRELAY_ALLOW_NAMED_SESSIONS"; const BRIDGE_FLAGS = [ "--enable", "tui_app_server", "--remote", @@ -116,7 +113,7 @@ export async function runCodex(args: string[]) { let proxyUrl: string; let tuiStateDir = stateDir; if (namedSession) { - const runtime = await ensureNamedRuntimeViaDaemon(lifecycle, stateDir, instance, runtimeSessionId); + const runtime = await ensureNamedRuntimeViaDaemon(lifecycle, stateDir, runtimeSessionId); proxyUrl = runtime.proxyUrl; tuiStateDir = new StateDirResolver(join(stateDir.dir, "runtime-sessions", runtimeSessionId)); tuiStateDir.ensure(); @@ -246,7 +243,8 @@ Usage: Starts Codex TUI connected through the ContextRelay daemon. ContextRelay owns --remote, --remote-auth-token-env, and --enable tui_app_server. -Named sessions require CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 and an existing registry session. +Named sessions require CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1. A missing named +session is created and bound to the current folder on first use. Non-TUI Codex subcommands such as exec, review, login, logout, mcp, completion, sandbox, and proto are passed through without bridge flags. `.trim()); @@ -282,53 +280,20 @@ export function parseCodexSessionFlag(args: string[]): { sessionId?: string; arg async function ensureNamedRuntimeViaDaemon( lifecycle: DaemonLifecycle, stateDir: StateDirResolver, - instance: Awaited>, runtimeSessionId: string, ): Promise<{ proxyUrl: string }> { - const registry = loadSessionRegistry(instance.projectRoot, instance.instanceId); - if (isForeignRegistryIssue(registry.issue)) { - console.error(`[contextrelay] ${registry.issue}`); - process.exit(1); - } - const validation = validateSessionLaunchRequest({ - sessionId: runtimeSessionId, - optInEnabled: process.env[NAMED_SESSION_OPT_IN_ENV] === "1", - registry: registry.registry, - }); - if (!validation.ok) { - console.error(`[contextrelay] ${validation.error}`); - process.exit(1); - } - const worktreeValidation = validateSessionWorktreeLaunch({ - registry: registry.registry, - sessionId: runtimeSessionId, - command: "codex", - }); - if (!worktreeValidation.ok) { - console.error(`[contextrelay] ${worktreeValidation.error}`); - process.exit(1); - } - - const controlToken = readLocalAuthToken(stateDir, "control"); - if (!controlToken) { - console.error(`[contextrelay] Control auth token was not found. Try: ${PRIMARY_BIN} kill && ${PRIMARY_BIN} codex`); - process.exit(1); - } - - const client = new DaemonClient(appendLocalAuthToken(lifecycle.controlWsUrl, controlToken)); try { - await client.connect(); - const result = await client.ensureCodexRuntime(runtimeSessionId); - if (!result.success) { - console.error(`[contextrelay] ${result.error ?? "Failed to launch named Codex runtime."}`); - process.exit(1); - } + const result = await ensureNamedSessionRuntimeViaDaemon({ + lifecycle, + stateDir, + sessionId: runtimeSessionId, + worktreePath: process.cwd(), + }); + maybePrintNamedSessionCreatedNotice(result.session, "claude"); return { proxyUrl: result.runtime.proxyUrl }; } catch (err: any) { console.error(`[contextrelay] Failed to ensure named Codex runtime: ${err.message}`); process.exit(1); - } finally { - await client.disconnect(); } } diff --git a/src/cli/session-runtime.ts b/src/cli/session-runtime.ts new file mode 100644 index 0000000..e88f6e2 --- /dev/null +++ b/src/cli/session-runtime.ts @@ -0,0 +1,57 @@ +import { DaemonClient } from "../daemon-client"; +import { DaemonLifecycle } from "../daemon-lifecycle"; +import { appendLocalAuthToken, readLocalAuthToken } from "../local-auth"; +import type { CodexRuntimeLaunchInfo, SessionRegistryEnsureInfo } from "../control-protocol"; +import { PRIMARY_BIN } from "../branding"; +import { StateDirResolver } from "../state-dir"; + +export interface NamedSessionRuntimeOptions { + lifecycle: DaemonLifecycle; + stateDir: StateDirResolver; + sessionId: string; + worktreePath?: string; +} + +export async function ensureNamedSessionRuntimeViaDaemon( + options: NamedSessionRuntimeOptions, +): Promise<{ session: SessionRegistryEnsureInfo; runtime: CodexRuntimeLaunchInfo }> { + const controlToken = readLocalAuthToken(options.stateDir, "control"); + if (!controlToken) { + throw new Error(`Control auth token was not found. Try: ${PRIMARY_BIN} kill && ${PRIMARY_BIN} codex`); + } + + const client = new DaemonClient(appendLocalAuthToken(options.lifecycle.controlWsUrl, controlToken)); + try { + await client.connect(); + const ensuredSession = await client.ensureSessionRegistry(options.sessionId, options.worktreePath ?? process.cwd()); + if (!ensuredSession.success) { + throw new Error(ensuredSession.error ?? "Failed to ensure named session registry entry."); + } + + const runtime = await client.ensureCodexRuntime(options.sessionId); + if (!runtime.success) { + throw new Error(runtime.error ?? "Failed to launch named Codex runtime."); + } + + return { + session: ensuredSession.session, + runtime: runtime.runtime, + }; + } finally { + await client.disconnect(); + } +} + +export function maybePrintNamedSessionCreatedNotice( + session: SessionRegistryEnsureInfo, + otherCommand: "claude" | "codex", +): void { + if (!session.created && !session.bound) return; + const verb = session.created ? "Created" : "Bound"; + console.error(`[contextrelay] ${verb} session ${session.sessionId} bound to ${session.worktreePath}.`); + console.error("[contextrelay] Start the other side with:"); + console.error(`[contextrelay] CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 ${PRIMARY_BIN} ${otherCommand} --session ${session.sessionId}`); + console.error("[contextrelay]"); + console.error("[contextrelay] If this was a typo:"); + console.error(`[contextrelay] ${PRIMARY_BIN} kill --session ${session.sessionId} && ${PRIMARY_BIN} session archive ${session.sessionId}`); +} diff --git a/src/cli/session.ts b/src/cli/session.ts index e72c3a6..00bcf83 100644 --- a/src/cli/session.ts +++ b/src/cli/session.ts @@ -255,7 +255,8 @@ function formatSessionLine(session: SessionInspection, runtime?: NonNullable, limit = 4) const label = session.label && session.label !== session.id && !(session.id === "default" && session.label === "Default") ? ` ${truncate(session.label, 14)}` : ""; - const worktree = session.worktreePath ? ` wt:${formatCompactWorktreePath(session.worktreePath, 18)}` : ""; + const operationPath = runtime?.operationPath ?? session.worktreePath; + const worktree = operationPath ? ` path:${formatCompactWorktreePath(operationPath, 18)}` : ""; return { id: session.id, color: session.isActive ? "green" : runtime ? "white" : "gray", diff --git a/src/control-protocol.ts b/src/control-protocol.ts index fde9b9c..8efea17 100644 --- a/src/control-protocol.ts +++ b/src/control-protocol.ts @@ -71,6 +71,7 @@ export interface DaemonStatus { export interface DaemonSessionStatus { sessionId: string; + operationPath?: string; bridgeReady: boolean; codexTurnInProgress?: boolean; codexState?: "idle" | "busy" | "stale" | "offline"; @@ -101,6 +102,13 @@ export interface CodexRuntimeKillInfo { killed: boolean; } +export interface SessionRegistryEnsureInfo { + sessionId: string; + created: boolean; + bound: boolean; + worktreePath: string; +} + 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 } @@ -124,6 +132,7 @@ export type ControlClientMessage = | { type: "claude_connect"; deliveryMode?: "push" | "pull"; runtimeSessionId?: string } | { type: "claude_disconnect" } | { type: "probe_ack"; requestId: string } + | { type: "ensure_session_registry"; requestId: string; sessionId: string; worktreePath: string } | { type: "ensure_codex_runtime"; requestId: string; sessionId: string } | { type: "kill_codex_runtime"; requestId: string; sessionId: string } | { type: "claude_to_codex"; requestId: string; message: BridgeMessage; requireReply?: boolean; runtimeSessionId?: string } @@ -134,6 +143,8 @@ export type ControlClientMessage = export type ControlServerMessage = | { type: "codex_to_claude"; message: BridgeMessage } | { type: "probe"; requestId: string; purpose: "claude_liveness" } + | { type: "ensure_session_registry_result"; requestId: string; success: true; session: SessionRegistryEnsureInfo } + | { type: "ensure_session_registry_result"; requestId: string; success: false; code: ErrorCode; error: string } | { type: "ensure_codex_runtime_result"; requestId: string; success: true; runtime: CodexRuntimeLaunchInfo } | { type: "ensure_codex_runtime_result"; requestId: string; success: false; code: ErrorCode; error: string } | { type: "kill_codex_runtime_result"; requestId: string; success: true; result: CodexRuntimeKillInfo } @@ -239,6 +250,17 @@ export function validateControlClientMessage(value: unknown): ValidationResult { timer: ReturnType; } >(); + private pendingSessionRegistries = new Map< + string, + { + resolve: (value: { success: true; session: SessionRegistryEnsureInfo } | { success: false; code?: ErrorCode; error?: string }) => void; + timer: ReturnType; + } + >(); private pendingCodexRuntimes = new Map< string, { @@ -134,6 +141,7 @@ export class DaemonClient extends EventEmitter { this.rejectPendingReplies("Daemon connection closed"); this.rejectPendingRelays("Daemon connection closed"); this.rejectPendingLedgerTools("Daemon connection closed"); + this.rejectPendingSessionRegistries("Daemon connection closed"); this.rejectPendingCodexRuntimes("Daemon connection closed"); this.rejectPendingCodexRuntimeKills("Daemon connection closed"); } @@ -178,6 +186,23 @@ export class DaemonClient extends EventEmitter { }); } + async ensureSessionRegistry(sessionId: string, worktreePath: string): Promise<{ success: true; session: SessionRegistryEnsureInfo } | { success: false; code?: ErrorCode; error?: string }> { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return { success: false, code: ErrorCode.BRIDGE_NOT_READY, error: `${DISPLAY_NAME} daemon is not connected.` }; + } + + const requestId = `session_registry_${Date.now()}_${this.nextRequestId++}`; + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingSessionRegistries.delete(requestId); + resolve({ success: false, code: ErrorCode.TIMEOUT, error: `Timed out waiting for ${DISPLAY_NAME} daemon session registry response.` }); + }, 10000); + + this.pendingSessionRegistries.set(requestId, { resolve, timer }); + this.send({ type: "ensure_session_registry", requestId, sessionId, worktreePath }); + }); + } + async ensureCodexRuntime(sessionId: string): Promise<{ success: true; runtime: CodexRuntimeLaunchInfo } | { success: false; code?: ErrorCode; error?: string }> { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return { success: false, code: ErrorCode.BRIDGE_NOT_READY, error: `${DISPLAY_NAME} daemon is not connected.` }; @@ -293,6 +318,18 @@ export class DaemonClient extends EventEmitter { pending.resolve(message.result); return; } + case "ensure_session_registry_result": { + const pending = this.pendingSessionRegistries.get(message.requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingSessionRegistries.delete(message.requestId); + pending.resolve( + message.success + ? { success: true, session: message.session } + : { success: false, code: message.code, error: message.error }, + ); + return; + } case "ensure_codex_runtime_result": { const pending = this.pendingCodexRuntimes.get(message.requestId); if (!pending) return; @@ -335,6 +372,7 @@ export class DaemonClient extends EventEmitter { this.rejectPendingReplies(pendingError); this.rejectPendingRelays(pendingError); this.rejectPendingLedgerTools(pendingError); + this.rejectPendingSessionRegistries(pendingError); this.rejectPendingCodexRuntimes(pendingError); this.rejectPendingCodexRuntimeKills(pendingError); if (event.code === CLOSE_CODE_REPLACED || event.code === CLOSE_CODE_EVICTED_STALE) { @@ -376,6 +414,14 @@ export class DaemonClient extends EventEmitter { } } + private rejectPendingSessionRegistries(error: string) { + for (const [requestId, pending] of this.pendingSessionRegistries.entries()) { + clearTimeout(pending.timer); + pending.resolve({ success: false, code: ErrorCode.BRIDGE_NOT_READY, error }); + this.pendingSessionRegistries.delete(requestId); + } + } + private rejectPendingCodexRuntimes(error: string) { for (const [requestId, pending] of this.pendingCodexRuntimes.entries()) { clearTimeout(pending.timer); diff --git a/src/daemon.ts b/src/daemon.ts index 542990d..c4e7894 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -975,6 +975,9 @@ async function handleControlMessage(ws: ServerWebSocket, raw: case "ledger_tool": void handleLedgerTool(ws, message.requestId, message.request); return; + case "ensure_session_registry": + void handleEnsureSessionRegistry(ws, message.requestId, message.sessionId, message.worktreePath); + return; case "ensure_codex_runtime": void handleEnsureCodexRuntime(ws, message.requestId, message.sessionId); return; @@ -1125,6 +1128,55 @@ async function handleControlMessage(ws: ServerWebSocket, raw: } } +function ensureSessionRegistryForLaunch(sessionId: string, worktreePath: string): { sessionId: string; created: boolean; bound: boolean; worktreePath: string } { + if (sessionId === DEFAULT_SESSION_ID) { + throw new ControlRequestError(ErrorCode.INVALID_REQUEST, "Default runtime session cannot be ensured as a named session."); + } + if (!optInNamedSessionsEnabled()) { + throw new ControlRequestError( + ErrorCode.INVALID_REQUEST, + "Named runtime sessions are experimental. Set CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 to enable launch plumbing.", + ); + } + + try { + const result = sessionRegistry.ensureSession(sessionId, { worktreePath }); + return { + sessionId, + created: result.created, + bound: result.bound, + worktreePath: result.worktreePath, + }; + } catch (err: any) { + throw new ControlRequestError(ErrorCode.INVALID_REQUEST, err?.message ?? String(err)); + } +} + +async function handleEnsureSessionRegistry( + ws: ServerWebSocket, + requestId: string, + sessionId: string, + worktreePath: string, +) { + try { + const session = ensureSessionRegistryForLaunch(sessionId, worktreePath); + sendProtocolMessage(ws, { + type: "ensure_session_registry_result", + requestId, + success: true, + session, + }); + } catch (err: any) { + sendProtocolMessage(ws, { + type: "ensure_session_registry_result", + requestId, + success: false, + code: isControlRequestError(err) ? err.code : ErrorCode.INTERNAL_ERROR, + error: err?.message ?? String(err), + }); + } +} + async function handleEnsureCodexRuntime( ws: ServerWebSocket, requestId: string, @@ -2355,6 +2407,7 @@ function buildRuntimeSessionParticipants( function buildRuntimeSessionStatus( session: SessionState, taskBoard: ReturnType, + operationPath?: string, ): NonNullable { const snapshot = session.tuiConnectionState.snapshot(); const claudeSnapshot = session.claudeAttachmentState.snapshot(); @@ -2362,6 +2415,7 @@ function buildRuntimeSessionStatus( const codexRuntime = session.codexRuntime; return { sessionId: session.id, + ...(operationPath ? { operationPath } : {}), bridgeReady: session.tuiConnectionState.canReply(), codexTurnInProgress: codexRuntime.turnInProgress, codexState: currentCodexState(snapshot.tuiConnected, codexRuntime), @@ -2379,10 +2433,13 @@ function buildRuntimeSessionStatus( }; } -function buildRuntimeSessionStatusMap(taskBoard: ReturnType): Record> { +function buildRuntimeSessionStatusMap( + taskBoard: ReturnType, + operationPaths: Map, +): Record> { const statuses: Record> = {}; for (const [id, session] of sessions) { - statuses[id] = buildRuntimeSessionStatus(session, taskBoard); + statuses[id] = buildRuntimeSessionStatus(session, taskBoard, operationPaths.get(id)); } return statuses; } @@ -2393,10 +2450,14 @@ function currentStatus(): DaemonStatus { const taskBoard = deriveTaskBoard(ledger.read(sessionId, 1_000), { claudeResponseTimeoutMs: CLAUDE_RESPONSE_TIMEOUT_MS }); const activeSession = activeRuntimeSession(); const activeCodex = activeSession.codexRuntime; - const runtimeSessions = buildRuntimeSessionStatusMap(taskBoard); - const activeSessionStatus = runtimeSessions[activeSession.id] ?? buildRuntimeSessionStatus(activeSession, taskBoard); - const defaultSessionStatus = runtimeSessions[DEFAULT_SESSION_ID] ?? activeSessionStatus; const registryInspection = inspectSessionRegistry(PROJECT_ROOT, INSTANCE_ID); + const operationPaths = new Map([[DEFAULT_SESSION_ID, PROJECT_ROOT]]); + for (const session of registryInspection.sessions) { + if (session.worktreePath) operationPaths.set(session.id, session.worktreePath); + } + const runtimeSessions = buildRuntimeSessionStatusMap(taskBoard, operationPaths); + const activeSessionStatus = runtimeSessions[activeSession.id] ?? buildRuntimeSessionStatus(activeSession, taskBoard, operationPaths.get(activeSession.id)); + const defaultSessionStatus = runtimeSessions[DEFAULT_SESSION_ID] ?? activeSessionStatus; return { instanceId: INSTANCE_ID, projectRoot: PROJECT_ROOT, diff --git a/src/e2e-cli.test.ts b/src/e2e-cli.test.ts index 5527c44..8f68a19 100644 --- a/src/e2e-cli.test.ts +++ b/src/e2e-cli.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { spawn, type ChildProcess } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { createServer } from "node:net"; import { fileURLToPath } from "node:url"; @@ -180,13 +180,22 @@ class CliE2EHarness { args: string[], extraEnv: NodeJS.ProcessEnv, timeoutMs = 20000, + ): Promise { + return this.runCliInCwd(args, this.projectDir, extraEnv, timeoutMs); + } + + async runCliInCwd( + args: string[], + cwd: string, + extraEnv: NodeJS.ProcessEnv = {}, + timeoutMs = 20000, ): Promise { if (args[0] === "codex" || args[0] === "viewer") { await this.releaseDaemonPortReservations(); } const proc = this.spawnProcess(process.execPath, ["run", CLI_PATH, ...args], { - cwd: this.projectDir, + cwd, env: { ...this.env, ...extraEnv, @@ -1023,6 +1032,138 @@ describe("E2E: CLI surface", () => { expect(codexRun?.args[5]).toBe("CONTEXTRELAY_CODEX_PROXY_TOKEN"); }); }, 30000); + + test("runtime CLI smoke covers config, session, and named commands against a background daemon", async () => { + await withHarness(async (harness) => { + await harness.startManagedFakeDaemon(); + const otherWorktree = join(harness.rootDir, "other-worktree"); + mkdirSync(otherWorktree, { recursive: true }); + const scopedEnv: NodeJS.ProcessEnv = { CONTEXTRELAY_PROJECT_ROOT: harness.projectDir }; + + const run = async ( + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, + timeoutMs = 30000, + ) => { + const result = await harness.runCliWithEnv(args, { ...scopedEnv, ...extraEnv }, timeoutMs); + expect(result.code, `${args.join(" ")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + return result; + }; + const failInCwd = async ( + args: string[], + cwd: string, + extraEnv: NodeJS.ProcessEnv = {}, + timeoutMs = 30000, + ) => { + const result = await harness.runCliInCwd(args, cwd, { ...scopedEnv, ...extraEnv }, timeoutMs); + expect(result.code, `${args.join(" ")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`).not.toBe(0); + return result; + }; + + expect(harness.env.PATH?.startsWith(`${harness.binDir}:`)).toBe(true); + expect(harness.env.HOME).toBe(harness.rootDir); + // In a piped test process, status uses the script-friendly JSON path. + // The native dashboard remains the human TTY surface. + expect((await run(["status"])).stdout).toContain('"instanceId": "ctx_e2e"'); + expect((await run(["status", "--json"])).stdout).toContain('"instanceId": "ctx_e2e"'); + expect((await run(["doctor", "--no-auth"])).stdout).toContain("ContextRelay doctor"); + expect((await run(["viewer", "--no-open"])).stdout).toContain("/viewer"); + expect((await run(["recover", "--json"])).stdout).toContain('"liveStatus"'); + + await run(["codex-mcp", "status"]); + await run(["codex-mcp", "install"]); + await run(["codex-mcp", "remove"]); + await run(["pair", "--dry-run"]); + + await run(["instructions", "status", "--scope", "project"]); + await run(["instructions", "remove", "--scope", "project"]); + await run(["instructions", "install", "--scope", "project"]); + + await run(["coordinator", "status"]); + await run(["coordinator", "claude"]); + await run(["coordinator", "codex"]); + await run(["coordinator", "human"]); + + await run(["permissions", "status"]); + await run(["permissions", "readonly", "on"]); + await run(["permissions", "readonly", "off"]); + await run(["permissions", "deny", "external_api"]); + await run(["permissions", "allow", "external_api", "--agent", "reviewer"]); + await run(["permissions", "reset", "--agent", "reviewer"]); + + await run(["autonomy", "status"]); + await run(["autonomy", "on"]); + await run(["autonomy", "off"]); + await run(["finalize", "status"]); + await run(["finalize", "auto"]); + await run(["finalize", "manual"]); + await run(["hook-compaction", "status"]); + await run(["hook-compaction", "compact"]); + await run(["hook-compaction", "set", "--preview-limit", "2", "--preview-chars", "160", "--dedupe-seconds", "30"]); + await run(["hook-compaction", "verbose"]); + + await run(["session", "create", "review", "--label", "Review lane", "--worktree", harness.projectDir]); + expect((await run(["session", "list"])).stdout).toContain("review Review lane"); + expect((await run(["session", "list", "--json"])).stdout).toContain('"id": "review"'); + await run(["session", "select", "review"]); + await run(["session", "rebind", "review", "--worktree", otherWorktree]); + await run(["session", "select", "default"]); + await run(["session", "archive", "review"]); + const archivedSessions = JSON.parse((await run(["session", "list", "--archived", "--json"])).stdout); + const archivedReview = archivedSessions.summary.sessions.find((session: any) => session.id === "review"); + expect(archivedReview?.archived).toBe(true); + + const firstNamedCodex = await run(["codex", "--session", "smoke", "--model", "o3"], { + CONTEXTRELAY_ALLOW_NAMED_SESSIONS: "1", + }); + expect(firstNamedCodex.stderr).toContain("Created session smoke bound to"); + expect(firstNamedCodex.stderr).toContain("CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 contextrelay claude --session smoke"); + expect(firstNamedCodex.stderr).toContain("contextrelay kill --session smoke && contextrelay session archive smoke"); + + const secondNamedCodex = await run(["codex", "--session", "smoke", "--model", "o4"], { + CONTEXTRELAY_ALLOW_NAMED_SESSIONS: "1", + }); + expect(secondNamedCodex.stderr).not.toContain("Created session smoke bound to"); + + const firstNamedClaude = await run(["claude", "--session", "reviewer", "--resume"], { + CONTEXTRELAY_ALLOW_NAMED_SESSIONS: "1", + }); + expect(firstNamedClaude.stderr).toContain("Created session reviewer bound to"); + expect(firstNamedClaude.stderr).toContain("CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1 contextrelay codex --session reviewer"); + + const namedCodexRun = harness + .readShimCalls("codex") + .find((entry) => entry.env?.CONTEXTRELAY_RUNTIME_SESSION_ID === "smoke"); + expect(namedCodexRun?.args).toContain("--remote"); + expect(namedCodexRun?.env?.CONTEXTRELAY_ALLOW_NAMED_SESSIONS).toBe("1"); + + const namedClaudeRun = harness + .readShimCalls("claude") + .find((entry) => entry.env?.CONTEXTRELAY_RUNTIME_SESSION_ID === "reviewer"); + expect(namedClaudeRun?.args).toContain("--resume"); + expect(namedClaudeRun?.env?.CONTEXTRELAY_ALLOW_NAMED_SESSIONS).toBe("1"); + + await run(["session", "create", "mismatch", "--worktree", otherWorktree]); + const mismatch = await failInCwd(["claude", "--session", "mismatch", "--resume"], harness.projectDir, { + CONTEXTRELAY_ALLOW_NAMED_SESSIONS: "1", + }); + expect(mismatch.stderr).toContain("bound to worktree"); + + await run(["session", "create", "archived", "--worktree", harness.projectDir]); + await run(["session", "archive", "archived"]); + const archivedReject = await failInCwd(["claude", "--session", "archived", "--resume"], harness.projectDir, { + CONTEXTRELAY_ALLOW_NAMED_SESSIONS: "1", + }); + expect(archivedReject.stderr).toContain("Cannot launch archived runtime session"); + + expect((await run(["kill", "--session", "smoke"])).stdout).toContain("runtime session smoke stopped"); + await run(["session", "archive", "smoke"]); + const cleanupStatus = JSON.parse((await run(["status", "--json"])).stdout); + expect(JSON.stringify(cleanupStatus.sessions ?? {})).not.toContain("smoke"); + expect((await run(["detach-claude"])).stdout).toContain("No active Claude session was attached."); + expect((await run(["kill"])).stdout).toContain("ContextRelay stopped."); + }); + }, 90000); }); async function withHarness( @@ -1223,6 +1364,8 @@ appendFileSync( cwd: process.cwd(), env: { CONTEXTRELAY_MODE: process.env.CONTEXTRELAY_MODE ?? null, + CONTEXTRELAY_ALLOW_NAMED_SESSIONS: process.env.CONTEXTRELAY_ALLOW_NAMED_SESSIONS ?? null, + CONTEXTRELAY_RUNTIME_SESSION_ID: process.env.CONTEXTRELAY_RUNTIME_SESSION_ID ?? null, }, }) + "\\n", "utf-8", @@ -1280,7 +1423,7 @@ process.exit(0); function buildFakeDaemonScript(): string { return `#!/usr/bin/env bun import { appendFileSync, chmodSync, existsSync, mkdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; const stateDir = process.env.CONTEXTRELAY_STATE_DIR; const controlPort = Number.parseInt(process.env.CONTEXTRELAY_CONTROL_PORT ?? "4502", 10); @@ -1305,6 +1448,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"); function currentDaemonEntry() { const path = process.env.CONTEXTRELAY_DAEMON_ENTRY; @@ -1323,6 +1467,193 @@ function currentDaemonEntry() { } const daemonEntry = currentDaemonEntry(); +const startedAt = new Date().toISOString(); +let activeSessionId = "default"; +const sessions = new Map([ + ["default", { + id: "default", + label: "default", + lifecycle: "active", + createdAt: startedAt, + lastSeenAt: startedAt, + participants: [ + { agentId: "claude", role: "planner-reviewer" }, + { agentId: "codex", role: "coordinator" }, + ], + }], +]); +const runtimeSessions = new Set(); + +function nowIso() { + return new Date().toISOString(); +} + +function inspectSessions() { + return Array.from(sessions.values()).map((session) => ({ + ...session, + archived: session.lifecycle === "archived", + isActive: session.id === activeSessionId, + })); +} + +function sessionSummary() { + return { + sessions: inspectSessions(), + sessionWorktreeWarnings: [], + }; +} + +function persistSessions() { + const record = {}; + for (const [id, session] of sessions.entries()) { + record[id] = session; + } + mkdirSync(dirname(registryFile), { recursive: true }); + writeFileSync(registryFile, JSON.stringify({ + version: 1, + instanceId, + activeSessionId, + sessions: record, + }, null, 2) + "\\n", "utf-8"); +} + +function runtimeStatusForSession(sessionId) { + const session = sessions.get(sessionId); + return { + sessionId, + ...(session?.worktreePath ? { operationPath: session.worktreePath } : {}), + bridgeReady: false, + deliveryMode: "push", + tuiConnected: false, + threadId: null, + queuedMessageCount: 0, + claudeConnected: false, + claudeClientId: null, + claudeReadyState: null, + backupInFlight: {}, + lastBackupResult: null, + }; +} + +function runtimeSessionStatuses() { + const entries = {}; + for (const sessionId of runtimeSessions) { + entries[sessionId] = runtimeStatusForSession(sessionId); + } + return entries; +} + +function ensureFakeSession(sessionId, worktreePath) { + if (sessionId === "default") { + throw new Error("Session default already exists and cannot be ensured as a named session."); + } + const existing = sessions.get(sessionId); + const timestamp = nowIso(); + if (!existing) { + sessions.set(sessionId, { + id: sessionId, + label: sessionId, + lifecycle: "active", + createdAt: timestamp, + lastSeenAt: timestamp, + worktreePath, + participants: [ + { agentId: "claude", role: "planner-reviewer" }, + { agentId: "codex", role: "coordinator" }, + ], + }); + persistSessions(); + return { sessionId, created: true, bound: true, worktreePath }; + } + if (existing.lifecycle === "archived") { + throw new Error(\`Cannot launch archived runtime session: \${sessionId}\`); + } + existing.lastSeenAt = timestamp; + if (!existing.worktreePath) { + existing.worktreePath = worktreePath; + persistSessions(); + return { sessionId, created: false, bound: true, worktreePath }; + } + if (existing.worktreePath !== worktreePath && !worktreePath.startsWith(existing.worktreePath + "/")) { + throw new Error(\`Runtime session \${sessionId} is bound to worktree \${existing.worktreePath}, but the current directory is \${worktreePath}. Run from \${existing.worktreePath} or rebind this session.\`); + } + persistSessions(); + return { sessionId, created: false, bound: false, worktreePath: existing.worktreePath }; +} + +function successfulLedgerResult(extra = {}) { + return { + ok: true, + sessionId: "test-session", + ...extra, + }; +} + +function handleLedgerToolRequest(request) { + const timestamp = nowIso(); + try { + if (request.type === "session_info") { + return successfulLedgerResult({ summary: sessionSummary() }); + } + if (request.type === "create_session") { + if (!request.id || request.id === "default") { + return { ok: false, code: "INVALID_REQUEST", error: "invalid session id" }; + } + sessions.set(request.id, { + id: request.id, + label: request.label ?? request.id, + lifecycle: "active", + createdAt: timestamp, + lastSeenAt: timestamp, + ...(request.worktreePath ? { worktreePath: request.worktreePath } : {}), + participants: [ + { agentId: "claude", role: "planner-reviewer" }, + { agentId: "codex", role: "coordinator" }, + ], + }); + persistSessions(); + return successfulLedgerResult({ summary: sessionSummary() }); + } + if (request.type === "select_session") { + if (!sessions.has(request.id)) { + return { ok: false, code: "INVALID_REQUEST", error: \`Unknown sessionId: \${request.id}\` }; + } + activeSessionId = request.id; + sessions.get(request.id).lastSeenAt = timestamp; + persistSessions(); + return successfulLedgerResult({ summary: sessionSummary() }); + } + if (request.type === "archive_session") { + const session = sessions.get(request.id); + if (!session) { + return { ok: false, code: "INVALID_REQUEST", error: \`Unknown sessionId: \${request.id}\` }; + } + if (activeSessionId === request.id) { + return { ok: false, code: "INVALID_REQUEST", error: \`Cannot archive active session: \${request.id}\` }; + } + const alreadyArchived = session.lifecycle === "archived"; + session.lifecycle = "archived"; + session.archivedAt = timestamp; + session.lastSeenAt = timestamp; + persistSessions(); + return successfulLedgerResult({ summary: sessionSummary(), alreadyArchived }); + } + if (request.type === "rebind_session") { + const session = sessions.get(request.id); + if (!session) { + return { ok: false, code: "INVALID_REQUEST", error: \`Unknown sessionId: \${request.id}\` }; + } + const alreadyBound = session.worktreePath === request.worktreePath; + session.worktreePath = request.worktreePath; + session.lastSeenAt = timestamp; + persistSessions(); + return successfulLedgerResult({ summary: sessionSummary(), alreadyBound }); + } + return successfulLedgerResult({ summary: sessionSummary(), message: "ok" }); + } catch (err) { + return { ok: false, code: "INTERNAL_ERROR", error: err?.message ?? String(err) }; + } +} if (existsSync(killedFile)) { process.exit(0); @@ -1346,6 +1677,7 @@ if (delayMs > 0) { writeFileSync(pidFile, \`\${process.pid}\\n\`, "utf-8"); function currentStatus() { + const sessionsStatus = runtimeSessionStatuses(); return { instanceId, projectRoot, @@ -1365,6 +1697,17 @@ function currentStatus() { claudeReadyState: null, daemonIdentity: "test-daemon-identity", daemonEntry, + sessionId: "test-session", + ledgerEntries: 0, + latestActiveHandoff: null, + autonomyEnabled: false, + autoFinalizeEnabled: false, + backupInFlight: {}, + lastBackupResult: null, + activeSessionId, + sessions: sessionsStatus, + defaultSession: runtimeStatusForSession("default"), + registrySessions: inspectSessions(), }; } @@ -1404,6 +1747,10 @@ const server = Bun.serve({ return Response.json(currentStatus()); } + if (url.pathname === "/detach-claude" && req.method === "POST") { + return Response.json({ detached: false }); + } + if (url.pathname === "/ws" && serverInstance.upgrade(req)) { return undefined; } @@ -1422,6 +1769,68 @@ const server = Bun.serve({ if (message.type === "claude_connect" || message.type === "status") { ws.send(JSON.stringify({ type: "status", status: currentStatus() })); + return; + } + + if (message.type === "ensure_session_registry") { + try { + const session = ensureFakeSession(message.sessionId, message.worktreePath); + ws.send(JSON.stringify({ + type: "ensure_session_registry_result", + requestId: message.requestId, + success: true, + session, + })); + } catch (err) { + ws.send(JSON.stringify({ + type: "ensure_session_registry_result", + requestId: message.requestId, + success: false, + code: "INVALID_REQUEST", + error: err?.message ?? String(err), + })); + } + return; + } + + if (message.type === "ensure_codex_runtime") { + const alreadyRunning = runtimeSessions.has(message.sessionId); + runtimeSessions.add(message.sessionId); + ws.send(JSON.stringify({ + type: "ensure_codex_runtime_result", + requestId: message.requestId, + success: true, + runtime: { + sessionId: message.sessionId, + proxyUrl, + appServerUrl, + proxyPort, + appServerPort: appPort, + alreadyRunning, + }, + })); + return; + } + + if (message.type === "kill_codex_runtime") { + const killed = runtimeSessions.delete(message.sessionId); + ws.send(JSON.stringify({ + type: "kill_codex_runtime_result", + requestId: message.requestId, + success: true, + result: { sessionId: message.sessionId, killed }, + })); + return; + } + + if (message.type === "ledger_tool") { + const request = message.request ?? {}; + const result = handleLedgerToolRequest(request); + ws.send(JSON.stringify({ + type: "ledger_tool_result", + requestId: message.requestId, + result, + })); } }, }, diff --git a/src/session/registry.ts b/src/session/registry.ts index df2ee7b..63f8d40 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path"; import { atomicWriteFile } from "../atomic-write"; import type { AgentId } from "../agents"; import type { SessionParticipantRole } from "../session-participants"; -import { canonicalExistingWorktreePath, normalizeStoredWorktreePath } from "./worktree"; +import { canonicalExistingWorktreePath, isPathWithinOrEqual, normalizeStoredWorktreePath } from "./worktree"; export const SESSION_REGISTRY_VERSION = 1; export const DEFAULT_RUNTIME_SESSION_ID = "default"; @@ -66,6 +66,13 @@ export interface SessionRegistryInspection { warning?: string; } +export interface EnsureSessionResult { + registry: SessionRegistryData; + created: boolean; + bound: boolean; + worktreePath: string; +} + const SESSION_ID_RE = /^[a-z][a-z0-9_-]{1,63}$/; const DEFAULT_PARTICIPANTS: RegisteredSessionParticipant[] = [ { agentId: "claude", role: "planner-reviewer" }, @@ -162,6 +169,80 @@ export class SessionRegistry { }); } + ensureSession(sessionId: string, options: { worktreePath: string }): EnsureSessionResult { + return this.withLock(() => { + const result = this.load(); + this.lastIssue = result.issue ?? null; + if (isForeignRegistryIssue(result.issue)) { + throw new Error(result.issue); + } + if (sessionId === DEFAULT_RUNTIME_SESSION_ID) { + throw new Error("Session default already exists and cannot be ensured as a named session."); + } + if (!isValidRuntimeSessionId(sessionId)) { + throw new Error(invalidSessionIdMessage(sessionId)); + } + + const nextWorktreePath = canonicalExistingWorktreePath(options.worktreePath); + const now = this.now().toISOString(); + const existing = result.registry.sessions[sessionId]; + if (!existing) { + result.registry.sessions[sessionId] = { + id: sessionId, + label: sessionId, + lifecycle: "active", + createdAt: now, + lastSeenAt: now, + worktreePath: nextWorktreePath, + participants: DEFAULT_PARTICIPANTS, + }; + saveSessionRegistry(this.projectRoot, result.registry); + return { + registry: result.registry, + created: true, + bound: true, + worktreePath: nextWorktreePath, + }; + } + + if (existing.lifecycle === "archived") { + throw new Error(`Cannot launch archived runtime session: ${sessionId}`); + } + + if (!existing.worktreePath) { + result.registry.sessions[sessionId] = { + ...existing, + worktreePath: nextWorktreePath, + lastSeenAt: maxIsoTimestamp(existing.lastSeenAt, now), + }; + saveSessionRegistry(this.projectRoot, result.registry); + return { + registry: result.registry, + created: false, + bound: true, + worktreePath: nextWorktreePath, + }; + } + + const boundPath = normalizeStoredWorktreePath(existing.worktreePath); + if (!boundPath || !existsSync(boundPath)) { + throw new Error(`Runtime session ${sessionId} is bound to missing worktree ${existing.worktreePath}. Recreate the worktree or archive this session and create a new one.`); + } + if (!isPathWithinOrEqual(nextWorktreePath, boundPath)) { + 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); + return { + registry: result.registry, + created: false, + bound: false, + worktreePath: boundPath, + }; + }); + } + selectActiveSession(sessionId: string): SessionRegistryData { return this.withLock(() => { const result = this.load(); diff --git a/src/unit-test/bridge-disabled-state.test.ts b/src/unit-test/bridge-disabled-state.test.ts index 85a9081..70cca0d 100644 --- a/src/unit-test/bridge-disabled-state.test.ts +++ b/src/unit-test/bridge-disabled-state.test.ts @@ -11,7 +11,8 @@ describe("bridge disabled-state messaging", () => { const message = disabledReplyError("rejected"); expect(message).toContain("rejected this session"); expect(message).toContain("another Claude Code session is already connected"); - expect(message).toContain("contextrelay kill"); + expect(message).toContain("CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1"); + expect(message).toContain("claude --session "); expect(message).not.toContain("/resume"); }); }); diff --git a/src/unit-test/cli.test.ts b/src/unit-test/cli.test.ts index 8ebf92b..9680b00 100644 --- a/src/unit-test/cli.test.ts +++ b/src/unit-test/cli.test.ts @@ -368,7 +368,7 @@ describe("CLI: session command", () => { }, }, { includeArchived: true }); const text = formatSessionListOutput(output); - expect(text).toContain("side Side work [archived] codex:launched claude:on wt:/repo/main"); + expect(text).toContain("side Side work [archived] codex:launched claude:on path:/repo/main"); expect(text).toContain("select: ctxrelay session select side"); expect(text).toContain("launch: ctxrelay codex --session side && ctxrelay claude --session side"); }); diff --git a/src/unit-test/control-protocol.test.ts b/src/unit-test/control-protocol.test.ts index d12d25e..9613535 100644 --- a/src/unit-test/control-protocol.test.ts +++ b/src/unit-test/control-protocol.test.ts @@ -80,6 +80,16 @@ describe("validateControlClientMessage", () => { expect(r.ok).toBe(true); }); + test("accepts ensure_session_registry requests", () => { + const r = validateControlClientMessage({ + type: "ensure_session_registry", + requestId: "req_session_registry", + sessionId: "side", + worktreePath: "/tmp/project", + }); + expect(r.ok).toBe(true); + }); + test("accepts kill_codex_runtime requests", () => { const r = validateControlClientMessage({ type: "kill_codex_runtime", @@ -311,6 +321,28 @@ describe("validateControlServerMessage", () => { }).ok).toBe(true); }); + test("accepts ensure_session_registry_result envelopes", () => { + expect(validateControlServerMessage({ + type: "ensure_session_registry_result", + requestId: "req_session_registry", + success: true, + session: { + sessionId: "side", + created: true, + bound: true, + worktreePath: "/tmp/project", + }, + }).ok).toBe(true); + + expect(validateControlServerMessage({ + type: "ensure_session_registry_result", + requestId: "req_session_registry", + success: false, + code: ErrorCode.INVALID_REQUEST, + error: "nope", + }).ok).toBe(true); + }); + test("accepts kill_codex_runtime_result envelopes", () => { expect(validateControlServerMessage({ type: "kill_codex_runtime_result", diff --git a/src/unit-test/daemon-client.test.ts b/src/unit-test/daemon-client.test.ts index 44f7ddf..0c09996 100644 --- a/src/unit-test/daemon-client.test.ts +++ b/src/unit-test/daemon-client.test.ts @@ -303,6 +303,60 @@ describe("DaemonClient", () => { } }); + test("ensureSessionRegistry sends request and resolves successful session result", async () => { + onServerMessage = (ws: any, raw: any) => { + const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString()); + if (msg.type === "ensure_session_registry") { + expect(msg.sessionId).toBe("side"); + expect(msg.worktreePath).toBe("/tmp/project"); + ws.send(JSON.stringify({ + type: "ensure_session_registry_result", + requestId: msg.requestId, + success: true, + session: { + sessionId: "side", + created: true, + bound: true, + worktreePath: "/tmp/project", + }, + })); + } + }; + + await client.connect(); + + const result = await client.ensureSessionRegistry("side", "/tmp/project"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.session.created).toBe(true); + expect(result.session.worktreePath).toBe("/tmp/project"); + } + }); + + test("ensureSessionRegistry resolves daemon failures", async () => { + onServerMessage = (ws: any, raw: any) => { + const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString()); + if (msg.type === "ensure_session_registry") { + ws.send(JSON.stringify({ + type: "ensure_session_registry_result", + requestId: msg.requestId, + success: false, + code: "INVALID_REQUEST", + error: "wrong folder", + })); + } + }; + + await client.connect(); + + const result = await client.ensureSessionRegistry("side", "/tmp/project"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe("INVALID_REQUEST"); + expect(result.error).toContain("wrong folder"); + } + }); + test("ensureCodexRuntime resolves daemon failures", async () => { onServerMessage = (ws: any, raw: any) => { const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString()); diff --git a/src/unit-test/session-registry.test.ts b/src/unit-test/session-registry.test.ts index 8ce2666..c2d036a 100644 --- a/src/unit-test/session-registry.test.ts +++ b/src/unit-test/session-registry.test.ts @@ -298,6 +298,49 @@ describe("SessionRegistry", () => { expect(inspection.sessions.find((session) => session.id === "side")?.worktreePath).toBe(realpathSync.native(worktree)); }); + test("ensures named sessions idempotently with worktree binding", () => { + const worktree = join(tempDir, "worktree"); + const nested = join(worktree, "nested"); + mkdirSync(nested, { recursive: true }); + const registry = new SessionRegistry(tempDir, "ctx_test", fixedNow("2026-05-18T10:15:31.000Z")); + registry.ensureDefaultSession(); + + const created = registry.ensureSession("side", { worktreePath: worktree }); + const ensured = new SessionRegistry(tempDir, "ctx_test", fixedNow("2026-05-18T10:15:32.000Z")) + .ensureSession("side", { worktreePath: nested }); + + expect(created.created).toBe(true); + expect(created.bound).toBe(true); + expect(created.worktreePath).toBe(realpathSync.native(worktree)); + expect(ensured.created).toBe(false); + expect(ensured.bound).toBe(false); + expect(ensured.worktreePath).toBe(realpathSync.native(worktree)); + expect(ensured.registry.sessions.side.lastSeenAt).toBe("2026-05-18T10:15:32.000Z"); + }); + + test("ensureSession binds existing unbound sessions and rejects protected cases", () => { + const worktree = join(tempDir, "worktree"); + const other = join(tempDir, "other"); + mkdirSync(worktree); + mkdirSync(other); + const registry = new SessionRegistry(tempDir, "ctx_test", fixedNow("2026-05-18T10:15:33.000Z")); + registry.ensureDefaultSession(); + registry.createSession("side"); + registry.createSession("archived", { worktreePath: worktree }); + registry.archiveSession("archived"); + + const bound = registry.ensureSession("side", { worktreePath: worktree }); + + expect(bound.created).toBe(false); + expect(bound.bound).toBe(true); + expect(bound.worktreePath).toBe(realpathSync.native(worktree)); + expect(() => registry.ensureSession("side", { worktreePath: other })).toThrow("bound to worktree"); + expect(() => registry.ensureSession("default", { worktreePath: worktree })).toThrow("default already exists"); + expect(() => registry.ensureSession("archived", { worktreePath: worktree })).toThrow("Cannot launch archived runtime session"); + expect(() => registry.ensureSession("Bad", { worktreePath: worktree })).toThrow("Invalid sessionId"); + expect(() => registry.ensureSession("newone", { worktreePath: join(tempDir, "missing") })).toThrow("Worktree path does not exist"); + }); + test("rebinds named sessions idempotently while preserving metadata", () => { const first = join(tempDir, "first"); const second = join(tempDir, "second"); diff --git a/src/unit-test/tui.test.ts b/src/unit-test/tui.test.ts index 1134561..ad4f0f4 100644 --- a/src/unit-test/tui.test.ts +++ b/src/unit-test/tui.test.ts @@ -30,7 +30,7 @@ describe("TUI terminal detection", () => { expect(rows.map((row) => row.text)).toEqual([ "1. default (codex, claude)", - "2. *side Side work (codex wait, claude) wt:/repo/main", + "2. *side Side work (codex wait, claude) path:/repo/main", ]); expect(rows[1].color).toBe("green"); });