diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cff9643..e987cec 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.2", + "version": "1.1.3", "author": { "name": "ProofOfWork / Danillo Felix", "email": "danillo@proofofwork.agency" diff --git a/README.md b/README.md index 85d5743..a3be8b1 100644 --- a/README.md +++ b/README.md @@ -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 `, and `ctxrelay claude --session ` 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 `, 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. - 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. @@ -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 ]` | Stop the current project instance, every known instance with `--all`, or only one named session's Codex runtime with `--session `. | -The native TUI uses the full terminal window and keeps the common control-plane actions in one place. The top header shows bridge readiness. The Status panel shows transcript id, daemon pid, port group, ledger count, and queue depth. The Runtime Sessions panel lists named runtime sessions when present. The Agents panel shows Claude and Codex connection state and role. The Controls panel shows coordinator, autonomy, finality, 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. @@ -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. | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c207b22..5ddeac3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 ] ``` @@ -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: diff --git a/package.json b/package.json index a127711..397a7b9 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/plugins/contextrelay/.claude-plugin/plugin.json b/plugins/contextrelay/.claude-plugin/plugin.json index a052139..962aa72 100644 --- a/plugins/contextrelay/.claude-plugin/plugin.json +++ b/plugins/contextrelay/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/contextrelay/scripts/peek_codex_queue.py b/plugins/contextrelay/scripts/peek_codex_queue.py index 3881ac3..fb89a7d 100755 --- a/plugins/contextrelay/scripts/peek_codex_queue.py +++ b/plugins/contextrelay/scripts/peek_codex_queue.py @@ -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: @@ -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": []} @@ -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 {} @@ -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": { @@ -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=(",", ":"))) diff --git a/plugins/contextrelay/server/bridge-server.js b/plugins/contextrelay/server/bridge-server.js index 33f9725..1457f95 100755 --- a/plugins/contextrelay/server/bridge-server.js +++ b/plugins/contextrelay/server/bridge-server.js @@ -14436,7 +14436,10 @@ var DEFAULT_CONFIG = { }, turnCoordination: { attentionWindowSeconds: 15, - bufferStatusDuringAttention: false + bufferStatusDuringAttention: false, + hookCompaction: { + mode: "verbose" + } }, collaboration: { coordinator: "claude", @@ -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); } @@ -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]; @@ -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), @@ -14551,7 +14593,6 @@ function normalizeConfig(raw) { idleShutdownSeconds: normalizeInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds) }; } - class ConfigService { configDir; configPath; @@ -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: {} diff --git a/plugins/contextrelay/server/daemon.js b/plugins/contextrelay/server/daemon.js index 5edda1d..a222da7 100755 --- a/plugins/contextrelay/server/daemon.js +++ b/plugins/contextrelay/server/daemon.js @@ -2,8 +2,8 @@ // @bun // src/daemon.ts -import { appendFileSync as appendFileSync2, realpathSync as realpathSync4, statSync as statSync3 } from "fs"; -import { join as join9, resolve as resolve4 } from "path"; +import { appendFileSync as appendFileSync2, realpathSync as realpathSync4, statSync as statSync4 } from "fs"; +import { join as join10, resolve as resolve4 } from "path"; import { fileURLToPath as fileURLToPath3 } from "url"; // src/codex-adapter.ts @@ -2866,7 +2866,10 @@ var DEFAULT_CONFIG = { }, turnCoordination: { attentionWindowSeconds: 15, - bufferStatusDuringAttention: false + bufferStatusDuringAttention: false, + hookCompaction: { + mode: "verbose" + } }, collaboration: { coordinator: "claude", @@ -2891,6 +2894,7 @@ var PERMISSION_CAPABILITIES = [ ]; var CONFIG_DIR = ".contextrelay"; var CONFIG_FILE = "config.json"; +var warnedConfigValues = new Set; function isRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -2918,6 +2922,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 = isRecord2(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]; @@ -2967,7 +3008,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), @@ -2981,7 +3023,6 @@ function normalizeConfig(raw) { idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds) }; } - class ConfigService { configDir; configPath; @@ -4829,6 +4870,30 @@ function escapeLabelValue(value) { return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, "\\\""); } +// src/hook-metrics.ts +import { existsSync as existsSync7, statSync as statSync3 } from "fs"; +import { join as join7 } from "path"; +function renderHookMetrics(stateDir) { + const path = join7(stateDir, "hooks", "hook_previews_dropped_dedupe.counter"); + const dedupe = readAppendOnlyCounter(path); + return [ + "# HELP contextrelay_hook_previews_dropped_total Number of UserPromptSubmit hook preview emissions dropped by reason.", + "# TYPE contextrelay_hook_previews_dropped_total counter", + `contextrelay_hook_previews_dropped_total{reason="dedupe"} ${dedupe}`, + "" + ].join(` +`); +} +function readAppendOnlyCounter(path) { + try { + if (!existsSync7(path)) + return 0; + return statSync3(path).size; + } catch { + return 0; + } +} + // src/codex-injection-queue.ts class CodexInjectionQueue { maxItems; @@ -5000,18 +5065,18 @@ function builtInCapabilities(agentId) { // src/session/registry.ts import { mkdirSync as mkdirSync5, readFileSync as readFileSync9, rmSync as rmSync3 } from "fs"; -import { dirname as dirname3, join as join8 } from "path"; +import { dirname as dirname3, join as join9 } from "path"; // src/session/worktree.ts -import { existsSync as existsSync7, realpathSync as realpathSync3 } from "fs"; -import { isAbsolute, join as join7, relative, resolve as resolve3, sep as sep2 } from "path"; +import { existsSync as existsSync8, realpathSync as realpathSync3 } from "fs"; +import { isAbsolute, join as join8, relative, resolve as resolve3, sep as sep2 } from "path"; var DEFAULT_RUNTIME_SESSION_ID = "default"; function canonicalExistingWorktreePath(path) { const trimmed = path.trim(); if (!trimmed) throw new Error("worktreePath must not be empty."); const resolved = resolve3(trimmed); - if (!existsSync7(resolved)) { + if (!existsSync8(resolved)) { throw new Error(`Worktree path does not exist: ${resolved}`); } return realpathSync3.native(resolved); @@ -5074,7 +5139,7 @@ class SessionRegistry { this.instanceId = instanceId; this.now = now; this.path = sessionRegistryPath(projectRoot); - this.lockPath = join8(dirname3(this.path), ".sessions.lock"); + this.lockPath = join9(dirname3(this.path), ".sessions.lock"); } load() { return loadSessionRegistry(this.projectRoot, this.instanceId, this.now); @@ -5275,7 +5340,7 @@ class SessionRegistry { } } function sessionRegistryPath(projectRoot = process.cwd()) { - return join8(projectRoot, ".contextrelay", SESSION_REGISTRY_FILE); + return join9(projectRoot, ".contextrelay", SESSION_REGISTRY_FILE); } function loadSessionRegistry(projectRoot, instanceId, now = () => new Date) { const path = sessionRegistryPath(projectRoot); @@ -5767,7 +5832,7 @@ function createDefaultSessionState(id, codexRuntime) { return state; } function namedRuntimeStateDir(id) { - const dir = id === DEFAULT_SESSION_ID ? stateDir.dir : join9(stateDir.dir, "runtime-sessions", id); + const dir = id === DEFAULT_SESSION_ID ? stateDir.dir : join10(stateDir.dir, "runtime-sessions", id); const resolver = new StateDirResolver(dir); resolver.ensure(); return resolver; @@ -6114,7 +6179,7 @@ function startControlServer() { if (!hasValidLocalAuthToken(req, localAuthToken)) { return new Response("Unauthorized", { status: 401 }); } - return new Response(metrics.renderPrometheusText(), { + return new Response(metrics.renderPrometheusText() + renderHookMetrics(stateDir.dir), { headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8", "cache-control": "no-store" @@ -7718,7 +7783,8 @@ function toRecentActivityEntry(entry) { source: entry.source, target: entry.target, content: entry.content, - status: typeof entry.meta?.runtime_event_status === "string" ? entry.meta.runtime_event_status : typeof entry.meta?.artifact_status === "string" ? entry.meta.artifact_status : undefined + status: typeof entry.meta?.runtime_event_status === "string" ? entry.meta.runtime_event_status : typeof entry.meta?.artifact_status === "string" ? entry.meta.artifact_status : undefined, + runtimeSessionId: typeof entry.meta?.runtimeSessionId === "string" ? entry.meta.runtimeSessionId : undefined }; } function isLiveActivityEntry(entry) { @@ -7849,7 +7915,7 @@ function writeStatusFile() { function currentDaemonEntrySnapshot() { const path = fileURLToPath3(import.meta.url); try { - const stat = statSync3(path); + const stat = statSync4(path); return { path, realpath: realpathSync4.native(path), diff --git a/scripts/verify-docs-sync.cjs b/scripts/verify-docs-sync.cjs index b6a0de3..d5aba71 100644 --- a/scripts/verify-docs-sync.cjs +++ b/scripts/verify-docs-sync.cjs @@ -36,6 +36,7 @@ const cliSurfaces = [ { label: "ctxrelay status json", tokens: ["ctxrelay status [--json]"] }, { label: "ctxrelay recover json", tokens: ["ctxrelay recover [--json]"] }, { label: "ctxrelay viewer no-open", tokens: ["ctxrelay viewer [--no-open]"] }, + { label: "ctxrelay hook-compaction actions", tokens: ["ctxrelay hook-compaction status|verbose|compact|set"] }, { label: "ctxrelay release-gate json", tokens: ["ctxrelay release-gate [--json]"] }, { label: "ctxrelay kill all", tokens: ["ctxrelay kill [--all|--session ]"] }, ]; @@ -121,6 +122,10 @@ const publicEnvVars = [ "CONTEXTRELAY_DAEMON_SHUTDOWN_STEP_TIMEOUT_MS", "CONTEXTRELAY_FILTER_MODE", "CONTEXTRELAY_HEALTH_HOOK_COOLDOWN_SECONDS", + "CONTEXTRELAY_HOOK_COMPACT", + "CONTEXTRELAY_HOOK_DEDUPE_SECONDS", + "CONTEXTRELAY_HOOK_PREVIEW_CHARS", + "CONTEXTRELAY_HOOK_PREVIEW_LIMIT", "CONTEXTRELAY_HOOK_STATE_DIR", "CONTEXTRELAY_IDLE_SHUTDOWN_MS", "CONTEXTRELAY_INSTANCE_ID", diff --git a/src/claude-adapter.ts b/src/claude-adapter.ts index babc783..aa7e8ec 100644 --- a/src/claude-adapter.ts +++ b/src/claude-adapter.ts @@ -152,7 +152,7 @@ export class ClaudeAdapter extends EventEmitter { const coordinator = new ConfigService().loadOrDefault().collaboration.coordinator; this.server = new Server( - { name: "contextrelay", version: "1.1.2" }, + { name: "contextrelay", version: "1.1.3" }, { capabilities: { experimental: { "claude/channel": {} }, diff --git a/src/cli.ts b/src/cli.ts index 60cddc9..7d4a9eb 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,6 +23,7 @@ * contextrelay viewer — Open the browser Command Deck * contextrelay autonomy — Enable/disable agent backup autonomy * contextrelay finalize — Configure finality acceptance mode + * contextrelay hook-compaction — Configure UserPromptSubmit hook token mode * contextrelay release-gate — Run local release readiness checks and record a report * contextrelay kill — Force kill all ContextRelay processes */ @@ -119,6 +120,10 @@ async function main() { const { runFinalize } = await import("./cli/finalize"); await runFinalize(restArgs); break; + case "hook-compaction": + const { runHookCompaction } = await import("./cli/hook-compaction"); + await runHookCompaction(restArgs); + break; case "release-gate": const { runReleaseGateCommand } = await import("./cli/release-gate"); await runReleaseGateCommand(restArgs); @@ -186,6 +191,8 @@ Commands: Enable or disable read-only backup-agent autonomy finalize auto|manual|status Configure whether Claude may auto-finalize + hook-compaction status|verbose|compact|set + Configure UserPromptSubmit hook token mode release-gate [--json] Run build/check release readiness and record a ledger report kill [--all|--session ] @@ -221,6 +228,7 @@ Examples: ${SHORT_BIN} viewer # Open the browser Command Deck ${SHORT_BIN} autonomy on # Allow explicit read-only backup agents ${SHORT_BIN} finalize auto # Allow Claude to auto-finalize + ${SHORT_BIN} hook-compaction compact # Reduce repeated hook context ${SHORT_BIN} release-gate # Run release readiness checks ${SHORT_BIN} kill # Stop this project instance ${SHORT_BIN} kill --all # Emergency: stop all known instances diff --git a/src/cli/hook-compaction.ts b/src/cli/hook-compaction.ts new file mode 100644 index 0000000..162681f --- /dev/null +++ b/src/cli/hook-compaction.ts @@ -0,0 +1,133 @@ +import { PRIMARY_BIN } from "../branding"; +import { + ConfigService, + resolveHookCompactionConfig, + type ContextRelayConfig, + type HookCompactionMode, +} from "../config-service"; + +const MODES: HookCompactionMode[] = ["verbose", "compact"]; + +export async function runHookCompaction(args: string[]) { + const action = args[0] ?? "status"; + if (action === "--help" || action === "-h") { + printHookCompactionHelp(); + return; + } + + const service = new ConfigService(); + const config = service.loadOrDefault(); + + switch (action) { + case "status": + printStatus(config); + return; + case "verbose": + case "compact": + config.turnCoordination.hookCompaction.mode = action; + service.save(config); + console.log(`ContextRelay token mode set to ${action}.`); + return; + case "set": + applySetArgs(config, args.slice(1)); + service.save(config); + console.log("ContextRelay hook compaction settings updated."); + printStatus(config); + return; + default: + console.error(`Unknown hook-compaction action: ${action}`); + printHookCompactionHelp(console.error); + process.exit(1); + } +} + +function printStatus(config: ContextRelayConfig): void { + console.log(JSON.stringify({ + configured: config.turnCoordination.hookCompaction, + effective: resolveHookCompactionConfig(config), + }, null, 2)); +} + +function applySetArgs(config: ContextRelayConfig, args: string[]): void { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--mode") { + config.turnCoordination.hookCompaction.mode = parseMode(readValue(args, ++i, arg)); + continue; + } + if (arg.startsWith("--mode=")) { + config.turnCoordination.hookCompaction.mode = parseMode(arg.slice("--mode=".length)); + continue; + } + if (arg === "--preview-limit") { + config.turnCoordination.hookCompaction.previewLimit = parseBoundedInteger(readValue(args, ++i, arg), arg, 1, 20); + continue; + } + if (arg.startsWith("--preview-limit=")) { + config.turnCoordination.hookCompaction.previewLimit = parseBoundedInteger(arg.slice("--preview-limit=".length), "--preview-limit", 1, 20); + continue; + } + if (arg === "--preview-chars") { + config.turnCoordination.hookCompaction.previewChars = parseBoundedInteger(readValue(args, ++i, arg), arg, 40, 4000); + continue; + } + if (arg.startsWith("--preview-chars=")) { + config.turnCoordination.hookCompaction.previewChars = parseBoundedInteger(arg.slice("--preview-chars=".length), "--preview-chars", 40, 4000); + continue; + } + if (arg === "--dedupe-seconds") { + config.turnCoordination.hookCompaction.dedupeSeconds = parseBoundedInteger(readValue(args, ++i, arg), arg, 0, 3600); + continue; + } + if (arg.startsWith("--dedupe-seconds=")) { + config.turnCoordination.hookCompaction.dedupeSeconds = parseBoundedInteger(arg.slice("--dedupe-seconds=".length), "--dedupe-seconds", 0, 3600); + continue; + } + if (arg === "--clear-custom") { + delete config.turnCoordination.hookCompaction.previewLimit; + delete config.turnCoordination.hookCompaction.previewChars; + delete config.turnCoordination.hookCompaction.dedupeSeconds; + continue; + } + console.error(`Unknown hook-compaction option: ${arg}`); + printHookCompactionHelp(console.error); + process.exit(1); + } +} + +function readValue(args: string[], index: number, flag: string): string { + const value = args[index]; + if (!value || value.startsWith("--")) { + console.error(`${flag} requires a value.`); + process.exit(1); + } + return value; +} + +function parseMode(value: string): HookCompactionMode { + if (MODES.includes(value as HookCompactionMode)) return value as HookCompactionMode; + console.error(`Invalid hook compaction mode: ${value}`); + console.error("Expected one of: verbose, compact"); + process.exit(1); +} + +function parseBoundedInteger(value: string, flag: string, min: number, max: number): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + console.error(`${flag} must be an integer from ${min} to ${max}.`); + process.exit(1); + } + return parsed; +} + +function printHookCompactionHelp(write: (message: string) => void = console.log): void { + write(` +Usage: + ${PRIMARY_BIN} hook-compaction status + ${PRIMARY_BIN} hook-compaction verbose + ${PRIMARY_BIN} hook-compaction compact + ${PRIMARY_BIN} hook-compaction set [--mode verbose|compact] [--preview-limit <1-20>] [--preview-chars <40-4000>] [--dedupe-seconds <0-3600>] [--clear-custom] + +Controls the UserPromptSubmit hook context volume. Verbose preserves the current release behavior; compact is opt-in. +`.trim()); +} diff --git a/src/cli/tui.tsx b/src/cli/tui.tsx index 3d171dc..8d800ca 100644 --- a/src/cli/tui.tsx +++ b/src/cli/tui.tsx @@ -33,6 +33,7 @@ const BRAND_COLOR = "cyan"; const CODEX_COLOR = "#60a5fa"; const CLAUDE_COLOR = "#a78bfa"; const LOCAL_ACTION_STATUS_MS = 10_000; +const ACTIVITY_PANEL_HORIZONTAL_OVERHEAD = 6; export async function runTui(args: string[]): Promise { if (args.includes("--help") || args.includes("-h")) { @@ -105,6 +106,7 @@ Hotkeys: f Toggle auto-finality c Cycle coordinator x Toggle readonly permission mode + t Toggle token mode q Quit `); } @@ -224,6 +226,13 @@ function ContextRelayTui({ lifecycle, initial }: { lifecycle: DaemonLifecycle; i updateConfig((config) => { config.permissions.readonly = !config.permissions.readonly; }, "readonly toggled"); + return; + } + if (_input === "t") { + updateConfig((config) => { + const current = config.turnCoordination.hookCompaction.mode; + config.turnCoordination.hookCompaction.mode = current === "compact" ? "verbose" : "compact"; + }, "token mode toggled"); } }); @@ -246,16 +255,16 @@ function ContextRelayTui({ lifecycle, initial }: { lifecycle: DaemonLifecycle; i - + {viewport.height >= 28 ? : null} - + -