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.2",
"version": "1.1.3",
"author": {
"name": "ProofOfWork / Danillo Felix",
"email": "danillo@proofofwork.agency"
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ 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.2 ships opt-in named runtime sessions behind `CONTEXTRELAY_ALLOW_NAMED_SESSIONS=1`: `ctxrelay session create`, `ctxrelay codex --session <id>`, and `ctxrelay claude --session <id>` can run independent Claude+Codex pairs inside one daemon.
- 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 <id>`, and `ctxrelay claude --session <id>` can run independent Claude+Codex pairs inside one daemon.
- Named sessions can be archived, rebound to worktrees, stopped with `ctxrelay kill --session <id>`, guarded against launching two named Codex runtimes on the same bound worktree, 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.

Expand Down Expand Up @@ -266,12 +267,24 @@ Use `contextrelay`, `context-relay`, or `ctxrelay`; all point to the same CLI.
| `ctxrelay viewer [--no-open]` | Open the local Command Deck for status, task lanes, artifacts, policy, and timeline. |
| `ctxrelay autonomy on|off|status` | Control whether explicit read-only backup-agent requests may run. |
| `ctxrelay finalize auto|manual|status` | Control whether finality can be recorded automatically. Default: manual. |
| `ctxrelay hook-compaction status|verbose|compact|set` | Configure the UserPromptSubmit hook token mode. `verbose` preserves the current output; `compact` limits repeated pending-message previews. |
| `ctxrelay release-gate [--json]` | Run build/check release readiness and record a `release_gate` artifact. |
| `ctxrelay kill [--all|--session <id>]` | Stop the current project instance, every known instance with `--all`, or only one named session's Codex runtime with `--session <id>`. |

The native TUI uses the full terminal window and keeps the common control-plane actions in one place. The top header shows bridge readiness. The Status panel shows transcript id, daemon pid, port group, ledger count, and queue depth. The Runtime Sessions panel lists named runtime sessions when present. The Agents panel shows Claude and Codex connection state and role. The Controls panel shows coordinator, autonomy, finality, and readonly policy state. The Activity panel shows current handoff state, agent attachment state, and a small capped strip of recent redacted handoffs and blocked or failed runtime events. The live status bar above the footer summarizes readiness, uptime, relay state, agent state, and the latest activity. The footer lists hotkeys as `(r)efresh`, `(p)air`, `(v)iewer`, `(a)uto`, `(f)inal`, `(c)oord`, `(x)readonly`, and `(q)uit`.
The native TUI uses the full terminal window and keeps the common control-plane actions in one place. The top header shows bridge readiness. The Status panel shows transcript id, daemon pid, port group, ledger count, and queue depth. The Runtime Sessions panel lists named runtime sessions when present. The Agents panel shows Claude and Codex connection state and role. The Controls panel shows coordinator, autonomy, finality, readonly policy state, and token mode. The Activity panel shows current handoff state, agent attachment state, and a width-aware capped strip of recent redacted handoffs and blocked or failed runtime events. The live status bar above the footer summarizes readiness, uptime, relay state, agent state, and the latest activity. The footer lists hotkeys as `(r)efresh`, `(p)air`, `(v)iewer`, `(a)uto`, `(f)inal`, `(c)oord`, `(x)readonly`, `(t)token`, and `(q)uit`.

Use `p` to launch the Claude + Codex pair, `v` to open the browser Command Deck, `a` to toggle autonomy, `f` to toggle auto-finality, `c` to cycle the coordinator, and `x` to toggle readonly permission mode. Use `v` or `ctxrelay viewer` for the full timeline.
Use `p` to launch the Claude + Codex pair, `v` to open the browser Command Deck, `a` to toggle autonomy, `f` to toggle auto-finality, `c` to cycle the coordinator, `x` to toggle readonly permission mode, and `t` to switch token mode between `verbose` and `compact`. Use `v` or `ctxrelay viewer` for the full timeline.

Hook compaction controls the pending-message context injected by the Claude `UserPromptSubmit` hook. The default token mode is `verbose`, which preserves the existing five-preview hook output. `compact` is opt-in and uses one preview, 200 preview characters, and a 60-second dedupe window for identical rendered hook output. Advanced users can keep a mode preset but override individual values:

```bash
ctxrelay hook-compaction status
ctxrelay hook-compaction compact
ctxrelay hook-compaction set --preview-limit 2 --preview-chars 240 --dedupe-seconds 45
ctxrelay hook-compaction verbose
```

The config shape is `turnCoordination.hookCompaction = { mode, previewLimit, previewChars, dedupeSeconds }` in `.contextrelay/config.json`. Precedence is environment variable, explicit config field, mode preset, then built-in default.

`contextrelay` and `ctxrelay tui` require an interactive terminal and start the project daemon unless `--no-start` is passed. Use `ctxrelay status --json` for scripts and CI. Use `ctxrelay tui --force` only when you intentionally want to render despite TTY detection.

Expand Down Expand Up @@ -446,6 +459,10 @@ Most users do not need to set these. The CLI exports project instance values aut
| `CONTEXTRELAY_ALLOW_DAEMON_ENTRY_OVERRIDE` | unset | Set to `1` only for local development/tests. |
| `CONTEXTRELAY_HEALTH_HOOK_COOLDOWN_SECONDS` | `120` | Cooldown for bundled Claude health-check hook reminders. |
| `CONTEXTRELAY_HOOK_STATE_DIR` | `${TMPDIR:-/tmp}/contextrelay-hooks` | State directory for bundled hook scripts. |
| `CONTEXTRELAY_HOOK_COMPACT` | unset | Set to `1` to force the compact UserPromptSubmit hook preset for automation. |
| `CONTEXTRELAY_HOOK_PREVIEW_LIMIT` | mode preset | Override pending-message preview count for the UserPromptSubmit hook. Range: `1`-`20`. |
| `CONTEXTRELAY_HOOK_PREVIEW_CHARS` | mode preset | Override pending-message preview character cap for the UserPromptSubmit hook. Range: `40`-`4000`. |
| `CONTEXTRELAY_HOOK_DEDUPE_SECONDS` | mode preset | Override identical hook-output suppression window. Set `0` to disable dedupe. |
| `CONTEXTRELAY_AUTO_REGISTER` | unset | Set to `1` during npm install/postinstall to opt into automatic Claude plugin registration. |
| `CONTEXTRELAY_AUTO_UNREGISTER` | unset | Set to `1` during uninstall/preuninstall to remove Claude plugin registration. |
| `CONTEXTRELAY_POSTINSTALL_DRY_RUN` | unset | Set to `1` to print postinstall/preuninstall actions without changing plugin registration. |
Expand Down
2 changes: 2 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ ctxrelay instances
ctxrelay viewer [--no-open]
ctxrelay autonomy on|off|status
ctxrelay finalize auto|manual|status
ctxrelay hook-compaction status|verbose|compact|set
ctxrelay release-gate [--json]
ctxrelay kill [--all|--session <id>]
```
Expand Down Expand Up @@ -165,6 +166,7 @@ Claude to Codex:
- `require_reply: true` marks the Codex turn as reply-required and bypasses normal status buffering for that turn.
- If Codex is already in a turn, the daemon queues Claude messages and flushes them after Codex completes the current turn.
- The pending Claude-to-Codex injection queue is bounded at 50 entries.
- The Claude `UserPromptSubmit` hook can run in `turnCoordination.hookCompaction.mode = "verbose" | "compact"`; `verbose` preserves legacy pending-message previews, while `compact` is an opt-in lower-token shortcut with explicit per-field overrides.

Codex to Claude:

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.2",
"version": "1.1.3",
"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.2",
"version": "1.1.3",
"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
140 changes: 135 additions & 5 deletions plugins/contextrelay/scripts/peek_codex_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,29 @@
from __future__ import annotations

import argparse
import hashlib
import json
import os
import platform
import sqlite3
import time
from pathlib import Path


VERBOSE_DEFAULTS = {
"mode": "verbose",
"preview_limit": 5,
"preview_chars": 400,
"dedupe_seconds": 0,
}
COMPACT_DEFAULTS = {
"mode": "compact",
"preview_limit": 1,
"preview_chars": 200,
"dedupe_seconds": 60,
}


def state_dir() -> Path:
override = os.environ.get("CONTEXTRELAY_STATE_DIR")
if override:
Expand All @@ -20,7 +36,73 @@ def state_dir() -> Path:
return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "contextrelay"


def peek(limit: int) -> dict:
def project_root() -> Path:
return Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()).resolve()


def project_config() -> dict:
config_path = project_root() / ".contextrelay" / "config.json"
try:
with config_path.open("r", encoding="utf-8") as handle:
raw = json.load(handle)
except Exception:
return {}
turn = raw.get("turnCoordination") if isinstance(raw, dict) else {}
hook = turn.get("hookCompaction") if isinstance(turn, dict) else {}
return hook if isinstance(hook, dict) else {}


def parse_int(value, fallback: int) -> int:
try:
parsed = int(value)
except Exception:
return fallback
return parsed


def clamp(value: int, minimum: int, maximum: int) -> int:
return max(minimum, min(maximum, value))


def truthy(value: str | None) -> bool:
return value is not None and value.lower() in {"1", "true", "yes", "on"}


def hook_settings(cli_limit: int | None = None) -> dict:
config = project_config()
env_overrides = any(
os.environ.get(name)
for name in (
"CONTEXTRELAY_HOOK_COMPACT",
"CONTEXTRELAY_HOOK_PREVIEW_LIMIT",
"CONTEXTRELAY_HOOK_PREVIEW_CHARS",
"CONTEXTRELAY_HOOK_DEDUPE_SECONDS",
)
)
explicit_knobs = any(name in config for name in ("previewLimit", "previewChars", "dedupeSeconds"))
configured_mode = config.get("mode") if config.get("mode") in {"verbose", "compact"} else "verbose"
mode = "compact" if truthy(os.environ.get("CONTEXTRELAY_HOOK_COMPACT")) else configured_mode
preset = COMPACT_DEFAULTS if mode == "compact" else VERBOSE_DEFAULTS
preview_limit = parse_int(os.environ.get("CONTEXTRELAY_HOOK_PREVIEW_LIMIT"), parse_int(config.get("previewLimit"), preset["preview_limit"]))
preview_chars = parse_int(os.environ.get("CONTEXTRELAY_HOOK_PREVIEW_CHARS"), parse_int(config.get("previewChars"), preset["preview_chars"]))
dedupe_seconds = parse_int(os.environ.get("CONTEXTRELAY_HOOK_DEDUPE_SECONDS"), parse_int(config.get("dedupeSeconds"), preset["dedupe_seconds"]))

# Backward compatibility: direct `--limit` continues to drive legacy callers
# unless config/env has opted into the new compaction surface.
if cli_limit is not None and config == {} and not env_overrides:
preview_limit = cli_limit

legacy_defaults = mode == "verbose" and not env_overrides and not explicit_knobs and preview_limit == VERBOSE_DEFAULTS["preview_limit"]
return {
"mode": mode,
"preview_limit": clamp(preview_limit, 1, 20),
"preview_chars": clamp(preview_chars, 40, 4000),
"dedupe_seconds": clamp(dedupe_seconds, 0, 3600),
"legacy_defaults": legacy_defaults,
}


def peek(limit: int, preview_chars: int = 400) -> dict:
db_path = state_dir() / "queue.db"
if not db_path.exists():
return {"pending_count": 0, "messages": []}
Expand Down Expand Up @@ -56,14 +138,55 @@ def peek(limit: int) -> dict:
{
"chat_id": row["chat_id"],
"created_at": row["created_at"],
"preview": content[:400],
"preview": content[:preview_chars],
},
)

return {"pending_count": total, "messages": messages}


def as_hook_output(data: dict) -> dict:
def hook_state_path() -> Path:
workspace_key = hashlib.sha1(str(project_root()).encode("utf-8")).hexdigest()[:16]
return state_dir() / "hooks" / f"userpromptsubmit-{workspace_key}.stamp"


def hook_metrics_path() -> Path:
return state_dir() / "hooks" / "hook_previews_dropped_dedupe.counter"


def increment_hook_metric(reason: str) -> None:
if reason != "dedupe":
return
path = hook_metrics_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
# One byte equals one dropped hook emission; daemon metrics read file size.
with path.open("a", encoding="utf-8") as handle:
handle.write("\n")
except Exception:
pass


def should_suppress(lines: list[str], dedupe_seconds: int) -> bool:
if dedupe_seconds <= 0:
return False
path = hook_state_path()
body_hash = hashlib.sha1("\n".join(lines).encode("utf-8")).hexdigest()
now = int(time.time())
try:
if path.exists():
stamp = json.loads(path.read_text(encoding="utf-8"))
if stamp.get("hash") == body_hash and now - int(stamp.get("epoch") or 0) <= dedupe_seconds:
increment_hook_metric("dedupe")
return True
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"epoch": now, "hash": body_hash}, separators=(",", ":")) + "\n", encoding="utf-8")
except Exception:
return False
return False


def as_hook_output(data: dict, settings: dict) -> dict:
pending = int(data.get("pending_count") or 0)
if pending <= 0:
return {}
Expand All @@ -76,6 +199,12 @@ def as_hook_output(data: dict) -> dict:
preview = str(message.get("preview") or "").replace("\n", " ").strip()
if preview:
lines.append(f"- {preview}")
omitted = pending - len(data.get("messages", []))
if omitted > 0 and not settings.get("legacy_defaults"):
lines.append(f"(+{omitted} more pending)")

if should_suppress(lines, settings["dedupe_seconds"]):
return {}

return {
"hookSpecificOutput": {
Expand All @@ -91,8 +220,9 @@ def main() -> None:
parser.add_argument("--hook", action="store_true")
args = parser.parse_args()

data = peek(max(1, min(20, args.limit)))
output = as_hook_output(data) if args.hook else data
settings = hook_settings(max(1, min(20, args.limit)))
data = peek(settings["preview_limit"], settings["preview_chars"])
output = as_hook_output(data, settings) if args.hook else data
if output:
print(json.dumps(output, separators=(",", ":")))

Expand Down
49 changes: 45 additions & 4 deletions plugins/contextrelay/server/bridge-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -14436,7 +14436,10 @@ var DEFAULT_CONFIG = {
},
turnCoordination: {
attentionWindowSeconds: 15,
bufferStatusDuringAttention: false
bufferStatusDuringAttention: false,
hookCompaction: {
mode: "verbose"
}
},
collaboration: {
coordinator: "claude",
Expand All @@ -14461,6 +14464,7 @@ var PERMISSION_CAPABILITIES = [
];
var CONFIG_DIR = ".contextrelay";
var CONFIG_FILE = "config.json";
var warnedConfigValues = new Set;
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -14488,6 +14492,43 @@ function normalizeBoolean(value, fallback) {
function normalizeCoordinator(value, fallback) {
return value === "claude" || value === "codex" || value === "human" ? value : fallback;
}
function normalizeHookCompactionMode(value, fallback) {
if (value === undefined)
return fallback;
if (value === "verbose" || value === "compact")
return value;
warnInvalidConfigValue("turnCoordination.hookCompaction.mode", value, "verbose or compact", fallback);
return fallback;
}
function warnInvalidConfigValue(path, value, expected, fallback) {
const key = `${path}:${String(value)}`;
if (warnedConfigValues.has(key))
return;
warnedConfigValues.add(key);
console.error(`ContextRelay config warning: ignored ${path}=${JSON.stringify(value)}; expected ${expected}. Using ${JSON.stringify(fallback)}.`);
}
function normalizeOptionalInteger(value) {
if (value === undefined)
return;
const parsed = normalizeInteger(value, Number.NaN);
return Number.isFinite(parsed) ? parsed : undefined;
}
function normalizeHookCompaction(value) {
const raw = isRecord(value) ? value : {};
const normalized = {
mode: normalizeHookCompactionMode(raw.mode, DEFAULT_CONFIG.turnCoordination.hookCompaction.mode)
};
const previewLimit = normalizeOptionalInteger(raw.previewLimit);
const previewChars = normalizeOptionalInteger(raw.previewChars);
const dedupeSeconds = normalizeOptionalInteger(raw.dedupeSeconds);
if (previewLimit !== undefined)
normalized.previewLimit = previewLimit;
if (previewChars !== undefined)
normalized.previewChars = previewChars;
if (dedupeSeconds !== undefined)
normalized.dedupeSeconds = dedupeSeconds;
return normalized;
}
function normalizePermissions(value, fallback) {
if (!Array.isArray(value))
return [...fallback];
Expand Down Expand Up @@ -14537,7 +14578,8 @@ function normalizeConfig(raw) {
},
turnCoordination: {
attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds),
bufferStatusDuringAttention: normalizeBoolean(turnCoordination.bufferStatusDuringAttention, DEFAULT_CONFIG.turnCoordination.bufferStatusDuringAttention)
bufferStatusDuringAttention: normalizeBoolean(turnCoordination.bufferStatusDuringAttention, DEFAULT_CONFIG.turnCoordination.bufferStatusDuringAttention),
hookCompaction: normalizeHookCompaction(turnCoordination.hookCompaction)
},
collaboration: {
coordinator: normalizeCoordinator(collaboration.coordinator, DEFAULT_CONFIG.collaboration.coordinator),
Expand All @@ -14551,7 +14593,6 @@ function normalizeConfig(raw) {
idleShutdownSeconds: normalizeInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds)
};
}

class ConfigService {
configDir;
configPath;
Expand Down Expand Up @@ -14744,7 +14785,7 @@ class ClaudeAdapter extends EventEmitter {
this.maxBufferedMessages = Number.isFinite(configuredMaxBufferedMessages) ? Math.max(1, configuredMaxBufferedMessages) : 100;
this.messageQueue = new PersistentMessageQueue(stateDir.queueDbFile, stateDir.transcriptFile, this.maxBufferedMessages);
const coordinator = new ConfigService().loadOrDefault().collaboration.coordinator;
this.server = new Server({ name: "contextrelay", version: "1.1.2" }, {
this.server = new Server({ name: "contextrelay", version: "1.1.3" }, {
capabilities: {
experimental: { "claude/channel": {} },
tools: {}
Expand Down
Loading
Loading