Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` and `ctxrelay claude --session <id>`
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 <id> && contextrelay session archive <id>`.

## [1.1.3] — 2026-05-19

### Added
Expand Down
149 changes: 108 additions & 41 deletions README.md

Large diffs are not rendered by default.

32 changes: 26 additions & 6 deletions docs/CONTEXTRELAY_V1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 43 additions & 14 deletions docs/SESSION-LIFECYCLE.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -69,15 +69,43 @@ not daemon process state.
Creation paths:

- `ctxrelay session create <name>` creates a named session.
- `ctxrelay codex --session <name>` launches or attaches the named Codex runtime
when `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`.
- `ctxrelay claude --session <name>` attaches Claude to a launched named runtime
when `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`.
- `ctxrelay codex --session <name>` 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 <name>` 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 <name>` 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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 <id>`
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 <id>`.
`CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`, explicit `ctxrelay claude --session
<id>` and `ctxrelay codex --session <id>` 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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/contextrelay/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
66 changes: 64 additions & 2 deletions plugins/contextrelay/server/bridge-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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.` };
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 <name>\`.`;
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.`;
}
Expand Down Expand Up @@ -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 <name>\`.`;
await claude.pushNotification(systemMessage("system_bridge_replaced", message));
await daemonClient.disconnect();
});
Expand Down
Loading
Loading