diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ccab71ce..f299c320 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.", - "version": "2.2.1" + "version": "2.2.2" }, "plugins": [ { "name": "relay", "description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.", - "version": "2.2.1", + "version": "2.2.2", "author": { "name": "Libar" }, diff --git a/AGENTS.md b/AGENTS.md index 60715f0d..ca7d1ce0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,3 +22,8 @@ tests, fixtures, docs, skills, CHANGELOG entries, commit messages and pull reque - Every version field moves together with `node scripts/bump-version.mjs `. `npm run check-version` checks them. - The CHANGELOG groups entries under the release heading. Don't add a heading per development version. + +## Release maintenance + +2.x is the final Relay line and takes bug fixes only: no new features. Validate its integration in +Claude Code after harness upgrades. diff --git a/README.md b/README.md index b0d61296..6b314c3c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ with a state block that says what happened. 1.0.6. [NOTICE](NOTICE) keeps the attribution and license terms. [docs/design.md](docs/design.md) explains the design. +2.x is the final Relay line and takes bug fixes only. + ## Backends A backend is named by its harness. The model family goes in `model:`. @@ -35,8 +37,8 @@ Requirements: - Claude Code 2.1.271 or later, for hooks on `SubagentHandback`. - Node.js 22 or later. - macOS or Linux. The process sweep reads the process table with `ps`. -- At least one backend CLI, signed in: the Codex CLI, Grok Build, ZCode or Kimi Code. Release 2.1.2 - was checked live with Codex 0.163.0, Grok Build 1.0.60, ZCode 3.15.3 and Kimi Code 2.2.0. +- At least one backend CLI, signed in: the Codex CLI, Grok Build, ZCode or Kimi Code. Release 2.2.2 + was checked live with Codex 0.166.0, Grok Build 1.0.70, ZCode 3.16.2 and Kimi Code 2.4.1. ```bash claude plugin marketplace add libar-dev/relay-plugin-cc @@ -218,8 +220,8 @@ is recorded as a job, so `/relay:status` lists it. | `--wait`, `--background` | run in the foreground, or as a Claude background task; without either, a review you type asks, and a review Claude starts runs in the foreground | | `[focus]` | text after the flags; `/relay:adversarial-review` only | -Grok, ZCode and Kimi read only the diff inlined in their prompt, so they refuse a change above two files -or 256 KiB before anything starts. An adversarial answer that does not match the review schema +Grok, ZCode and Kimi read only the diff inlined in their prompt, so they refuse a change above 256 KiB +before anything starts. An adversarial answer that does not match the review schema fails the review with the raw answer in the report. ### The stop-time review gate @@ -319,7 +321,7 @@ runs on command hooks and the transcript. ## Development -`package.json` names the package `@libar-dev/relay-plugin-cc`, version 2.2.0. It is private and +`package.json` names the package `@libar-dev/relay-plugin-cc`, version 2.2.2. It is private and not published to npm. Clone the repository and run: ```bash @@ -347,6 +349,8 @@ them); the version named above in this section is set by hand. Read [docs/architecture.md](docs/architecture.md) for the code layout and module rules, [docs/backends.md](docs/backends.md) for the backend contract, -[docs/design.md](docs/design.md) for the behavior and the reasons behind it, and -[docs/kimi.md](docs/kimi.md) for what the Kimi backend rests on and how to check a new Kimi version, and -[docs/thread-surveys.md](docs/thread-surveys.md) for the surveys of real use that fixes came from. +[docs/design.md](docs/design.md) for the behavior and the reasons behind it, +[docs/kimi.md](docs/kimi.md) for what the Kimi backend rests on and how to check a new Kimi version, +[docs/thread-surveys.md](docs/thread-surveys.md) for the surveys of real use that fixes came from, and +[docs/relay-2.2.2-validation.md](docs/relay-2.2.2-validation.md) for this release's review findings, +regressions and live-validation evidence. diff --git a/docs/design.md b/docs/design.md index 553825e5..63a387e6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -238,8 +238,10 @@ server that dies during initialization leaves an identity, and records its end: signal, an unfinished last line, the last 4096 bytes of stderr. `server.stderr.log` keeps at most 1 MiB. JSONL lines have no size cap, so multi-megabyte results reach `events.jsonl` intact, and an unfinished last line is never parsed. A server that relay closes gets its stdin ended, then -SIGTERM 50 ms later if it still runs, so a closed server's end record can show SIGTERM; that is -relay's own close, not a crash, and the job's state was settled before it. When the server exits +SIGTERM after 50 ms and SIGKILL after another 4,950 ms if it still runs. A process that survives +SIGKILL for another five seconds is abandoned: its pipes close, its process is unreferenced, and its +server record stays open for the next worker's sweep. Its late exit does not write to a job another +worker may own. A close timeout is reported as such, not as an observed exit. When the server exits while a child of it holds a pipe, the client reads for 200 ms more, then closes the pipes, so the end is recorded and every pending request settles; a request to a server that has exited fails at once. @@ -334,9 +336,11 @@ Before every relay tool call and at SubagentStop, the hook reads the transcript received message not yet delivered, in order: steered while the job runs, continued once it ended. - A message is recognised by its entry type and origin, never by its text: a `queued_command` - attachment from the coordinator (a `SendMessage` while the relay works), a `user` entry from the - coordinator (a message after the hand-back, taken out of the harness's fixed wrapper), or a plain - `user` turn after the prompt that is neither metadata (`isMeta`) nor a tool result. + attachment or `user` entry from the coordinator, a peer or a person, or a plain `user` turn after + the prompt that is neither metadata nor a tool result. Peer text comes from `origin.body`; the + coordinator's and person's fixed wrappers are removed. Text from any of those origins survives a + shared text/tool-result entry, while a tool-result-only entry stays out. A task notification stays + out too, and an entry of any other origin is reported as a violation rather than delivered. - A message that arrives before the job exists is delivered by `start` once the job exists. - Each delivery is recorded in `inbox/relay/.json` under the relay lock, with the inbox message id chosen before posting. A hook cut off half way posts again under the same @@ -704,12 +708,13 @@ or `adversarial-review`, so `status` lists it. A Codex review turns off the job ZCode and Kimi, which have no schema parameter, get it in the prompt. A Grok review always runs under Grok's `read-only` profile, and a Kimi review refuses the file writes and terminal commands Kimi sends through the client, which is not an OS sandbox (Known limits, Kimi). -- **Inline limits.** A Grok, ZCode or Kimi review reads only the inlined change (read-only ZCode and - Kimi have no shell to run git), so it sees the whole change or does not start. Untracked files count toward - the limits and are inlined whole, and a binary file is named and counts as a file. Above two files - or 256 KiB, the review is refused before any job or process exists, with a message naming - `--base`, `--scope` and Codex. A Codex adversarial review above the limits gets a summary and - reads the diff itself with read-only git commands; untracked files there are cut at 24 KiB each. +- **Inline limit.** A Grok, ZCode or Kimi review reads only the inlined change (read-only ZCode and + Kimi have no shell to run git), so it sees the whole change or does not start. The actual serialized + repository context, including Git's rendered names and decoded file contents, must fit 256 KiB. + Above that, the review is refused before any job or process exists, with a message naming `--base`, + `--scope` and Codex. A file already known to be oversized is named without being read. A Codex + adversarial review above two files or 256 KiB gets a summary and reads the diff itself with + read-only git commands; untracked files there are cut at 24 KiB each. - **The schema.** An adversarial answer counts only if it matches `schemas/review-output.schema.json`: types, enums, required fields, ranges, array items and no extra properties. One JSON code fence around the whole answer is unwrapped. Anything else fails diff --git a/docs/harness-output-limits.md b/docs/harness-output-limits.md new file mode 100644 index 00000000..7627739f --- /dev/null +++ b/docs/harness-output-limits.md @@ -0,0 +1,165 @@ +# Tool-output limits per harness + +Every harness the relay runs shortens large tool results before its model sees them. A review that +reads a diff, a log or a whole file can then reason from part of the evidence. This page records +what each harness does, what the relay can change, and what it cannot. + +## How the limits were measured + +On 30 October 2026, on a macOS machine, one job per harness read the same synthetic file: 16,000 +lines, 128,056 bytes (about 60k tokens), with a random marker word on the first, middle and last +line. Each job ran `cat` once through its shell tool, then read the file once with its own file-read +tool, and reported the first and last lines it received, every notice, and the marker words it saw. +The markers were not in the brief, so a job could name only the markers that reached it. Every job +named exactly the markers it received. Each model then reported its harness's settings from the +installed binary, its configuration files and docs, and official sources, marking each claim as +measured, read on disk, official, or recalled. + +On 31 October 2026 the shell results were measured again on a Linux machine, with the same results +for Grok, ZCode and Kimi on the same versions. A second probe gave each job a 16,000-line refund log +with one odd line in the middle, which no keyword search finds, and asked only whether anything +looked wrong. Every job found the line: Codex with its budget on and off, and Grok, ZCode and Kimi +with and without an instruction to read saved output. These were short, synthetic, single-file +tasks; a long job can still reason from a preview. + +[`scripts/tool-output-probe.mjs`](../scripts/tool-output-probe.mjs) writes the marker file. Run the +probe again after a harness upgrade: a missing middle marker shows a cut that a report alone would +not. + +## What the jobs received + +| Harness | Shell `cat` | File-read tool | Full output kept? | +| --- | --- | --- | --- | +| Codex CLI 0.165.0, gpt-6-astra | about 10,000 tokens: start and end, middle cut | none in code mode | no | +| Grok Build 1.0.70, grok-4.7 | 19.6 KB: start and end, middle cut | lines 1 to 1,000 | yes: a log path in the notice | +| ZCode, runtime 0.17.0, GLM-5.3 | a 2 KB preview of the start | lines 1 to 7,966 (25,000-token cap) | yes: a log path in the notice | +| Kimi Code 2.3.1, k3.1-pro | 4 KB of the start and 1 KB of the end | lines 1 to 7,755 (100,000-character cap) | yes: `output_path` in the notice | + +Codex discards the cut text of a shell result. Grok, ZCode and Kimi save the whole shell output and +tell the model where it is; there the risk is a model that works from the preview and never reads +the file. That is not true of every tool: ZCode's MCP results and web search are cut with no saved +copy, and no instruction recovers them. + +## Codex + +Two separate limits apply, and the relay handles both (see +[Codex configuration](../README.md#codex-configuration)). Measured on Codex CLI 0.166.0. + +- `tool_output_token_limit` budgets each result kept in history. Its default comes from the model + catalog: 10,000 tokens for every current model. Relay sends a per-job value as a thread `config` + override. +- Code mode, which every current model uses (`tool_mode: code_mode_only` in the catalog), cuts each + `exec` and `wait` result to 10,000 tokens unless the call asks for more with + `// @exec: {"max_output_tokens": N}` or `max_tokens: N`. The default is the constant + `DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL` in `codex-rs/code-mode-protocol/src/runtime.rs`; no + setting changes it. `max_output_tokens` on a nested `exec_command` alone does not change it: a job + that set only that received 10,000 tokens. The relay tells the model the budget and both forms in + developer instructions, but nothing enforces them. In the refund-log probe, jobs given only that + instruction still began with a plain `cat`, saw the truncation notice and searched the file. The + same text in the brief made them use the pragma every time, at a cost of about 132,000 tokens for + the one read. + +Also known, and not changed by the relay: unified exec keeps at most 1 MiB per command, half from the +start and half from the end, as a constant. `mcp_servers..tools..output_token_limit` replaces +the budget for that tool, and a plugin's own limit can only be tightened. A native review runs in its +own conversation with a copy of the thread's configuration, so it inherits the budget and +instructions (read in the source). A cold `thread/resume` applies new `config` and +`developerInstructions`; a thread already loaded and running ignores them. The bundled computer-use +plugin's `cua_repl` is not an `mcp_servers` entry, so `job-mcp-off` cannot turn it off, and three +per-process overrides tried on 0.166.0 (the plugin's `enabled`, its server's `enabled`, and +`features.computer_use`) left it listed with its tools. Codex's token counts are approximations at four bytes per token. Compaction starts at 90% of the +context window by default. + +Sources: [truncate.rs](https://github.com/openai/codex/blob/main/codex-rs/utils/string/src/truncate.rs), +[code-mode runtime](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/code-mode-protocol/src/runtime.rs), +[unified_exec/mod.rs](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/core/src/unified_exec/mod.rs), +[review task](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/core/src/tasks/review.rs), +[thread resume](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/app-server/src/request_processors/thread_processor.rs), +[configuration reference](https://developers.openai.com/codex/config-reference/). + +## Grok Build + +| Limit | Default | Cut | How to set it | +| --- | --- | --- | --- | +| Shell preview | 20,000 characters | middle | `[toolset.bash] output_byte_limit` in the home's `config.toml`, read at launch. The name says bytes; the count is of characters. No flag or environment variable, and `GROK_CONFIG` refuses the key. | +| MCP result | 20,000 bytes | head; on a successful write the full output is saved and the notice names the file | `GROK_MAX_MCP_OUTPUT_BYTES` (wins over `MAX_MCP_OUTPUT_BYTES`), or `[mcp] max_output_bytes` | +| File read | 1,000 lines and 25,000 estimated tokens per call | head; a range over the token cap is an error | per call only (`offset`, `limit`) | +| Grep | 200 lines in content mode | head | per call only (`head_limit`) | +| Web fetch | 3% of the estimated context | head | runtime parameters only; the relay does not set them. The page is saved as an artifact | + +Full shell output is kept in the session's `terminal/` log, up to 64 MiB once the command ends. ACP's +`session/new` `_meta` documents `rules`, which appends to the system prompt when the session is +created, as well as `systemPromptOverride` and `agentProfile`; the relay uses none of them. The context +window is 256,000 tokens, and `session/set_model` with `_meta: {"contextWindow": 500000}` selects the +larger one. Auto-compaction is 80% in the catalog of current models, and 85% is the fallback. + +On Linux, Grok refuses its `read-only` profile when it cannot resolve a runtime socket it denies, such +as `/run/podman/podman.sock` under a root-only `/run/podman`. `grok models` under that profile fails +the same way, with no model call, so setup can report it. + +Sources: [changelog](https://x.ai/changelog/build), +[mcp_truncate.rs](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-tools/src/util/mcp_truncate.rs), +[read_file](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-tools/src/implementations/grok_build/read_file/mod.rs), +[configuration guide](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md), +[MCP cap precedence](https://github.com/xai-org/grok-build/blob/2bdd1d6a6369de0e8c68132ea4539e9abd9e14a8/crates/codegen/xai-grok-pager/docs/user-guide/07-mcp-servers.md#configuration). + +## ZCode + +The binary inspected was the npm package `zcode-app-cli` 3.16.2-38, whose `package.json` names the +repository `github.com/kingsword09/zcode-cli`. It bundles zcode-runtime 0.17.0. The bundle of 3.17.1-42 +(runtime 0.17.1) declares the same budgets and changes how a large file read selects its text; it was +read, not run. + +| Limit | Default | Cut | How to set it | +| --- | --- | --- | --- | +| Shell result | 30,000 bytes, then a 2,000-character preview | head; the full output is saved | not settable: `BASH_MAX_OUTPUT_LENGTH` (default 30,000, at most 150,000) changes what is captured, not this budget | +| File read | 25,000 estimated tokens | head, with the next offset | not settable | +| MCP result | 50,000 bytes for the model, 100,000 inline | head; **no copy is saved** | not settable | +| Web fetch | 100,000 bytes | head; the page is saved | not settable | +| Web search | 10,000 bytes | head; **no copy is saved** | not settable | +| Glob, Grep, JavaScript REPL | 100,000, 20,000 and 65,536 bytes | saved | not settable | +| Task output | 32,000 characters | head | `TASK_MAX_OUTPUT_LENGTH`, at most 160,000 | + +Full shell output goes to a per-session log whose path the model receives. GLM-5.3 has a context of +about 1M tokens; compaction starts about 34k tokens before the end, with no setting. + +Sources: [GLM-5.3](https://z.ai/blog/glm-5.3), [ZCode configuration](https://zcode.z.ai/en/docs/configuration). + +## Kimi Code + +| Limit | Default | Cut | How to set it | +| --- | --- | --- | --- | +| Shell, MCP, web, subagent results | 50,000 characters, then 4,096 head and 1,024 tail characters; a result of long lines that fits after shaping is kept with a file pointer | middle | not settable | +| File read | 100,000 characters per call, at most 500,000 | head | `[read] default_max_chars` and `max_chars` in the home's `config.toml`; the process reloads the file | +| Grep, glob | 250 lines, 100 paths | head | per call only | + +Output is saved to `output_path`: the first 10,000,000 characters, or nothing if the write fails, in +which case the notice says so. The relay's own limit on a `read-only` job's file reads is 8 MiB, so a +larger saved output cannot be read there in one piece. ACP's `session/set_config_option` sets the model, +thinking level and mode only. Measured on 2.3.1; 2.4.0 has been released, and its docs describe a lower +read cap. k3.1-pro is configured for +1,048,576 tokens; compaction starts at 85% or with 50,000 tokens left. + +Sources: [configuration files](https://www.kimi.com/code/docs/en/kimi-code-cli/configuration/config-files.html), +[overrides](https://www.kimi.com/code/docs/en/kimi-code-cli/configuration/overrides.html), +[release notes](https://www.kimi.com/code/docs/en/kimi-code/whats-new.html). + +## What the relay does + +- **Codex:** every job gets a budget, and developer instructions name the budget, the pragma and line + ranges. +- **Grok MCP results:** a job's environment sets `GROK_MAX_MCP_OUTPUT_BYTES` to 262,144, a quarter of the + 256k window at four bytes per token, unless the environment, managed policy, home or a project config + between the job directory and the Git root names an MCP budget. Grok decides whether to trust the project setting. The variable is read from Grok's source; a live check could not reach an MCP server + from a job, so its effect has not been measured. +- **Reviews on Grok, ZCode and Kimi:** the serialized repository context goes inline in the prompt, up + to 256 KiB including Git's rendered names and decoded file contents; above that the review is + refused. Later file reads are limited as above. +- **Setup:** warns when Grok refuses its `read-only` profile. + +The relay does not change Grok's shell preview or Kimi's file reads, which live only in a home's +`config.toml`, because it does not write a user's configuration; set them there, or in a home kept for +jobs. Nor does it give Grok, ZCode or Kimi jobs a standing instruction: each harness's notice already +names the saved file, and the probes found no case where an instruction changed the result. + +All values here are from the versions named above and change with them. diff --git a/docs/mods/BRIEF.md b/docs/mods/BRIEF.md deleted file mode 100644 index ab952549..00000000 --- a/docs/mods/BRIEF.md +++ /dev/null @@ -1,66 +0,0 @@ -# Brief: plan the Claude Code Mods features for the relay plugin - -## Goal -Investigate what the relay plugin can build on Claude Code **Mods** (the plugin extension -mechanism: JS/TS modules that subscribe to events, register commands and draw panes/indicators -inside Claude Code), and produce an implementation plan for the set of features worth building. -You are one of three independent planners (Opus 5.5, GPT-6.1 Sol, GPT-6 Astra). The lead will -compare the three plans, so take your own position and argue for it; do not hedge toward a -consensus you imagine. - -## Where things are -- Repository: /Users/darkomijic/dev-libar/relay-plugin-cc (main @ f1283c9, "relay 2.2.0"). - Plugin under plugins/relay/ (companion.mjs, scripts/lib, scripts/hooks, hooks/hooks.json, - agents/job.md, commands/, skills/). Design docs: docs/architecture.md, docs/design.md, - docs/backends.md, docs/kimi.md, README.md, plugins/relay/CHANGELOG.md. -- Preliminary research by the user (read it first): its summary is - [preliminary-research.md](preliminary-research.md). - It was written on 2026-10-17 against relay PR #1 at 7dad14c and a Mods declaration file - generated by Claude Code 2.1.277. The repo has moved to 2.2.0 since, and the installed - Claude Code is **2.1.307**. Treat every claim in it as a hypothesis: confirm or refute it - against current code and current docs, and say which. -- Official Mods docs: https://code.claude.com/docs/en/plugins/mods/overview and the sibling - pages create, api, events, interface, reference, gallery, test. Examples: - https://github.com/anthropics/claude-code-playground/blob/main/claude-code/mods/README.md - The installed build's generated declarations (written under `.claude-plugin/types/` of a - locally loaded dev Mod) outrank the docs. If you can, scaffold a throwaway Mod in a scratch - directory to obtain them and check API names/limits against 2.1.307. - -## Questions that must be answered with evidence -1. **Where will these Mods actually run for this user?** The user works mostly inside T3 Code, - which drives Claude Code through the Claude Agent SDK / a non-terminal host, and also uses the - terminal. The research doc's matrix says `claude -p` / Agent SDK gets hooks but "no - interactive drawing". Establish exactly which Mod capabilities (events/hooks, commands, - `immediate` commands, `$.ui.log`, panes, indicators, `$.session.send`, `$.process.run`, - `$.store`/`$.state`) work in (a) terminal, (b) Desktop, (c) SDK-hosted sessions such as T3 - Code. This decides whether a UI-first plan is worth anything to this user, so be precise and - cite the source or experiment. -2. Which relay responsibilities today (hooks in hooks.json: SessionStart, PreToolUse guard incl. - the SubagentHandback matcher, SubagentStop guard, Stop review gate; the relay:job agent's - Bash-mediated companion calls; status/result/steer/continue/cancel commands) could a Mod do - better, and which must stay where they are? Note ordering and fail-open/fail-closed semantics. -3. What does the companion need to expose as a stable bridge for a Mod (JSON outputs, request - ids, versioning, reconciling an action whose process timed out)? Check what `status --json`, - `result --json`, `setup --json` emit today. -4. Which non-UI Mod features (events, typed classic hooks, session messaging, background - process, commands that bypass a model turn) give value even where nothing can be drawn? - -## Deliverable -Write your plan to /tmp/relay-mods-plan/.md and also return it in full as your -final message. Structure: -1. Verdict in 5 lines or fewer. -2. Surface matrix (Q1) with sources. -3. Feature catalogue: for each feature — user value, Mod APIs used, companion/runtime changes, - surfaces where it works, risks, size (S/M/L), dependencies. Include features you reject and why. -4. Phased plan with acceptance criteria per phase; first phase small enough to ship in one PR. -5. Bridge/contract changes to companion.mjs, with file references (path:line). -6. Test strategy (Mod test kit + existing tests under tests/ + live checks). -7. Corrections to the preliminary research. -8. Open questions only the user can answer. - -## Rules -- Read-only on the repository: no edits, no commits, no branches. Experiments go in a scratch - directory under /tmp/relay-mods-/. -- Do not start, steer or cancel relay jobs. Read-only companion calls (`status --json --all`, - `result --json`, `setup --json`) are fine. -- Separate what you verified (file:line, doc URL, experiment output) from what you infer. diff --git a/docs/mods/claude-opus-5-5.md b/docs/mods/claude-opus-5-5.md deleted file mode 100644 index 43b3bb6b..00000000 --- a/docs/mods/claude-opus-5-5.md +++ /dev/null @@ -1,295 +0,0 @@ -# Relay × Claude Code Mods: implementation plan (Opus 5.5) - -Repository: `relay-plugin-cc` main @ f1283c9 (relay 2.2.0). Claude Code 2.1.307. Agent SDK 0.3.276 as T3 Code runs it. -Experiments: `/tmp/relay-mods-claude-opus-5-5/` (probe Mod `probe/`, a relay copy with a module `relaycopy/`, -stream-json transcripts `out/exp1..5.jsonl`, the probe's own log `out/probe-log.jsonl`, the classic-hook log -`out/classic-hooks.jsonl`, stdin feeders in `scripts/`). - -Labels: **[V]** verified by experiment or by reading the code or declarations, with a citation. **[D]** stated by the -official docs. **[I]** inferred. - ---- - -## 1. Verdict - -1. In T3 Code, where you do most of your work, a Mod can draw nothing. Its hooks and slash commands run. Its status line, - toasts, log lines and panes go to T3 as `system` messages, and T3 has no code for them **[V]**. A plan that starts - with the UI gets you nothing until you open a terminal. -2. The one feature that needs a Mod and pays off in T3: answer `/relay:status`, `/relay:result` and `/relay:cancel` - inside the module. That costs no model turn and no tokens, and the report is never retyped by a model **[V]**. The - markdown commands stay as the fallback. -3. The biggest win of this investigation is not a Mod. A classic `PostToolUse` hook on `SendMessage` sees a steer the - moment it is sent **[V]**. It can end the relay's 120-second `wait` early, so a steer reaches the job in about - 2 seconds instead of up to 2 minutes. This works on every surface and on every Claude Code version relay supports. -4. Keep the guard, the hand-back, the stop gate and the session export as command hooks. A Mod would sit above them in - the chain, be skipped when it fails, and rest on an API marked early access **[V]**. -5. Build a terminal or Desktop board only if you will open it. Before any board, make the companion's JSON a versioned, - slim and idempotent bridge. - ---- - -## 2. Surface matrix (Q1) - -**How T3 runs Claude Code [V]:** `claude --output-format stream-json --input-format stream-json --permission-prompt-tool stdio …`, -`CLAUDE_CODE_ENTRYPOINT=sdk-ts`, `CLAUDE_AGENT_SDK_VERSION=0.3.276`. I read this from this session's own process (`ps`) and -environment. The installed relay plugin loads there: its `SessionStart` hook exported `RELAY_COMPANION_TRANSCRIPT_PATH` -into this session. So a module shipped in the relay plugin would load in T3 too, with no `CLAUDE_CODE_PLUGIN_DIRS`. - -| Capability | Terminal REPL | Desktop Code tab | SDK host / T3 Code (stream-json) | `claude -p` | -|---|---|---|---|---| -| Events: `tool.call`, `agent.spawn`, `session.send`/`receive`, `turn.complete`, `classic.*` | yes [D] | yes [D] | **yes [V]** exp2–5: all fired | yes [V] exp1 | -| `session.start` input | `surface: "terminal"`, interactive | remote surface [D] | **`surface: null`, `isInteractive: false`, `surfaces() = []` [V]** | same [V] | -| `$.command.register` + a `command.run` answer | yes [D] | yes [D] | **yes [V]**: listed in the SDK `initialize` response's `commands` and in init `slash_commands`. The answer arrives as an assistant message with `model: ""`; `num_turns: 0`, `total_cost_usd: 0` (exp1, exp2) | yes [V] | -| Hook a plugin's own markdown command (e.g. `relay:status`) and answer it | yes [I] | yes [I] | yes [I]: the same dispatch as in `-p` | **yes [V]**: `relaycopy:status` answered, `num_turns 0`, cost 0 | -| Does T3 show that synthetic message? | — | — | **likely [I]**: T3 reads `/goal` output from `` assistant messages and leaves them out only of usage and model tracking (bundle grep around the `` sites). Confirm live in Phase 2 | prints it [V] | -| `immediate: true` (runs mid-turn) | yes [D] | not checked | **no [V]**: exp4 sent `/probe-imm` 9 s into a 20 s turn; it ran at `turn.complete`, like a plain command | n/a | -| `{}` answer (nothing in the transcript) | yes [D] | yes [D] | yes [V]: result `""`, no synthetic message (exp3) | yes | -| `$.ui.log` | dim transcript row [D] | yes [D] | emitted as `{"type":"system","subtype":"ui_log"}` [V]. **T3 shows nothing [I]**: the bundle holds no `ui_log` string, and T3 handles system subtypes one by one (`compact_boundary`, `status`, …) | same as SDK | -| `$.ui.status`, `$.ui.toast` | yes [D] | yes [D] | emitted as `system/ui_status` and `system/ui_toast` [V]; **T3 has no handler [I]** | same | -| Panes `$.ui.open` | drawn [D] | drawn [D] | **`{isPlaced:false, reason:"no-surface: no surface is attached"}` [V]**, plus `system/ui_panes` | same [V] | -| Band `AbovePrompt`, redrawn engine components | yes [D] | yes, except terminal-only elements [D] | nothing draws [D] | no | -| `$.ui.notify` | yes [D] | `no-surface` (d.ts 15021: "no terminal (a -p run, the SDK, the desktop)") | **`{isSent:false, reason:"no-surface"}` [V]** | same [V] | -| `$.ui.ask` | yes [D] | yes [I] | not checked; rejects under `-p` per the d.ts | rejects [D] | -| `$.prompt.read` / `fill` / `suggest` | yes | yes | **`read` → `{text:"",cursor:0}` [V]**; `fill`/`suggest` → not shown (d.ts 3066–3067, 3076) | same | -| `$.ui.view` | yes | yes | denied: "draws no main view" (reference.md "Work that outlives a dispatch") | denied | -| `$.session.send` | yes | yes | **yes [V]**: the `session.send` hook fired at send time, `isDelivered:true` | yes | -| `$.process.run` | yes | yes | **yes [V]**: `node -v`, and `companion status --json` from `$.plugin.root` (relaycopy) | yes [V] | -| `$.store` / `$.state` | yes | yes | `$.store` **yes [V]**; `$.state` held by the host, no surface needed [I] | yes | - -**What that means for you:** in T3, a Mod gives you event hooks, commands answered between turns, and background calls -to `$.process.run`. It shows nothing on its own. Everything it shows has to come back as a slash command's answer. A host -could render `ui_status`, `ui_toast` and `ui_log`; T3 doesn't today. A small T3 change, rendering `system/ui_status` as a -chip and `ui_log` as a dim row, would make a terminal-style status line visible in T3. That's the cheapest route to any -UI there (open question 4). - -**The timing that matters for steering [V]** (exp3, exp5: a background subagent blocked in a 25-second Bash call): - -``` -t=0 SendMessage tool call -t=+6 ms Mod session.send (to = agent id, isDelivered: true) -t=+51 ms classic PostToolUse SendMessage (tool_input.to = agent id) -t=+25.9 s Mod session.receive (agentId = the subagent), only when its tool call returned -``` - -The receiving side only sees a message at the agent's next tool round. The `SendMessage` result says so itself: "queued -for delivery … at its next tool round". So nothing on the relay agent's side can shorten its 120-second `wait`. The -sending side can, and a classic hook sees it as early as a Mod does. - ---- - -## 3. Feature catalogue - -Size: S ≤ 1 day, M ≤ 3 days, L more. - -### F1. Steer wake (classic hook, not a Mod). **Build first.** -- **Value:** a `SendMessage` to a running `relay:job` agent reaches the job within about 2 s instead of 0–120 s (design.md - "Two-minute waits, never trapped"). This is the steer latency the lead feels in every review loop. -- **Mechanism:** a `PostToolUse` matcher `SendMessage` in `hooks.json`, running a new `scripts/hooks/relay-wake.mjs`. - If `tool_input.to` names a relay pointer (`readRelayPointer`, `lib/harness/relay.mjs:292`; `findRelayJob`, - `relay.mjs:312`) whose job is not terminal, it writes `/relay-wake.json` (`{at}`). The relay `wait` polls it: - `wait.mjs:41-54` already polls once a second. When the file's mtime is later than the wait's own start, and only under - `--exit-zero-while-running`, the wait leaves with the "still running" line (`wait.mjs:62-68`). The relay runs `wait` - again; that tool round ends, the queued message is attached, and the guard delivers it at the next `PreToolUse`, the - same path the 120-second timeout takes today. -- **Mod APIs:** none. A Mod's `session.send` would be equivalent but needs Claude Code ≥ 2.1.287 and an early-access API. -- **Surfaces:** all. It's a command hook. -- **Risks:** `to` given as a name rather than an agent id isn't resolved; relay agents are addressed by id in practice - (open question 5). A stale marker can't hurt: the guard still decides what is delivered. It is never worse than today: - if the hook fails, the 120 s timeout remains. The hook must fail open: exit 0 and a 5 s timeout. -- **Size:** S. **Depends on:** nothing. - -### F2. Zero-turn `/relay:status`, `/relay:result`, `/relay:cancel` (the first Mod). **Build second.** -- **Value:** in T3 and the terminal, these commands answer in about 0.3 s with no model turn and no tokens. The report is - shown verbatim, so a model never retypes it. Today `commands/result.md` asks the model to "present the full command - output … do not summarize", which costs a turn and risks drift. -- **Mod APIs:** `on('command.run', { command: 'relay:status' }, …)` and the same for `relay:result` and `relay:cancel`. - Each runs `$.process.run(['node', $.plugin.root + '/scripts/companion.mjs', , '--format', 'markdown', e.args])` - and returns `{ text }`. Each registration has `.catch(($, e, next) => next(e))`: when the module throws, the markdown - command runs as it does today. Verified: a module in relay's own `hooks.json` (beside `"hooks"`) validates, loads, and - answers the plugin's markdown command with `num_turns 0` (relaycopy). -- **Companion changes:** `--format markdown` for `status` (the table `commands/status.md` asks the model to build: id, - state, kind, model/effort, duration, summary, relay) and for `result` (the state block in a ```text fence, then the - report as it is). The format lives in the companion, so `node --test` covers it and the module stays a pipe. -- **Surfaces:** terminal, Desktop, T3/SDK (between turns), `-p`. -- **Risks:** the early-access API changes; the module must stay small and fail open. The command's text goes to the - model on the next turn, as the Bash output does today. In T3 the command waits for the turn to end [V]. `/relay:setup` - stays model-mediated: it asks questions and installs packages. -- **Size:** S–M. **Depends on:** nothing. Ship F3 (a) and (b) with it or before it. - -### F3. Bridge contract hardening (companion). **Build alongside F2, needed before F5.** -See §5. **Size:** M. - -### F4. Header pre-check for `relay:job` (classic hook). Optional. -- **Value:** a malformed header (unknown key, missing `model:`/`effort:`) fails at the `Agent` call with the parser's - message, instead of spinning up a Haiku relay that fails `start` and hands back an error. -- **Mechanism:** `PreToolUse` matcher `Agent` with `subagent_type == "relay:job"`. Run `parseHeaderPrompt` - (`lib/jobs/request.mjs:29`) and `buildLaunchRequest` (`request.mjs:123`) on `tool_input.prompt` without creating a job, - and deny with the `UsageError` message. Verified: `PreToolUse` on `Agent` receives the full prompt and `subagent_type` - (exp5). -- **Mod alternative:** `agent.spawn` also covers a Workflow script's `agent()` (a deny is the error `agent()` throws, d.ts - `AgentSpawnInput.workflow`). Defer it until a Mod is in place anyway. -- **Size:** S. **Risk:** the check must use the exact parser `start` uses, so it can never refuse a header that `start` - would accept. - -### F5. Terminal and Desktop presence: status line and job board. Deferred. -- **Value:** "2 running · 1 owed" under the prompt (`$.ui.status`), and a `/relay-board` pane listing jobs, the selected - job's report in a `Markdown` element, and a Cancel button that asks again at the moment of action. Only on surfaces - that draw. -- **Mod APIs:** `$.ui.status`, `$.ui.open`, a `ui.render` hook on `Pane`, `$.state` atoms, `$.clock.every`, - `$.process.run`, `$.command.register` (with `immediate`, which works in the terminal). -- **Rule:** poll only when `(await $.session.surfaces()).length > 0`, so a T3 session never spawns a process every 15 s - for output nobody sees. -- **Depends on:** F3 (slim listing, versioned JSON, busy as JSON). **Size:** M for the status line and a read-only board. - Steer/continue buttons add M and need F3's message ids. - -### F6. Shadow observation of Mod events. Experiment. -- **(a)** A `session.receive` hook for relay agents writes `{agentId, origin, sha256(text), t}` to - `relay-shadow.jsonl`. Compare it with `inbox/relay/` to measure whether Mod events could replace the transcript reads - in `lib/harness/` (design.md "What the relay reads from Claude Code"). -- **(b)** An `agent.spawn` hook with `subagentType: "relay:job"` records `workflow.runId` and `workflow.agentIndex` with - the spawned `agentId`. That is a second adoption key for when the Workflow journal keeps no `journal.jsonl` - (design.md "The Claude Code dependency"). Caveat from the d.ts: "a stalled agent's retry is not raised again", so it - covers resumes, not retries. -- **Promote only** after weeks with zero divergence. **Size:** M. - -### Rejected -| Idea | Why not | -|---|---| -| Move the relay guard (PreToolUse incl. `SubagentHandback`, SubagentStop) into a Mod | The chain is `[managed settings hooks, ...hooks modules, the other settings hooks as core]` (d.ts 1280-1283): a Mod sits **above** the guard, and a Mod that answers without `next` skips it. A failed Mod hook is skipped (reference.md, "Developing one"), so the Mod is no safer than today's fail-open exit 0 (`relay-guard.mjs:469-472`). The guard's work (locks, fs, transcript) needs Node and would still be a process per call. | -| Stop review gate in a Mod | It runs up to 900 s of Codex. It already works in T3 as a command hook. A Mod adds an unknown hook budget and nothing else. Its known limit, not knowing which edits the turn made (design.md "The stop-time review gate"), is fixed better by a classic `UserPromptSubmit` snapshot (`git stash create`) than by a Mod. | -| SessionStart export in a Mod | `CLAUDE_ENV_FILE` handling is a settings-hook contract, and the E2BIG lesson (design.md) lives there. Nothing to gain. | -| Replace the `relay:job` Haiku agent with a Mod tool or `$.agent.register` | The agent exists for a background run, a completion notice, `SendMessage` and a Workflow slot (design.md "Why each layer exists"). A registered agent type is still a Claude agent. | -| `immediate` commands as a T3 feature | Over stream-json they wait for the turn [V, exp4]. | -| "Direct controls" board as the first release (research §2B) | Invisible in T3. Without F3 it would also need retry reconciliation the companion can't support yet. | -| Config form (backend, home, model, effort) | Terminal-only. `/relay:setup` exists. The setup page already exposes `can`/`models` in JSON. | -| `$.tool.register` status tool for the model | The model already calls the companion through Bash. One more deferred tool for little gain. | -| `$.model.*` report summaries | Reports are verbatim by design. It costs tokens. | -| A watcher that notifies on job completion | Relay-launched jobs already notify through the agent's completion notice; `--background` reviews notify through the Bash task. And in T3, `$.ui.notify` and `toast` show nothing. | - ---- - -## 4. Phased plan - -### Phase 1: steer wake (one PR, no Mod) -Changes: `hooks/hooks.json` (add `PostToolUse` → `SendMessage`, timeout 5), new `scripts/hooks/relay-wake.mjs`, -`cli/wait.mjs` (watch the marker only under `--exit-zero-while-running`), `lib/jobs/store.mjs:93` `jobPaths` (marker path), -design.md "Two-minute waits" plus the README line "within about two minutes" (agents/job.md description, design.md -interface table), and the CHANGELOG. - -Acceptance: -- Unit: a hook input with `tool_input.to` naming a relay pointer of a running job writes the marker. An unknown `to`, a - terminal job, malformed JSON or a missing jobs home write nothing and exit 0. -- Unit: a `wait --exit-zero-while-running --timeout-seconds 120` on a running fake job returns within 2 s of a newer - marker, prints `still running: …`, exits 0. A marker older than the wait's start is ignored. A direct `wait` without - the flag ignores markers. -- E2E (`tests/relay-e2e.test.mjs` style, fake backend): the steer is delivered and run once, with no - `relay-violation:` line. -- Live (T3 and terminal): steer a running Codex `relay:job`. The time from the `SendMessage` call to the - `inbox/relay/.json` record is under 10 s, three runs out of three. After hand-back, a message still continues - as before. - -### Phase 2: the first Mod, zero-turn commands (one PR) -Changes: `hooks/hooks.json` adds `"modules": ["./mod/register.js"]`; new `hooks/mod/register.js` (JS with JSDoc -`import('claude-code').Register`, no build), `hooks/mod/*.test.ts`; companion `--format markdown` for `status` and -`result`; F3 (a) and (b); README "Requirements: Claude Code 2.1.307 for the module; older builds use the markdown -commands"; design.md replaces "The relay needs no Claude Code mod" with what the module does and its fallback. - -Acceptance: -- `claude plugin validate plugins/relay` passes. The `hooks:` line lists exactly three `command.run` matchers, each with - `.catch`. No hook on `tool.call`, `classic.PreToolUse` or `classic.SubagentStop`. -- `claude plugin test plugins/relay` passes on `terminal` and `desktop` mounts where relevant: each command answers from - a stubbed `process.run` (`{ value: … }`, verified working in `relaycopy/hooks/mod/status.test.ts`). A throwing stub - falls through to `next` (the test's own `command.run` answers "fallback"). -- `node --test`: `--format markdown` golden tests for a list, an empty list, a busy job, a report with a fence in it. -- Live, `claude -p "/relay:status" --output-format stream-json`: `num_turns: 0`, `total_cost_usd: 0`. -- Live in T3: `/relay:result ` shows the state block and the report in the thread with no model reply under it. If - T3 does not show the synthetic message, stop and record that in design.md; the markdown fallback stays the T3 path. - -### Phase 3: bridge for a board (one PR, companion only) -F3 (c)–(f) from §5. Acceptance in §6. - -### Phase 4: terminal/Desktop status line, then a read-only board (only if you want it, question 1) -Acceptance: no `$.process.run` in a session whose `surfaces()` is empty (test: mount with no surface; the kit's -`process.run` stub is never called). The board survives `/clear` (it rebuilds from `status --json`) and a module reload -(state in `$.state`). Cancel asks again with the job id and current state at the moment of the press. - -### Phase 5: shadow experiments (F6). Report after four weeks; no behaviour change. - ---- - -## 5. Bridge contract changes to the companion - -| # | Change | Where | Why | -|---|---|---|---| -| a | Every `--json` output carries `"schema": "relay./1"` and `"relayVersion"` (from `plugin.json`) | `cli/status.mjs:16` (result), `:31`, `:59`; `cli/wait.mjs:64`; `cli/message.mjs:54`, `:76`; `cli/start.mjs:198-201`; `cli/cancel.mjs:193-194`; `cli/setup.mjs:73` | A module built against relay N must notice relay N+1's shape. Today nothing in the JSON says which version wrote it. | -| b | Errors as JSON under `--json`: a busy view prints `{"schema":…,"busy":true,"message":…}` on stdout and still exits 4; a usage error prints `{"error":{"kind":"usage","message":…}}` and exits 1 | `companion.mjs:113-117` (every error goes to stderr as text today); `JobViewBusy` at `lib/jobs/view.mjs:28-33` | A Mod can't tell "busy, keep the last view" from "broken" without parsing stderr. The research's "label it stale, don't clear the board" needs this. | -| c | `status --json --brief` (no `attemptRecords`, `server`, `relay.transcriptPath`; adds `owed` = `undelivered.length`) and `--active` | `cli/status.mjs:24-60`, `jobViewJson` `lib/jobs/view.mjs:350-384` | Measured: `status --all --json` is **1,290,527 bytes for 473 jobs** (about 2.7 KB a job, mostly `attemptRecords[].servers[].stderrTail`). `$.process.run` keeps 4 MiB of stdout, so the full listing would be cut at about 1,500 jobs. It takes 0.28 s now. | -| d | `steer`/`continue`/`resume --message-id `: post under the caller's id; if `messageExists` already, don't post again, just await or read the outcome | `cli/message.mjs:41-79`; `postInboxMessage` already takes `message.id` (`lib/jobs/messages.mjs:17-27`); `messageExists` `messages.mjs:83-86` | A process the Mod killed at its timeout may have posted already. A retry under the same id runs the message once, the guarantee the relay delivery already has (design.md "Every message runs once or is reported"). | -| e | `--ack-timeout-seconds N` on the same commands | `awaitOutcome` reads `RELAY_JOBS_ACK_TIMEOUT_MS` (60 s) at `lib/worker/delivery.mjs:87` | `$.process.run` defaults to a 30 s timeout. The bridge must return `outcome: null` ("pending") before the caller gives up. | -| f | `message-status --json` → `{state: pending\|claimed\|done, outcome}` | new; reads `readOutcome` `messages.mjs:153`, `isMessagePending` `:158` | Reconciliation after an unclear result, without sending again. | -| g | `--format markdown` for `status` and `result` | `cli/status.mjs`, rendering in `lib/jobs/view.mjs` beside `renderJobList` (`:325`) and `renderResultText` (`:278`) | F2. One renderer, tested in Node. | -| h | Phase 1 only: the wake marker in `jobPaths`; `wait` watches it | `lib/jobs/store.mjs`; `cli/wait.mjs:41-54` | F1. | - -Not needed: a daemon or socket. Each call takes 0.1–0.3 s (`status --json` 0.10 s, `--all` 0.28 s, measured), and -architecture.md rule 5 (no daemon) stands. - ---- - -## 6. Test strategy - -- **Node tests (`tests/`, `npm test`):** F1 hook unit tests next to `tests/relay-guard.test.mjs`. The `wait` marker in - `tests/jobs.test.mjs` or a new `tests/relay-wake.test.mjs`. An E2E steer in `tests/relay-e2e.test.mjs` with the fakes. - Bridge: golden JSON with `schema`; busy-as-JSON in `tests/busy-view.test.mjs`; `--message-id` idempotence (post - twice, one inbox file, one outcome) in `tests/message-account.test.mjs`; `--brief` size bound (100 fake jobs < N KB); - markdown goldens in `tests/render.test.mjs`. `tests/commands.test.mjs` asserts the markdown commands still exist, - since they are the fallback. -- **Mod test kit (`claude plugin test plugins/relay`):** `$` stubs answer `{ value }` (verified on 2.1.307). One test per - command: argv as expected, text passed through, a throwing stub falls to `next`. For F5: mount `Pane` on `terminal` - and `desktop`, press Cancel, and assert the second confirmation; with an empty `surfaces()`, assert `process.run` is - never called. The kit runs only where `claude` is installed, so CI runs it as a separate job, or it stays a local - pre-release step listed in CONTRIBUTING. -- **Static:** `claude plugin validate plugins/relay` in the release checklist. Fail if `gatingHooks` lists a hook without - `.catch`, or if any hook names `tool.call`, `classic.PreToolUse` or `classic.SubagentStop`. -- **Live, after every Claude Code update** (add to design.md "After a Claude Code update"): (1) `claude -p "/relay:status" - --output-format stream-json`: `num_turns 0`; (2) steer a running relay and measure F1's latency; (3) `claude --version` - is in the module's README range. The scratch harness `scripts/feed*.mjs` (stdin feeder) plus the probe Mod is the - model for this check. - ---- - -## 7. Corrections to the preliminary research - -| Claim (research doc, 2026-10-17) | Status | -|---|---| -| Matrix: `claude -p` / Agent SDK, "Supported when the plugin loads / No interactive drawing" | **Confirmed, with one gap.** The engine still sends `ui_status`, `ui_toast`, `ui_log` and `ui_panes` to the host as `system` messages [V exp1]. T3 drops them [I, bundle grep]. Commands work there and appear in the SDK's `initialize` command list [V]. | -| "Mods can register commands that run while Claude is busy using `immediate: true`" | **True in the terminal only.** Over stream-json, so in T3, an immediate command runs at turn end [V exp4]. | -| "An interface-only command can return `{}`" | **Confirmed** [V exp3]. In T3 it then shows nothing at all. | -| "`$.ui.log` displays text without giving it to Claude"; "routine status stays in the interface" | True in the terminal. In T3 the "interface" is nowhere, so a routine status Mod shows nothing there. | -| "The first cross-project job board needs neither agent-harness-kit nor a new service … relay already exposes enough" | **Partly refuted.** No version marker. Busy goes to stderr as text with exit 4. No caller-chosen message id on `steer`/`continue`, though the inbox supports one. `status --all --json` is 1.29 MB for 473 jobs, against a 4 MiB stdout limit in `$.process.run`. §5 (a)–(f). | -| Stage 1 = `/relay-board` + indicator + pane | For this user, none of the three renders in T3. A registered Mod command can't be named `relay:…` (`CommandSpec.name`: letters, digits, `_`, `-`). Hooking the plugin's own `relay:status` keeps the names and keeps a fallback [V relaycopy]. | -| `$.session.send` reports queuing, not action | **Confirmed**, and more: the receiver's `session.receive` fires only at its next tool round (25.9 s later in exp3). Only the sender side can cut relay latency, and a classic `PostToolUse` sees that as early as a Mod [V exp5]. | -| Native send/receive events may replace transcript detection (research §7) | Possible only for messages while the relay runs. A message after hand-back resumes the agent (`agent.spawn` with `resume`). Taking it in a Mod would park the agent and lose the completion notice. Keep it shadow-only (F6). | -| `classic.PreToolUse` is a `ToolCallEnvelope` exception; results are normalized | **Confirmed** for 2.1.307 (d.ts 1280-1290, `ClassicResult` 1312). Also: Mod hooks sit **above** plugin and settings command hooks in the classic chain, which the research didn't state. | -| `SubagentHandback` is a tool matched by `PreToolUse` | Confirmed (`hooks/hooks.json`). | -| Limits: 4096 messages, 4 MiB fs, 4 MiB store, `process.run` 30 s / 10 min | Confirmed (d.ts 2794-2826, 3316-3318, 3450-3454, 3604-3618). Also: `process.run` keeps the first 4 MiB of each stream. | -| `$.state` resets on `/clear`; `session.start` doesn't fire again; `classic.SessionStart` does | Confirmed (d.ts 4464; `SessionStartHookInput.source` includes `clear`). | -| Mods on by default from 2.1.287 terminal / 2.1.286 Desktop | Confirmed by the docs today. Declarations now come from 2.1.307 (the research had 2.1.277). | -| Three playground samples (`token-weather`, `blast-radius`, `replay-theater`) | Confirmed; the README says each needs 2.1.287+. | -| (Not addressed) packaging | One `hooks.json` holds both `"hooks"` and `"modules"`; `claude plugin validate` passes and the module loads [V relaycopy]. `$.plugin.root` locates `scripts/companion.mjs` [V]. | - ---- - -## 8. Open questions only you can answer - -1. **Terminal versus T3:** how often do you work in a terminal or the Desktop Code tab? If almost never, drop Phase 4. -2. **Version floor:** relay's floor is 2.1.271, and modules load from 2.1.287. I tested only 2.1.307 (your machine also - has 2.1.303 and 2.1.306). Which Claude Code versions do M1, M3 and the ThinkPad run, and may the module's documented - floor be 2.1.307? -3. **Model visibility:** should `/relay:status` and `/relay:result` output enter the model's context, as their Bash output - does today? The other choice is `{}` plus UI only, which is invisible in T3. -4. **T3 rendering:** can you get T3 Code to render `system/ui_status` (a chip) and `system/ui_log` (a dim row)? With that, - Phase 4's status line works in T3 with no relay change. -5. **Addressing:** do your leads and Workflow scripts always send to a relay by agent id, or also by `name`? F1 resolves - ids only. Names would need a name → id record from `PostToolUse Agent`. -6. **Header pre-check (F4):** a refusal at the `Agent` call is stricter than today's failed start plus hand-back. Is that - the behaviour you want in Workflow scripts, where the deny becomes a thrown error? diff --git a/docs/mods/funding.md b/docs/mods/funding.md deleted file mode 100644 index 9e67b2b7..00000000 --- a/docs/mods/funding.md +++ /dev/null @@ -1,35 +0,0 @@ -# Funding and the product - -Relay may look for support for its maintenance at some point. Nothing about that is decided. This note records -the principles any support would follow and what they ask of the product. Those principles are the parts that -matter for this plan. - -## Principles - -- **Relay stays free and open source.** No paid tier, no paid UI, no hosted version, no private build. The Mod and - its board ship in the same plugin as everything else. -- **Support funds testing and maintenance, not control.** It doesn't influence compatibility results, defaults, - model recommendations, issue priority or the roadmap. -- **A backend counts as supported only when the public live suite says so.** Support can't buy that status. -- **Every backend is treated the same.** Each one's real capabilities and limits are stated exactly. For example, - Kimi takes no message during a running turn and runs it as the next turn, and a `read-only` mode is not an - operating-system sandbox. - -## Why testing is the cost - -Relay's guarantees come from live runs of real harnesses: tools, MCP, steering, cancellation, recovery after a -killed worker, and long review workflows. Those runs use real model capacity. Any support relay looks for would go -first to that testing. - -## What it asks of the product - -- **A public capability table for each backend, backed by the live suite.** Each cell links to the test that - proves it, and shows only what the suite has passed. Candidate rows include a delegated task, multi-turn tools, - MCP, steering a running job, messages between the parent and the agent (both ways) and between agents, joining - a dynamic workflow, recovery, and a long review workflow. -- **A short demo.** Claude delegates to an external agent, steers it while it runs, the agent uses a tool, a second - agent joins, and the result returns normally. -- **Concrete compatibility cases**, each with its regression test: a harness behaviour relay had to handle, and how - it handles it. - -[plan.md](plan.md#impact-of-the-funding-notes) lists where the plan takes these up. diff --git a/docs/mods/gpt-6-astra.md b/docs/mods/gpt-6-astra.md deleted file mode 100644 index 3e0ad20e..00000000 --- a/docs/mods/gpt-6-astra.md +++ /dev/null @@ -1,322 +0,0 @@ -# Relay Mods plan, GPT-6 Astra - -## 1. Verdict - -Build a command-first Mod for relay, with a stable companion bridge and an optional terminal interface. -For this user, a terminal job board is a later feature: current T3 does not implement the Mod drawing protocol. -The immediate gains are deterministic status/report commands, reliable direct controls, and faster delivery through existing relay machinery. -Keep the relay agent, command hooks, worker leases, recovery, and durable message accounting authoritative. -Do not replace those mechanisms until a separately tested adapter proves equivalent behavior. - -## 2. Surface matrix and evidence - -Research date: 2026-10-22. The repository is clean at `f1283c9d13a77952d93d114185d5859e2d3bdbdb`, relay 2.2.0. The installed CLI is Claude Code 2.1.307, built 2026-10-21T19:40:02Z. I read the preliminary research first, then current source, official docs, and declarations generated by an interactive scratch Mod. - -Evidence labels below distinguish **observed** experiments, **declared** contracts/docs, and **inferred** host behavior. Desktop was not tested live. I did not inject a Mod into an existing T3 thread, launch a new T3 conversation, or start, steer, resume, or cancel any relay job. - -The generated declaration file is [the 2.1.307 contract](/tmp/relay-mods-gpt-6-astra/probe/.claude-plugin/types/claude-code/index.d.ts). References to `T:line` below mean that file. `R/` means `/Users/darkomijic/dev-libar/relay-plugin-cc/`. `H:line` means the extracted [T3 server bundle](/tmp/relay-mods-gpt-6-astra/t3/binCli-DvdnACBz.mjs). These abbreviations keep repeated source references readable. - -| Capability | Terminal | Desktop local Code | Plain print / Agent SDK | Current T3 Claude adapter | -|---|---|---|---|---| -| Engine events and typed classic hooks | Yes, declared; startup observed | Yes, declared | Yes when the plugin loads; `session.start` and `classic.SessionStart` observed | Same engine support when the plugin loads; no extra T3 event API required | -| Registered commands, without a model turn | Yes, observed | Yes, declared | Yes, observed with zero model turns and zero cost | Strong static support for manually submitted slash commands and their text replies; end-to-end T3 check still required | -| `immediate: true` | Declared to run while Claude works | Same command contract | Registration and invocation observed; busy-turn behavior declared, not separately exercised | Do not promise this bypasses T3's own queue/steering controls; T3 does not consume dynamic command metadata in the inspected adapter | -| `$.ui.log` | Visible transcript notice, not model context; observed | Visible notice, declared | Emits `system/ui_log`; observed. It is not absent merely because drawing is absent | No handler found in the inspected server dispatcher; treat as invisible to the user until T3 supports it | -| `$.ui.status` / `$.ui.toast` | Visible, observed | Supported, declared | Emit `system/ui_status` and `system/ui_toast`; observed | Same missing host rendering support as log | -| Panes and indicators | Supported render sites | Supported common elements; terminal-only elements unavailable | No attached renderer. `ui.open` returned `isPlaced:false`, reason `no-surface`; surfaces were `[]` | No drawing protocol handler found; a Mod pane will not appear as a T3 panel | -| `$.session.send` | Declared | Declared | Engine messaging API remains available; delivery depends on recipient and policy, not a renderer | Same engine operation; T3 presentation and lifecycle after delivery need host validation | -| `$.process.run` | Observed | Declared for the local CLI backend | Observed, including execution from a timer between commands | Runs on the machine hosting Claude, using that provider's environment | -| Timers and `$.process.spawn` | Declared | Declared | Timer plus `process.run` observed; streaming spawn declared | Useful while T3 retains the Claude process; not a durable scheduler | -| `$.store` | Read/write and cross-session persistence observed | Declared | Read/write and persistence observed | Available in the provider's Claude configuration directory, not T3's database | -| `$.state` | Declared | Declared | Read/write observed, including version increments across two commands | Session-local, available without drawing; reset behavior remains Claude's | - -The official [host matrix](https://code.claude.com/docs/en/plugins/mods/overview#where-mods-run) establishes the broad support boundary. The [command tutorial](https://code.claude.com/docs/en/plugins/mods/create#write-a-mod-yourself) explicitly runs a registered command with `claude -p`. The [API guide](https://code.claude.com/docs/en/plugins/mods/api) covers immediate commands, process access, timers, messaging, and non-model notices. This support assumes the plugin is loaded and Mods are allowed. `disableAllHooks` also disables ordinary relay hooks while leaving plugin commands/agents installed; safe mode disables broader customizations. Neither is a way to turn off only relay's Mod. Newly authored session Mods needing approval are a different case from explicitly loaded `--plugin-dir` plugins. Current documented floors are terminal 2.1.287 and Desktop's bundled CLI 2.1.286; use 2.1.307 as this implementation's verified target. - -**Observed host-boundary results.** The [SDK observation capsule](/tmp/relay-mods-gpt-6-astra/sdk-observations.json) and [raw stream](/tmp/relay-mods-gpt-6-astra/sdk-stream.jsonl) record two commands through streaming JSON input/output. Both completed with `num_turns:0` and `total_cost_usd:0`. State advanced from version 0 to 1 to 2. Store values survived separate CLI sessions. A timer executed Node and wrote `BACKGROUND_OK` while the streaming session remained open. A pane stayed unplaced. The [terminal result](/tmp/relay-mods-gpt-6-astra/terminal-result.json) records `surfaces:["terminal"]` and `isInteractive:true`. The probe source and runner are under `/tmp/relay-mods-gpt-6-astra/`. - -`T:2439` explicitly says print/SDK hosts receive `ui_log`; `T:2865` describes attached renderers; `T:12280` says SDK startup is noninteractive with no initial surface. `T:15050` describes an unplaced pane waiting for a renderer. Thus a future SDK host could attach a supported renderer; "SDK can never draw" is too absolute. Current T3 does not do so in the inspected adapter. - -**T3-specific evidence and its limit.** The running app package identifies itself as `0.1.1-nightly.20261020.3438`, commit `fe27e8e3dc12`. Its Claude adapter uses an Agent SDK query with streaming input at `H:198863`. Plain slash text survives the prompt-effort prefix at `H:47541`; messages without attachments pass through `makeClaudeUserMessage` at `H:199394`. The adapter extracts assistant text at `H:199277` and emits it at `H:203360`, which matches the synthetic assistant text produced by the probe command. However, the full extracted server bundle contains no `ui_log`, `ui_status`, `ui_toast`, `ui_attach`, `ui_surface`, or `commands_changed` handlers. These are static findings, not a T3 UI experiment. They support a command-first release and rule out promising a native T3 board today. - -The REA analysis retained evidence `ev_9e308f92cc0c83b54ed91c269b621c819e38d67c6f076b1875f5214d479674ef`. It reported native companion integrity mismatches and exhausted its semantic-analysis heap on the large server bundle. I therefore base the detailed adapter findings on the separately extracted, readable JavaScript and its explicit dispatch paths, not on completeness of the REA graph. The [saved REA report](/tmp/relay-mods-gpt-6-astra/t3-rea-summary.json) preserves those limitations. The server bundle SHA-256 is `220e9dc682a2bc5d56e4df923bbe47b8872331611f3faaef8f2391414d03f3e5`. - -Three consequences matter to the product: - -- Return `{ text }` for an explicit headless command whose answer must be visible. This bypasses a model invocation but puts the result into Claude's conversation. Use `{}` for interface-only commands. Do not stream routine progress into model context to compensate for T3's missing notice support. -- Detect drawing with `$.session.surfaces()` and react to attachment changes. `isInteractive` is not a reliable proxy for Desktop drawing because Desktop also hosts a CLI backend. -- Treat `$.session.send` success as queued delivery, never proof that an agent acted. The [messaging contract](https://code.claude.com/docs/en/plugins/mods/api#send-and-receive-messages-between-sessions) and `T:2935` say this explicitly. No live send was performed in this investigation. - -`$.ui.notify` is a separate API. The headless probe returned `{isSent:false,reason:"no-surface"}`. The API docs also exclude Desktop and SDK from terminal notifications. It cannot be the T3 notification solution. - -## 3. Feature catalogue and responsibility boundaries - -Sizes are relative implementation estimates: S is one narrow PR; M is a few focused PRs; L changes delivery or workflow semantics and needs a separate compatibility effort. Proposed command names use hyphens because registered Mod names do not support the colon in existing `/relay:status` names. Keep the existing Markdown commands available. - -### F1. Deterministic status and report commands, build first - -User value: `/relay-status` and then `/relay-result ` show authoritative results without asking Claude to run Bash and reformat them. Include effective state, attempt, owed work, violations, and whether a result was handed back. Add repository and Workflow scope before machine-wide browsing. - -APIs: `session.start`, `$.command.register`, `command.run`, `$.plugin.root`, `$.session.cwd`, `$.process.run`. Mark read commands immediate. Use command text in headless hosts and permit a pane link later. - -Companion changes: a versioned, bounded read bridge described below, explicit backend names, typed busy errors, and report pagination or a report-size refusal. No worker changes. Works in terminal, Desktop and headless engines; T3 text rendering has strong static evidence and remains a release smoke test. Risks are silent JSON truncation, stale attempts, and confusing an old report with current completion. Size M overall, with status alone S. Dependency: the read contract. - -### F2. Direct steer, queue, continue, resume and cancel, build after durable receipts - -User value: a deliberate user action reaches the companion without a Haiku relay turn. Offer `/relay-send`, `/relay-continue`, `/relay-resume`, `/relay-cancel` and a request-status command. Explain whether the backend accepted an interjection or queued another turn. Kimi's `steer:false` must appear as queue-follow-up behavior. - -APIs: command registration, `command.run`, `$.process.run`, `$.state` for pending presentation; later Button/Input callbacks. Companion/runtime changes: durable request IDs, payload binding, queryable receipts, action preconditions, and a fast accept/reconcile path. Both CLI and future pane controls call the same bridge. All three host kinds can execute it; T3 mid-turn availability needs its own routing check. Risks are duplicate continuation after timeout, cancellation of a newer attempt, and permissions differing from Bash. Size M. Depends on F1 and the action contract. Do not implement mutations by constructing a shell command from user text. - -### F3. Event-assisted delivery, investigate early and promote only after parity - -User value: messages can reach an existing relay job sooner than the roughly two-minute Bash wait cycle. This is the strongest non-UI improvement for T3 workflows. - -APIs: observe `session.receive`, `session.append`, `tool.call`, `classic.SubagentStart`, and `classic.SubagentStop`; use `$.clock.after` to coalesce work and `$.process.run` for a bounded companion call. `T:11948` describes receive events without a durable message UUID; `T:11108` gives append events a pinned transcript UUID and agent ID. Prefer a post-`next` append signal as the candidate trigger. Exclude virtual queue rows until their relationship to delivery rows is proved. - -Companion/runtime changes: expose an agent-bound "reconcile relay inbox" operation under `lib/harness/`. It must read the existing transcript and use `syncRelayMessages`, its lock, UUID ledger and message IDs. An append notification is a wake signal, not a second source of message contents. Retry boundedly if transcript publication lags the event. Initially record timing/correlation only and never deliver from the Mod. - -Works in all engine hosts; does not need T3 to render anything. Risks include pre-approval observation, duplicate wake events, transcript lag, Workflow adoption, and blocking Claude's event chain. Size L. Depends on a read-only agent/job mapping and a separate accepted-event parity experiment. Keep polling/guards as fallback, including after missed events and process restarts. - -### F4. Failure and uncollected-result diagnostics, build - -User value: find a completed job whose latest attempt was never handed back, an exhausted recovery, owed work, and relay violations. Relay 2.2.0 already supplies much of the evidence. - -APIs: read commands, event-triggered refresh, `$.clock`, `$.ui.log` on hosts that display it, and disposable `$.state`. Companion changes: explicit handback coverage plus pending-message counts and structured diagnostic codes. `handedBackAt:null` alone is insufficient for old jobs or jobs never owned by a relay. Reading a report must not mark it handed back. - -Diagnostics work everywhere through F1. Passive notices work in terminal/Desktop; current T3 needs a host notice adapter. Optional `$.prompt.submit` may wake Claude for a narrowly selected failure, but that is a model turn and must be an explicit preference. `$.session.send` to an agent is likewise not a durable job receipt. Risks are duplicate alerts, cross-session noise, and claiming receipt from a UI view. Size M. Depends on F1; automatic wakeups depend on durable deduplication and stop/refusal handling. - -### F5. Setup inspector and validated launch configuration, build the inspector first - -User value: inspect backend/home/model/effort and distinguish credentials present from verified sign-in. Later, build a valid launch request without hand-editing a header. - -APIs: `/relay-setup`, `$.process.run`; common Input/Select controls if a renderer exists. Companion changes: wrap existing `buildSetupPage`; expose full registry capabilities and their notes, not only today's reduced `can` projection. Add a pure validation operation before adding launch actions. Keep model/home discovery in Node. - -The inspector works as text everywhere and as a form in terminal/Desktop. Setup may take tens of seconds and should run on request, not every board refresh. A configuration change does not prove a live turn succeeds. Launching direct jobs also does not automatically provide a Claude Agent/Workflow slot. Size S for the inspector, M for validation/forms, L if expanded into a launch workflow. Depends on F1; writes/launches depend on F2. - -### F6. Compact job board and report inspector, build later - -User value: see active jobs, owed work, stale reads, errors and report availability without repeated commands. Provide one compact indicator and an explicitly opened pane with a report inspector. Render from one cached projection. - -APIs: `ui.render` at Pane and AbovePrompt, `$.ui.open`, `$.ui.panes`, `$.ui.resolve`, `$.state`, `$.clock`, `session.attach`, and `$.session.surfaces`. Use Box/Text/Button/Input/Select/Markdown rather than terminal-only Raster or Image. - -Companion changes: F1's bounded queries and F2's actions; no UI-owned job state. Works in terminal and Desktop local Code. Current T3 gets the text commands, not the board. Refresh while visible, with a small single-flight interval; stop drawing-related polling when no renderer is attached. Risks are refresh storms across sessions, report size, narrow terminals, and stale selection targeting a different attempt. Size M. Depends on F1; controls depend on F2. This is optional, not the first milestone. - -### F7. Workflow job grouping, build a limited projection - -User value: inspect jobs from one Workflow run and identify missing handbacks. The existing `status --workflow ` already finds jobs across repositories. - -APIs: F1 commands and optionally F6's pane. Companion changes: expose Workflow/agent identifiers and labels consistently; group existing jobs. Do not invent observer/fixer/judge stages or finding ownership from prose. Those need an explicit workflow record not present in the current job API. Text works everywhere, graphical grouping in terminal/Desktop. Risk: incomplete job lists being mistaken for complete Workflow state. Size S for grouping, L for a workflow controller. Depends on F1. Defer role/finding management. - -### F8. Narrow model-callable read tools, optional - -User value: Claude can read typed relay status/results without generating Bash syntax. APIs: `$.tool.register` and exact-name `tool.call` handlers invoking the read bridge. Runtime changes: none beyond F1. Works in all hosts loading the Mod. It still takes a model tool-use step and is not the same as a free user command. Risks are added tool context and duplicating existing commands. Size S. Add only if actual usage shows Bash construction remains a problem; do not expose every bridge operation as a tool. - -### Features I reject for this plan - -| Proposal | Value sought and proposed APIs | Runtime cost, host scope, dependency and rejection | -|---|---|---| -| Replace `relay:job` with a fully native external Mod agent | Remove Haiku waiting; agent registration/spawn and tool interception | L, all engine hosts, requires supported external-engine completion/Workflow integration. Current APIs do not establish that contract. Returning a tool result is not creating an Agent task with adoption, SendMessage and handback semantics. Reject as a release dependency. | -| Move all guards into typed classic hooks | Less process startup; `classic.*`, `tool.call` | L, all engine hosts, requires transport adapters and full parity. `classic.PreToolUse` lacks the classic stdin envelope; hook ordering and failure semantics differ. Reject a wholesale migration. | -| Machine-wide session/history browser | Search all harness history; `$.fs`, `$.process`, an agent-harness-kit consumer | L, terminal/Desktop UI plus another index/service, requires discovery, retention and replay policy. Outside the relay-job problem; no first-party machine-wide event feed. Defer to a separate product effort. | -| Run workers or a shared backend daemon inside the Mod | Faster orchestration; `$.process.spawn` | L, all hosts while Claude lives, requires changing worker ownership. Spawned children die when the Mod unloads or its stream is closed. Reject; relay workers must survive the interface. | -| Custom model summaries or replacing backend jobs with `$.model.complete/fork` | Attractive summaries or less process glue | M to L, all engine hosts, requires new usage policy and cannot supply equivalent external tools. Reject routine summaries and harness replacement. Deterministic reports already exist. | - -### Responsibilities that stay in relay - -The exact registered hooks are `R/plugins/relay/hooks/hooks.json:4`, `:15`, `:26` and `:38`. - -| Responsibility | Decision and evidence | -|---|---| -| SessionStart environment exports | Keep `scripts/hooks/session-lifecycle.mjs:22`. It exports session/transcript paths once per export line into `CLAUDE_ENV_FILE`. The Mod initializes its own state separately. `session.start` does not fire on clear/resume/branch; use `classic.SessionStart` for those transitions. | -| PreToolUse guard, including SubagentHandback | Keep `scripts/hooks/relay-guard.mjs:349`. It delivers messages, restricts Bash, binds job identity, handles missed starts, rejects StructuredOutput, and rewrites handbacks. `SubagentHandback` is a tool matcher, not an event name. | -| SubagentStop guard | Keep `scripts/hooks/relay-guard.mjs:375`. It verifies owed messages and terminal state, checks handback/text parity and handles Workflow's state-only return. | -| Stop review gate | Keep `scripts/hooks/stop-review-gate.mjs:194`. It skips recursion, background tasks and active repository jobs, then blocks only on an actual `BLOCK:` result. A Mod may display configuration/outcomes, not run a second gate. | -| Relay agent's Bash calls | Keep `agents/job.md:18`, `:26`, `:36`. The hook enriches start with identity and transcript, adds automatic recovery to wait, and supplies result integrity. Direct user controls can bypass the waiting agent for their own requests without deleting this integration. | -| Worker, backend and durable delivery | Keep in Node. `lib/worker/delivery.mjs:27` detaches workers; `lib/harness/relay.mjs:577` serializes message delivery through the UUID ledger; the job layer remains the state authority. | - -Paths in that table are relative to `R/plugins/relay/` except the explicitly prefixed hooks.json references. - -The guard is deliberately **fail open**, not an absolute security barrier: its outer catch exits 0 at `relay-guard.mjs:469`. Counted handback/stop blocks eventually release after eight blocks with a violation at `:177`; a still-busy handback is not fabricated at `:343`. The stop gate also allows operational failures and unexpected reviewer answers, while reporting why. This behavior is part of compatibility, not something to "improve" silently. - -Managed PreToolUse hooks run before Mod `tool.call`; ordinary plugin hooks run beneath it. A Mod that answers without `next` can bypass relay's existing plugin guard. A failed Mod hook normally skips forward; a `.catch` can deny, but if that handler fails, execution can proceed. See [ordering and failures](https://code.claude.com/docs/en/plugins/mods/events#run-alongside-other-mods), `T:1276`, and `T:8187`. Every observational relay Mod handler must preserve `next` and avoid changing the existing gate policy. - -## 4. Phased implementation plan - -### Phase 1, one PR: read-only `/relay-status` - -Add `modules:["./register.ts"]` alongside the existing hooks object, a tiny Mod module, and `companion.mjs bridge` with only `describe` and bounded `status` operations. Use the existing validated view functions, not direct file assembly. Expose repository scope and explicit job lookup first. Keep `/relay:status` as fallback. No background polling, pane, mutation, setup probe or hook migration in this PR. - -Acceptance criteria: - -- A command-only terminal and streaming SDK run produces the expected answer with zero model turns. A manual T3 command reaches its transcript in a fresh Claude thread used for release validation; record T3 and Claude versions. No reliance on dynamic slash completion or `ui_log`. -- Response schema/version and explicit backend are validated. Status output is bounded before it crosses the Mod process limit. Unknown versions and truncated output produce a visible error. -- A busy read produces a typed retryable error, not an empty board or failed job. Command usage errors cannot fall through to model execution. -- Removing or disabling only the relay Mod leaves its existing hooks and commands working, subject to the host's hook policy. Global `disableAllHooks` is explicitly outside that guarantee. Mod load/reload/exit creates no job and changes no job record. -- Existing affected checks pass; the generated 2.1.307 contract is captured as a compatibility fixture without bundling unrelated MCP declarations. - -### Phase 2: useful read access and durable user actions - -First add bounded result retrieval, setup inspection, Workflow grouping and handback diagnostics. Then implement the action ledger and add direct message/cancel commands. These are separate reviewable PRs within the phase. - -Acceptance criteria: - -- Existing result exit semantics and report identity survive the bridge. A failed/cancelled job is not presented as a successful task just because the bridge read succeeded. -- Duplicate requests converge on one receipt; a reused ID with changed payload is refused. A killed companion after acceptance is reconciled by ID without a second message. -- The same action is correct under concurrent callers, a job advancing attempts, a cancellation race, and a worker restart. The worker enforces relevant preconditions at the mutation boundary. -- Kimi queueing is labeled accurately; capability-based actions do not assume all tasks or review jobs accept messages. Unresolved actions remain unresolved until authoritative evidence arrives. -- T3 idle commands are checked live. Mid-turn controls are advertised only after validating T3's send-now/queue path independently from Claude's `immediate` flag. - -### Phase 3: event observations, then a delivery accelerator - -Record only job/agent/message identities and timing. Compare Mod append signals against existing transcript delivery on both ordinary Agent and Workflow paths. If they correlate correctly, enable coalesced calls into the existing reconciler for a small opt-in cohort. Guards remain enabled. - -Acceptance criteria: - -- Compare accepted, refused, held-for-approval and consumed messages; none may reach the backend earlier than the authorized existing path. -- Observe correlation across adoption, reload, compaction, clear/resume, interruption and T3 process restart. Identical text sent twice remains two messages because identity, not content hashing, determines delivery. -- Repeated events, missing events and a crash after receipt cause neither duplicate backend delivery nor lost owed work. -- Measure message-to-backend latency against the current two-minute polling path. Retain the feature only if it improves latency without slowing unrelated events. -- No new fail-closed behavior, no bypass of classic hooks, no replacement of handback verification. - -### Phase 4: optional terminal/Desktop interface - -Add F6 and only the limited Workflow projection of F7. Reuse the shipped commands and bridge. Configuration forms may follow once validation and receipts are stable. - -Acceptance criteria: - -- Test narrow/wide terminal and Desktop common elements. The same commands still work with no renderer. -- Closing or reloading the pane never ends a worker. Refresh has one request in flight, backs off on errors and stops when irrelevant. -- Selected job and attempt remain fixed through refresh. Stale data is visible, and stale actions are rejected by the bridge. -- Opening a report does not claim a relay handback or inject it into model context. Sending it to Claude is a separate deliberate operation. - -A T3 notice/drawing adapter is a separate host contribution, not a relay feature we can deliver solely by adding a Mod. If the user wants persistent T3 UI first, prioritize that host work before Phase 4. - -## 5. Companion bridge and contract changes - -### What the current JSON actually provides - -All paths in this section are relative to `R/plugins/relay/scripts/` unless linked otherwise. These are verified source facts and read-only command results, not proposed behavior. - -| Current operation | Output and semantics | Consequence for a Mod | -|---|---|---| -| `status --json [id]` | One `jobViewJson` for an ID; otherwise `{jobsHome,repoSlug,jobs}`. Normal listing takes the newest 20; `--all` and `--workflow` are unbounded. A busy selected job aborts the listing. `cli/status.mjs:24`. | Useful prototype, but needs bounds, explicit partial/error policy, scope and versioning. The observed `--all` response was 1,290,527 bytes for 473 jobs; this is a snapshot, not an upper bound. | -| `result --json` | Job view plus `report`, or null with `--state-only`. Exit 0 means a completed job with a report and no owed messages; 3 means unavailable result/owed work, 4 means nonterminal, 5 means cancelled. `cli/status.mjs:12`; `lib/jobs/view.mjs:119`. | A parsed JSON object or successful process launch is not task success. Reports must be bounded and pinned to an attempt. | -| `setup --json` | `{ready,jobsHome,settings,backends,actionsTaken,nextSteps}`. Read-only without configuration flags. Default overview exits 0 even if no backend is ready; a selected unready backend exits 1. `cli/setup.mjs:36`, `:72`. | Read `ready` and individual backend blocks. Do not derive readiness solely from the process exit code. Probe on demand. | -| `steer` / `continue` / `resume --json` | Message, outcome, spawned flag and resulting state. No outcome yields exit 4; resulting state may be null after a busy read. `cli/message.mjs:12`, `:41`, `:61`. | Acceptance, acknowledgement and current state are separate facts. A missing state read does not justify reposting. | -| `cancel --json` | Final job view and an `actions` array; it can enqueue, signal process trees, take a lease or record a no-op receipt. `cli/cancel.mjs:128`. | Cancellation needs its own resumable receipt handling; it is not just another generic message action. | - -The inspected result was 5,115 bytes and setup 9,320 bytes. These samples prove the present shapes, not scale limits. Evidence files are `relay-status.json`, `relay-result.json` and `relay-setup.json` under the scratch directory; they contain local job data and are not intended as publishable fixtures. - -`jobViewJson` already exposes effective/stored state, stall/death information, attempts, model, effort, paths, summary, relay identity, violations, `handedBackAt`, automatic resumes, attempt records and undelivered/dropped messages at [view.mjs:350](/Users/darkomijic/dev-libar/relay-plugin-cc/plugins/relay/scripts/lib/jobs/view.mjs:350). However, Codex's default backend key is deliberately omitted at `:189`. Pending messages owned by a live worker are not the same as `undelivered`, which is suppressed for a live consumer at `:75`. A board cannot reliably infer a queue count from that field. - -The dispatcher at [companion.mjs:60](/Users/darkomijic/dev-libar/relay-plugin-cc/plugins/relay/scripts/companion.mjs:60) is the natural additive entry point. Its outer catch at `:113` currently writes a text error to stderr and exits 4 for `JobViewBusy`, otherwise 1, even when the caller asked for JSON. Existing CLI behavior should remain compatible. - -### Proposed read contract - -Add `companion.mjs bridge`, backed by a new `cli/bridge.mjs`. It accepts one bounded JSON request on stdin and produces one bounded JSON response on stdout. Diagnostics go to stderr. Start with `describe` and `status`; add `result`, `setup`, `request.get` and mutations in later phases. - -The envelope should contain `protocolVersion:1`, `requestId`, `operation`, `ok`, and exactly one of `data` or `error`. Errors include stable `code`, human `message`, `retryable`, and an optional retry delay. A valid envelope may describe a failed job or rejected action: transport success, operation success and job outcome must be separate fields. Define exit 0 as a delivered protocol envelope, including a structured operation error; reserve nonzero exit/no valid envelope for bridge transport failure. This convention applies only to `bridge`, not legacy commands. - -Readers ignore unknown additive fields and reject unsupported major protocol versions. `describe` reports protocol versions, relay version, operations, output limits and capabilities. Pin the Mod to the installed generated contract; do not infer compatibility from a docs page's date. - -For status, default to 20 rows and cap at 100, with a proposed serialized response cap of 512 KiB. Return explicit `backend` for every job, an opaque per-job revision, attempt identity, `sampledAt`, scope, and `nextCursor`/`hasMore`. Bound nested summaries and diagnostic arrays too, with explicit omission counts. A cursor uses deterministic creation-time/job-ID ordering and a first-page upper bound; it is not a promise of an atomic machine-wide snapshot. Scope defaults to the current repository. `--all` becomes a deliberate bounded scope, never an unrestricted bulk dump. - -For Phase 1, fail the requested read with `BUSY` rather than silently hiding a busy job. If later listing uses partial results, return per-job read errors and an explicit incomplete flag. Preserve the validated view logic in [view.mjs:48](/Users/darkomijic/dev-libar/relay-plugin-cc/plugins/relay/scripts/lib/jobs/view.mjs:48), including version fencing and settled retries. It protects a coherent job view; it does not establish a transaction spanning every job and ledger file. - -For results, return attempt identity, report identity/hash, total bytes and a bounded chunk. Subsequent chunks must retain that identity; if the underlying report changes, return `REPORT_CHANGED` and restart rather than splice two attempts. Preserve exact report text and UTF-8 boundaries. Initially refusing an oversized report with a clear location is acceptable; silently cutting its ending is not. `loadReport` at `lib/jobs/view.mjs:106` already handles archived reports and must stay the source of report selection. - -For setup, retain backend discovery in [lib/setup/page.mjs:47](/Users/darkomijic/dev-libar/relay-plugin-cc/plugins/relay/scripts/lib/setup/page.mjs:47). Expose complete registry capabilities from `lib/backends/types.d.ts:43`, not just the reduced `can` object in `lib/backends/setup.mjs:118`. Include credential/sign-in evidence, supported modes, web-search options, priority-tier support and explanatory notes. A pure request-validation operation should call the same request rules as launch; it must not start a job. - -### Proposed durable action contract - -Every mutation requires a caller-supplied request ID, a job ID and explicit operation arguments. Bind that ID to a canonical payload hash, including operation, job, message and relevant preconditions. Repeating the same ID/payload returns or advances the existing receipt; repeating it with different payload is an error. Record origin/session information for audit without making a reconnect require a new request. - -Persist the reservation before any inbox write or process signal. Place receipts under the owning job directory, with atomic writes and a per-job submission lock using the existing ownership primitives. Do not put authoritative receipts in `$.store`. Keep them queryable for the job's retention lifetime and return a clear expired/pruned result afterwards. - -For message operations, allocate one durable message ID in the reservation, then reuse it across inbox, claimed and outcome checks. [messages.mjs:17](/Users/darkomijic/dev-libar/relay-plugin-cc/plugins/relay/scripts/lib/jobs/messages.mjs:17) already accepts an ID, and `:83` checks the message locations. That alone is not idempotency: concurrent check/post and recovery still need the reservation/lock protocol. Use the existing relay UUID ledger pattern at `lib/harness/relay.mjs:639` as a reference, not as an excuse to share unrelated transcript identities with user action IDs. - -Return explicit states such as `accepted`, `applied`, `rejected` and `unresolved`, along with the message ID and authoritative outcome when known. Define `applied` as the worker's recorded action, such as queued/steered/cancelled, not completion of the resulting coding task. The Mod must never convert accepted into completed optimistically. - -Check state and attempt preconditions at the operation's mutation boundary. A continuation or resume must not start a second attempt after a concurrent caller already advanced it. A cancel intended for attempt A must not kill attempt B after automatic recovery. Ordinary UI timestamps are insufficient; pass the intended attempt plus applicable state preconditions, and have the lease owner enforce them. - -Cancellation needs dedicated work in [cli/cancel.mjs:135](/Users/darkomijic/dev-libar/relay-plugin-cc/plugins/relay/scripts/cli/cancel.mjs:135), `:158`, and `:168`: terminal no-op receipts, inbox requests and process-tree escalation must all belong to the same durable action and intended attempt. Recovery may continue that action under the existing lease/ownership rules; blindly rerunning today's cancel command is not the contract. If making the escalation recoverable is too large for the action PR, ship message controls first and leave direct Mod cancellation disabled until it is correct. - -Use a short accept path and `request.get` to reconcile. Today's [delivery.mjs:85](/Users/darkomijic/dev-libar/relay-plugin-cc/plugins/relay/scripts/lib/worker/delivery.mjs:85) can wait 60 seconds for acknowledgement, whereas `$.process.run` defaults to 30 seconds. On timeout, report "outcome unknown; reconciling request …," then query the same ID. Retry submission only with that same ID, through the idempotent endpoint. A reconnect can list unresolved requests for the selected job. No exactly-once guarantee should exceed what the worker/backend can actually prove. - -### Mod runtime constraints - -Call Node with an argv array, for example `['node', pluginRoot + '/scripts/companion.mjs', 'bridge']`, and JSON stdin; never interpolate message text into a shell. Read the string property `$.plugin.root` and await `$.session.cwd()` explicitly. Pass any needed session/agent identity from supported APIs or validated event data; do not assume `CLAUDE_ENV_FILE` exports are applied to Mod process calls. The jobs home is on the machine hosting Claude and may differ between a local terminal and a remote T3 provider. Mod-started processes run outside Claude's Bash sandbox, so retain relay's explicit backend mode and request validation rather than assuming Bash permissions govern a button or registered command. - -The Mod is not Node and cannot import the companion's npm/Node modules. Keep calls statically recognizable as `$.namespace.method` and event names/matchers literal. Same-file top-level helpers have different analyzer support from imported helpers or methods: validate the actual structure with `claude plugin validate`. Register commands so one collision cannot prevent unrelated initialization. These restrictions are in the [creation guide](https://code.claude.com/docs/en/plugins/mods/create). - -The current declarations cap **stdout and stderr separately at 4 MiB**, with truncation flags (`T:3590`, `T:8229`). Check those flags before parsing. A timed-out `process.run` kills its child; a detached relay worker may already be running. `process.spawn` belongs to the Mod and its iterator, and is killed on unload/stream closure (`T:3610`). Neither API should own a relay job's lifetime. - -The [limits](https://code.claude.com/docs/en/plugins/mods/reference#limits) also include a 10-second hook execution budget excluding `next` and most API waits, a 1-second `.catch` budget, a default 1.5-second aggregate session-end budget, 4 MiB per filesystem read/write, 4 MiB total store, and 4,096 recent session entries. Do not flush a durable delivery protocol in `session.end`. Do not wrap the whole 900-second Stop gate in a process call whose maximum is 10 minutes. - -Use `$.state` for disposable selection, cached reads and pending presentation; it supports version-checked writes (`T:3470`). It survives reload but resets on clear/resume/branch. Use `classic.SessionStart` to rebuild after those transitions; compaction also emits that classic event without resetting state. Use `$.store` for small preferences only: its cross-session read/modify/write is not atomic. None of these replace the job's durable records. - -## 6. Test strategy - -### Mod contract tests - -Use the official [Mod test kit](https://code.claude.com/docs/en/plugins/mods/test), importing `test`, `expect` and `mock` from `claude-code/testing`, and run `claude plugin test`. Stub process responses, state, store and clocks. Test terminal, Desktop and no-surface paths explicitly. A missing stub or failed handler can be skipped, so assert the expected API calls, returned text and state transitions; "did not throw" is not sufficient. - -Cover registration and command collisions, invalid arguments, unknown protocol versions, additive fields, malformed JSON, stderr diagnostics, both truncation flags, busy reads, nonterminal/cancelled/no-report results, empty lists and oversized nested records. For actions, cover timeout before reservation, timeout after acceptance, duplicate ID, changed payload, stale attempt, rejected worker outcome and reconnect reconciliation. Prove no new message ID appears during a retry. - -Use mock clocks to verify one refresh in flight, backoff, teardown and reset behavior. Use `ui.mount` for narrow/wide terminal and Desktop common elements; assert missing surfaces do not prevent command execution. Assert that routine refresh never calls `prompt.submit` or `session.send`. Test post-interruption refusals without starting an automatic retry loop: since 2.1.304, new-work APIs can refuse until the user supplies input. See [interruption behavior](https://code.claude.com/docs/en/plugins/mods/api#calls-and-background-work-after-the-user-interrupts-a-turn). - -### Companion and relay regression tests - -Add bridge contract tests with isolated temporary jobs homes and fake backends. Validate stdout framing, byte caps, page ordering, cursor behavior, report identity, typed errors and legacy output compatibility. Inject crashes between reservation, posting, claiming, outcome and receipt update. Exercise two callers with the same request, two different requests, and a worker advancing attempts during cancellation. These are the tests that justify reliable controls. - -Extend the relevant existing suites rather than duplicating their fixtures: `tests/busy-view.test.mjs`, `backend-views.test.mjs`, `setup-page.test.mjs`, `commands.test.mjs`, `message-account.test.mjs`, `relay-delivery.test.mjs`, `ownership.test.mjs`, `recovery.test.mjs`, `report-archive.test.mjs` and `process.test.mjs`. Hook/event work additionally requires `relay-guard.test.mjs`, `stop-review-gate.test.mjs`, `relay-e2e.test.mjs`, and backend-specific delivery tests including Kimi's final-output/turn-boundary cases. - -Run the affected suites during development, then the repository's `npm test`, `npm run check`, and `npm run check-version` for the release candidate. Keep test jobs entirely outside the user's production jobs home. I did not run the relay test suite in this research pass and do not claim it passes; the brief prohibited starting relay jobs, and the work was source inspection plus the permitted read-only companion calls. - -### Live host checks before release - -| Host/check | Required evidence | -|---|---| -| Terminal on 2.1.307 | Commands complete without a model turn; fallback Markdown commands and guards still work; pane controls later behave at narrow widths. | -| Streaming SDK | Zero-turn command results, expected system notices, no-surface behavior, timer activity while idle, and explicit immediate-command behavior while an actual turn is busy. The first four were exercised here; the busy-turn case was not. | -| Current T3 Claude provider | Manual registered command produces text in the thread; it does not get treated as a Markdown skill or silently disappear. Separately test queued versus send-now input during an active turn. Record the exact app/provider version. | -| Desktop local Code | Plugin loading, process access, command text and supported interface elements. This remains unverified live in this report. | -| Mod absent / older supported Claude | Classic relay remains functional under the same hook policy. Relay's current floor is 2.1.271; check how older loaders parse a `modules` entry. If it breaks classic loading, package the Mod as an optional companion plugin rather than silently raising relay's minimum. Separately verify and document that global `disableAllHooks` intentionally disables both kinds of hooks. | -| Event parity | Disposable Agent and Workflow jobs, held/refused messages, repeated text with distinct UUIDs, adoption, missing start, handback, recovery, Mod reload and session transitions. Compare against transcript/guard delivery before enabling the accelerator. | -| Process loss | Kill only disposable test processes. Confirm Mod unload never cancels jobs and request reconciliation survives worker/interface restart. Test platform behavior separately; the Linux systemd-scope protection in 2.2.0 is not proof of macOS T3 restart survival. | - -These later mutation checks require a dedicated test environment; none were performed against real relay jobs in this investigation. The scratch probe itself ran only zero-model-turn registered commands, local Node output, state/store calls, notifications and timer experiments. Plugin validation succeeded with metadata/documentation warnings saved in `probe-validation.txt`. - -Official [playground examples](https://github.com/anthropics/claude-code-playground/blob/main/claude-code/mods/README.md) remain useful references: `token-weather` for an indicator, `blast-radius` for explicit choices, `replay-theater` for browsing. They establish interface patterns, not correctness of relay delivery. - -## 7. Corrections to the preliminary research - -The preliminary document was largely right about authority boundaries. Its implementation order should change for this user's main host, and its dated evidence needs the following corrections. - -| Preliminary hypothesis | Current conclusion and evidence | -|---|---| -| Start with a board, indicator and report pane. | **Reject that priority.** T3 uses the SDK and its inspected adapter has no Mod renderer or system-notice handlers. Start with registered text commands. Terminal/Desktop UI remains useful later. | -| Print/SDK gets hooks but no interactive drawing. | **Confirmed for an unattached renderer, refined.** Commands, process calls, timers, store/state and `ui_*` system events work headlessly. The missing piece is host rendering. The experiment and `T:2439`, `T:2865` establish the distinction. | -| Mod commands can avoid a model turn. | **Confirmed empirically.** Both streaming command results reported zero turns and zero cost. Their returned text enters the conversation; `ui.log` does not. T3's handling of command replies still needs the release smoke test. | -| Typed classic hooks are not a drop-in shell-hook transport. | **Confirmed on 2.1.307.** `classic.PreToolUse` is still a bare `ToolCallEnvelope`, without the classic session/agent/transcript fields; its result uses permission/updated-input semantics. The generated declarations outrank broad docs wording. | -| Existing guards can stay while native integration is tested. | **Confirmed, strengthened by 2.2.0.** Missed-start repair, Workflow listing, per-attempt handback logs and corrected block-budget behavior now exist. Reuse them. The guards deliberately fail open on operational exceptions and eventually release counted blocks with violations. | -| Existing JSON is enough for the first read-only board. | **Only for a constrained prototype.** Unbounded scopes, large nested records/reports, omitted Codex backend, missing queue counts and unstructured errors make a stable bridge worthwhile even for the first shipped command. | -| Native receive events may replace transcript detection. | **Promising but not yet proved.** `session.receive` has no durable UUID and can observe held messages. `session.append` provides a transcript UUID and agent identity; test post-append wakeups into the existing reconciler. Do not forward event text directly to a backend. | -| Limits imply keeping Node and durable state outside the Mod. | **Confirmed, with two additions.** Process output is also bounded at 4 MiB per stream, and process children have Mod/call lifetimes. The default process timeout is shorter than today's message acknowledgement wait. | -| UI state resets and store RMW is not atomic. | **Confirmed.** Current state also supports version-checked writes, useful within a session but not a substitute for durable cross-process action accounting. Classic SessionStart and Mod session.start have different transition behavior. | -| Backend-aware controls can use setup data. | **Confirmed with more detail.** Setup's `can` is only a projection of the registry. Kimi cannot steer a live turn; queueing is valid. Readiness can mean credentials present rather than a verified live request. All four current backends must retain their own request rules. | -| Session/history work belongs in agent-harness-kit. | **Confirmed as a separate concern, not a dependency here.** The inspected kit remains at `061408df…`; its documented checkpoint protocol is at least once, with manual commits after durable ingestion. Grok/Senpi adapters are attach-only. Normal exports redact tool bodies, while raw-record paths have explicit unredacted behavior. Do not promise blanket redaction of every raw access. | -| The SDK exposes historical session reads. | **Confirmed in current official [session docs](https://code.claude.com/docs/en/agent-sdk/sessions#resume-across-hosts).** `listSessions()` and `getSessionMessages()` exist, with Python equivalents. The kit's distinction is processing/replay/checkpoints/cross-harness support, not exclusive access to history. | -| A user-installed Mod is not a machine-wide event feed. | **Confirmed.** Local event coverage, all-relay-job inspection and historical multi-harness ingestion remain three different scopes. A disabled or uninstrumented session is unknown, not evidence of inactivity. | -| UI support varies across hosts and versions. | **Update the dated matrix.** Current docs include VS Code rendering from extension 2.1.299, or 2.1.304 without feature-flag fetching. Desktop WSL plugin support remains a separate limitation. Neither fact creates T3 rendering support. See the [current interface guide](https://code.claude.com/docs/en/plugins/mods/interface#draw-in-the-vs-code-extension). | -| Routine status should stay out of model context. | **Keep the rule for passive refresh; qualify explicit commands.** In current T3, returning command text is the practical supported path to visible results and therefore adds context. Do not use model prompts as a substitute for missing passive notices. | - -Kit evidence: [README.md](/Users/darkomijic/dev-libar/libar-agent-harness-kit/README.md:3) and [tail-session.md](/Users/darkomijic/dev-libar/libar-agent-harness-kit/docs/internal/tail-session.md:262). I checked its source/docs, not a live ingestion run. The optional history/index feature and role/finding workflow controller remain deferred; relay's existing Workflow IDs justify simple grouping now without either dependency. - -## 8. Open questions only the user can answer - -1. Is useful command access in T3 enough for the first release, or is persistent T3 UI the actual requirement? My default is command access first. Persistent UI requires work in T3 as well as relay. -2. Should any failure notification wake Claude and incur a model turn? My default is no automatic wakeups; expose diagnostics on request and use passive notices where the host supports them. -3. Should repository scope remain the default, with explicit Workflow/machine scope, or should cross-repository work be the primary view? My default is repository scope, while Workflow scope follows its existing cross-repository semantics. -4. Should direct launch controls create standalone relay jobs, or must every launch participate in Claude Agent/Workflow ownership and handback? My default is to preserve the current agent-mediated launch path and ship direct controls only for existing jobs. - -These preferences do not block Phase 1. Remaining questions about T3 command routing, event approval timing, older loader compatibility and Desktop behavior are engineering validation work, not questions the user needs to solve. diff --git a/docs/mods/gpt-6.1-sol.md b/docs/mods/gpt-6.1-sol.md deleted file mode 100644 index 41ff698d..00000000 --- a/docs/mods/gpt-6.1-sol.md +++ /dev/null @@ -1,340 +0,0 @@ -# Relay Mods implementation plan, GPT-6.1 Sol - -## 1. Verdict - -Build a small Mod for deterministic commands and job visibility, with text replies that work in Claude-backed T3 sessions. -Ship `/relay-jobs` first; put a versioned companion bridge before controls, and terminal/Desktop panes after that. -Keep the worker, message ledger, recovery, `relay:job`, and all four existing hook responsibilities authoritative. -Do not budget this as a T3 dashboard: its Claude SDK sessions have no drawing surface, and other T3 providers run no Claude Mod. -The strongest later experiment is faster message observation; the current API does not prove a safe replacement for durable delivery. - -## 2. Surface matrix and evidence - -Research date: 2026-10-22. Repository: `f1283c9d13a77952d93d114185d5859e2d3bdbdb`, relay 2.2.0. Installed Claude Code: 2.1.307. All repository paths below are relative to `/Users/darkomijic/dev-libar/relay-plugin-cc` unless an absolute path is given. The repository remained unchanged. No relay job was started, steered, continued, resumed, or cancelled. - -Evidence labels distinguish an observed result, a declaration or documentation guarantee, and an inference about a host. Experiments and downloaded documents are under `/tmp/relay-mods-gpt-6.1-sol/`. - -| Capability | Interactive terminal | Claude Desktop, local Code session | Agent SDK and Claude-backed T3 | -|---|---|---|---| -| Engine events and typed `classic.*` hooks | Supported. Observed `session.start`; hooks validated against 2.1.307. | Documented support. Its bundled Claude version must be checked separately. | Supported when the plugin loads. Actual bundled-SDK probe observed `session.start` and `classic.SessionStart`. Other events are declared, not all exercised. | -| Registered commands with `{ text }` | Supported. Observed command execution. | Documented support. | Supported by the engine. Actual SDK probe returned command text with `num_turns: 0` and zero API cost. T3 forwards ordinary prompt text and can display synthetic assistant/result text; this is static evidence, not a live T3 command test. | -| `immediate: true` commands | Documented to run while a turn is active; registration was tested. | Same engine command flag; busy-session behavior not live-tested. | Engine supports the flag. A host must send the command while busy. Do not promise immediate execution through T3's ordinary composer queue; no separate Mod-command dispatch was established. | -| `$.ui.log` | Observed transcript line. Claude does not read it. | Documented transcript output. | Observed SDK `system` event with `subtype: "ui_log"`. The inspected T3 adapter has no matching display handler. SDK delivery is verified; T3 visibility is not. Plain `claude -p` text output omitted this line. | -| Panes | Observed a drawn pane and `isPlaced: true`. | Documented support, with element differences. The test kit accepted the probe pane for Desktop. | No drawing in the tested host. Observed `surfaces: []` and `ui.open` returning `isPlaced: false` with `no-surface`. | -| Indicators, `AbovePrompt`, spinner changes | Documented terminal rendering. | `AbovePrompt` supported; some other render sites are terminal-only. | No visible indicator without an attached drawing surface. Registering a render hook does not create one. | -| `$.session.send` | Declared/documented, same routing as SendMessage. | Same engine API. | Same engine API when plugin and recipient are available. No real message was sent in this investigation. Queue acceptance is not recipient execution. | -| `$.process.run`, `$.process.spawn` | `run` observed. `spawn` declared. | Documented host-process APIs; run on the Claude engine host, not in the Desktop renderer. Not live-tested. | `run` observed through the actual bundled SDK. `spawn` declared; it is suitable for a disposable watch process, not for owning relay jobs. | -| `$.store`, `$.state` | Observed reads/writes. | Declared engine APIs; not a separate Desktop database. | Observed reads/writes through the actual bundled SDK. No drawing is required. | -| OS/terminal notification | Terminal-specific, dependent on configured channel. | `$.ui.notify` reports no terminal surface. | Probe returned `isSent: false, reason: "no-surface"`. This is not a T3 notification API. | - -Mods run in the Claude Code process. They do not run in T3 threads backed by Codex, Grok, Kimi, or another provider that does not use Claude Code. A future T3-native relay view could reuse the companion bridge, but that is a separate host integration. - -The official [host matrix](https://code.claude.com/docs/en/plugins/mods/overview#where-mods-run) supports terminal, Desktop local Code, and headless hook execution. Desktop WSL sessions do not load plugins. Desktop includes its own Claude Code version; the terminal's 2.1.307 is not proof of Desktop's version. The [render-site reference](https://code.claude.com/docs/en/plugins/mods/reference#render-sites) distinguishes panes, `AbovePrompt`, and terminal-only render sites. Remote Control draws on the originating machine's terminal. The current docs confirm default Mod loading from terminal 2.1.287 and Desktop 2.1.286. VS Code drawing starts at extension 2.1.299, or 2.1.304 when feature flags are not fetched. Cloud sessions can run loaded Mod hooks but do not draw this UI. The obsolete `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` flag does not control current builds. - -### What was actually tested - -The scratch plugin generated its own installed-build declarations at: - -`/tmp/relay-mods-gpt-6.1-sol/probe/.claude-plugin/types/claude-code/index.d.ts` - -Its first line says `Written by Claude Code 2.1.307`. Relevant declarations include: - -- Commands and `immediate`: lines 1920-1944. -- SDK `ui_log` delivery: lines 2435-2451. -- Pane placement without a surface: lines 2515-2540. -- Session sending: lines 2933-2949. -- Store and reactive state: lines 3440-3502. -- Process lifecycle and output limits: lines 3580-3639 and 8207-8258. -- `classic.PreToolUse` exception: lines 1275-1288 and 8180-8200. -- Inbound messages: lines 11945-11982. -- Session startup's null headless surface: lines 12277-12301. - -I ran the plugin through both `claude -p` and the exact Agent SDK 0.3.276 library bundled in the installed T3 app. The SDK experiment used copied library modules, a scratch Claude configuration, an explicitly loaded scratch plugin, and no model prompt. `evidence/sdk-probe.jsonl` records classic startup logs, `ui_log`, the absent drawing surface, successful process/store/state calls, and a successful result with zero model turns. A live terminal session drew the pane. Two `claude plugin test` tests passed, covering the terminal/Desktop pane trees and the headless command's process/store/state behavior. This is not a live Desktop or end-to-end T3 UI test. - -Two further probes matter for the bridge: - -- A child wrote a scratch file and then exceeded a 150 ms process timeout. The Mod caught the timeout and read back the file. A rejected process call can follow a completed side effect. See `evidence/print-timeout.txt`. -- A child wrote 4,194,305 bytes. `process.run` returned 4,194,304 bytes, `isStdoutTruncated: true`, and exit 0. Successful process exit does not prove complete JSON. See `evidence/print-large.txt`. - -The [creation guide](https://code.claude.com/docs/en/plugins/mods/create#get-the-types-for-your-build) says installed-build declarations take precedence over documentation. They do here. In particular, the API includes `process.spawn`, truncation flags, and versioned `$.state` writes, which deserve explicit treatment in the implementation. - -### T3-specific findings and limits - -The inspected installed app is `0.1.1-nightly.20261020.3438`, commit `fe27e8e3dc12`. Its ASAR SHA-256 is `a904e977780162f5e3a3357ac3ca6d0a346cd0f13e2152dfbd9cab6995bf91b7`. The copied files retain their original line numbers: - -- `evidence/t3-binCli-DvdnACBz.mjs:199097` builds normal Claude query options and passes configured launch arguments onward. It does not globally disable hooks for ordinary turns. -- `:199394` and `:203907` pass user text into the SDK. `:47541` preserves leading slash commands when adding effort instructions. -- `:203896` rejects starting a second ordinary turn while one is active. Other steering paths exist, so this alone does not prove every busy command is impossible. It does show why `immediate` cannot override T3 queuing before input reaches Claude. -- `:204939` builds a separate capability probe with `settings: { disableAllHooks: true }`. That probe cannot discover commands registered by a user Mod. The normal adapter has no `commands_changed` handler in the inspected bundle, so picker discoverability requires a T3 fix or separate documentation. -- `:202650` through `:203433` handles selected SDK frames and assistant/result text. There is no `ui_log` handler or literal anywhere in this bundle. I infer that these logs are not rendered as user notices; the SDK still delivers them, and protocol logging can retain incoming frames at `:198926`. -- `evidence/t3-claudeHistoryWorker-EwZHWjIg.mjs:58530` identifies SDK 0.3.276. `:41842` updates its own command list from `commands_changed`, and `:41861` forwards messages to SDK consumers. This confirms the distinction between SDK support and T3 presentation. - -These are static findings for the installed artifact, not proof that every remote T3 server the user connects to runs the same build. REA's automated analysis timed out; the conclusions above come from direct inspection of extracted JavaScript. Its native-file integrity warnings and incomplete automated-analysis record are preserved in `evidence/t3-rea-limitations.txt`. I did not use the older migrated T3 source checkout as current evidence. - -## 3. Feature catalogue and responsibility decisions - -Sizes: S fits one focused PR; M needs several coordinated changes; L changes execution or host integration and needs a separate project. "All Claude hosts" means terminal, Desktop local Code, and loaded-plugin SDK sessions, with T3 qualifications from section 2. - -### A. Direct status command, build first, S - -- User value: `/relay-jobs` produces an accurate status table without a Claude model choosing a Bash command or rewriting the output. Include running/dead state, owed messages, relay violations, workflow provenance, and collection status. -- APIs: `session.start`, `$.command.register`, `command.run`, `$.process.run`, `$.plugin.root`, and `$.session.cwd()`. -- Runtime changes: none for the initial repository-scoped list. Call `status --json --cwd ` by argv, with explicit cwd and a five-second timeout. Validate the known 2.2.0 response and inspect truncation flags before JSON parsing. Catch command-name collisions independently. -- Hosts: all Claude hosts. Return `{ text }` for requested status, so the SDK host can show it. This text enters Claude's context but starts no model turn. T3 picker appearance is not promised. -- Risks/dependencies: declared-name conflicts, Node discovery, a changing job view, incompatible future JSON, and unexpectedly large output. A failure returns a short diagnostic, never an empty-job claim. Depends only on existing companion reads. Keep existing `/relay:status` as the fallback. - -### B. Structured report access, M - -- User value: inspect the exact report and its attempt without a relay model copying it. Add `/relay-report ` with bounded pages; a later pane can use the same contract. -- APIs: `command.run`, `$.process.run`; optional `ui.open`, `ui.render`, `Code`/Markdown elements. -- Runtime changes: a versioned report operation with byte bounds, content digest, attempt identity, continuation offset, and explicit truncation. It must read through the validated view, not reopen a mutable `report.md` after reading metadata. -- Hosts: text on all Claude hosts; inspector pane only on drawing hosts. -- Risks/dependencies: an unbounded `result --json` can exceed process limits; an attempt can change between pages. A digest mismatch restarts the read. Report text is data, never a source of automatic commands. Depends on phase 2's bridge. - -### C. Uncollected-result and failure visibility, M - -- User value: find completed relay work that nobody collected, failed jobs, and owed messages. This directly addresses the 2.2.0 survey findings. -- APIs: `$.clock.every`, `$.process.run`, `$.state`; `$.ui.log` for supported receivers. An optional `prompt.submit` hook can add a bounded pointer to a known pending result, without starting a turn. -- Runtime changes: expose a collection-status enum derived from the existing handback rules. Keep terminal/non-relay/legacy cases distinct. A missing `handedBackAt` alone does not mean a report was lost. -- Hosts: requested status everywhere; passive terminal/Desktop notices. SDK logs require host support, which inspected T3 does not provide. Defer background T3 notices until that is fixed; do not substitute unsolicited model turns. -- Risks/dependencies: repeated notices across sessions, stale snapshots, and falsely flagging pre-2.2.0 jobs. Dedupe notifications by job, attempt, and outcome, with preferences in the Mod store. Depends on bridge snapshot identities and an actual host receiver. - -### D. Direct queue, steer, continue, resume, and cancel, M to L - -- User value: deliberate controls reach the intended job without a relay model translating the request. Kimi gets "queue follow-up"; only a backend that supports it gets "steer running turn". -- APIs: registered commands and `$.process.run`; later buttons and inputs. Process requests use stdin JSON, not shell interpolation or shared message files. -- Runtime changes: durable request IDs, payload checks, preconditions, action receipts, and reconciliation. Expose allowed actions and backend meanings from the registry and current job view. Treat cancellation as its own protocol operation. -- Hosts: direct text commands on all Claude hosts when the input reaches the engine; graphical controls on terminal/Desktop. T3 busy-session controls require a host dispatch path before being advertised as immediate. -- Risks/dependencies: duplicated paid turns, cancellation of a newer attempt, and claiming that queued work already ran. Every action must display the target job and attempt and report accepted/pending/executed separately. Depends on phase 3's durable bridge. Never retry with a fresh ID after a timeout. - -### E. Job board and compact indicator, M - -- User value: inspect several jobs while Claude works. Start with this repository, then an explicit all-repositories view. Display backend, state, attempt, pending work, last coherent refresh, report availability, and collection status. -- APIs: `ui.open`, `ui.render` on `Pane` and `AbovePrompt`, `$.state`, `$.ui.panes`, `$.clock`, and the bridge. Use `$.ui.resolve(e)` for each host's elements. -- Runtime changes: bounded snapshot/detail reads only. Poll visible boards at a modest interval, with one read in flight, and use slower or no refresh when hidden. Rendering reads cached data and performs no process calls. -- Hosts: terminal/Desktop. No T3 pane in the tested SDK architecture. -- Risks/dependencies: stale selection, overlapping refreshes, crowding other Mods, and excessive machine-wide polling. Keep the previous coherent view visibly stale on busy/error; never reinterpret a busy read as a job state. Compose `AbovePrompt` with `next(e)` so other Mods remain visible. Depends on phase 2; action buttons wait for phase 3. - -### F. Workflow grouping, M, after the board - -- User value: collect jobs from one Workflow run across repositories, including adopted relay identities and reports not handed back. -- APIs: the board's APIs; no new execution events required. -- Runtime changes: expose `status --workflow ` through the read bridge. Current code already supports this filter. -- Hosts: text grouping everywhere, visual grouping on terminal/Desktop. -- Risks/dependencies: workflow label strings do not prove observer/fixer/judge roles or disputed findings. Group by stored run ID and provenance. A true review-workflow view requires an explicit workflow schema first. Depends on B/E; reject a second workflow engine inside the Mod. - -### G. Setup diagnostics and validated launch preparation, M - -- User value: see backend homes, model choices, effective settings, and reasons a backend is unavailable. Later, prepare a validated job request without manually writing a header. -- APIs: command/process APIs; optional `Input`/`Select` controls; store only nonsecret preferences. -- Runtime changes: use the backend registry and setup blocks; add a cheap static capability operation and request-validation operation. Expensive setup probes run on demand, not every board refresh. A form must show the effective permissions and home before launch. -- Hosts: diagnostic text on all Claude hosts, form on terminal/Desktop. -- Risks/dependencies: setup readiness is not proof that credentials will work on the next turn, and model/effort information can be incomplete. Distinguish credentials-present, signed-in, model discovery, and a real turn test. Setup itself launches bounded diagnostic processes but no model turn. Depends on phase 2; actual job launch waits for durable creation idempotency beyond phase 3. - -### H. Native message/lifecycle observer, M, experimental - -- User value: structured events may reveal messages earlier than the next two-minute relay Bash wait, and provide useful diagnostics even in an SDK host. -- APIs: `session.send`, `session.receive`, `session.append`, `tool.call`, `turn.complete`, and selected `classic.*` events. Initial handlers always pass through with `next(e)`. -- Runtime changes: an optional observation sink in Node, correlated against the existing relay ledger, transcript UUIDs, attempts, and handbacks. Keep the current delivery writer alone authoritative. -- Hosts: all Claude hosts while their process and plugin are alive. No GUI requirement. -- Risks/dependencies: `SessionReceiveInput` contains no durable message UUID; approval-held messages reach the hook before user acceptance; origin names/plugin claims are not authentication. `session.append` has row identity but does not by itself prove a safe mapping to the earlier message or a completed durable delivery. Depends on the bridge and a documented correlation design. Do not consume or forward messages as a second authoritative path. - -### I. Disposable background watcher, M, conditional - -- User value: avoid repeated whole-store scans when a supported receiver needs continuous updates. -- APIs: `$.process.spawn`, or bounded polling with `$.clock.every`; `next.signal` where appropriate. -- Runtime changes: only add a bounded `bridge watch` after measuring polling costs. Stream framed updates plus resync tokens; reconnect with a complete snapshot after gaps. No daemon or shared database is needed. -- Hosts: process execution works in the host engine, including SDK. The receiver must still be useful there. -- Risks/dependencies: Mod unload, leaving the async iterator, or abort kills the spawned child. Therefore the watched process must be disposable and never be the relay worker. Depends on phase 2 and measurements. Reject always-on watchers in the first PR. - -### J. Full hook migration, reject for this release, L - -Typed hooks exist, but a migration would add a transport adapter and move correctness-sensitive ordering without improving T3 drawing. Keep each responsibility as follows: - -| Current responsibility | Decision and reason | -|---|---| -| `SessionStart` shell environment exports | Keep `scripts/hooks/session-lifecycle.mjs:25`. It writes session ID and transcript path into `CLAUDE_ENV_FILE` idempotently. `session.start` initializes a Mod and re-runs on reload; it is not the same lifecycle. A Mod can observe `classic.SessionStart` for state resets. | -| `PreToolUse` relay guard | Keep `scripts/hooks/relay-guard.mjs:231` and `:349`. It binds commands to the relay's own job, delivers messages first, blocks unrelated calls, and sets wait timeouts. The current matcher also includes `StructuredOutput`, not just Bash/Write/handback. | -| Handback enforcement | Keep `relay-guard.mjs:297`. `SubagentHandback` is a tool matched under `PreToolUse`, not an event named SubagentHandback. Busy views, live work, and owed messages prevent handing back; allowed handbacks get the exact validated result. | -| `SubagentStop` fallback | Keep `relay-guard.mjs:375`. It starts missing jobs, reconciles messages, recognizes a valid prior handback, checks exact final output, and handles Workflow string returns. Preserve the eight-block release and recorded violations. | -| Stop-time review gate | Keep `scripts/hooks/stop-review-gate.mjs:194`. Only a real `BLOCK:` verdict blocks; missing, malformed, timed-out, or failed review allows the stop with a reason. It skips continuation, background work, and active repository jobs. | -| `relay:job` Bash-mediated lifecycle | Keep `agents/job.md:1`. Direct user inspection/control can bypass this agent, but Workflow adoption, slot ownership, exact handback, and recovery still depend on its existing path. | - -Current hook registration and budgets are in `hooks/hooks.json:4`, `:15`, `:26`, and `:38`: 5 seconds for SessionStart, 900 for Stop, and 120 for both guard registrations. A Mod's own execution budget is different; API/`next` waits generally do not count toward the documented 10-second hook CPU budget. The process API still has a ten-minute maximum. One 900-second shell-hook budget is not interchangeable with one `process.run` call. - -Ordering is part of the contract. Managed PreToolUse hooks run before Mod `tool.call`; ordinary/plugin PreToolUse hooks run under the final `next`. A Mod that returns a tool result without `next` can bypass the existing relay guard. `classic.PreToolUse` receives the flattened tool envelope, unlike ordinary classic stdin JSON, and cannot simply accept the existing handler unchanged. `tool.call` adds `agentId`; the classic envelope does not promise the guard's full `agent_id`, `agent_type`, and transcript input. See the [ordering and failure rules](https://code.claude.com/docs/en/plugins/mods/events#where-settings-hooks-run-in-the-order) and the installed declarations cited above. - -Relay already deliberately fails open on a thrown guard error at `relay-guard.mjs:469`, and releases repeated blocks at `:177`. A failed Mod handler is skipped by default; even a `.catch` refusal has its own failure limit. Preserve the existing behavior, including its limits. Do not describe either implementation as an unconditional fail-closed security boundary. The Stop gate's explicit review failure handling is at `stop-review-gate.mjs:104` and `:153`. - -### K. Native external-agent replacement and Mod model helpers, reject, L - -`$.agent.register/spawn` creates Claude-side agents; `$.model.complete/fork` does not run an independent tool-using Codex/Grok/Kimi harness. A registered Mod tool could call relay's bridge directly for a simpler job, but durable completion delivery and Workflow compatibility remain unsolved. This overlaps T3's own delegates for plain fan-out. Keep it outside this release instead of making a second delegation product. The [API guide](https://code.claude.com/docs/en/plugins/mods/api#call-a-model) confirms the model-helper restrictions. - -### L. Machine-wide cross-harness history, defer, L - -There is useful value in a separate historical browser, but it needs discovery, redaction, checkpoints, retention, and its own query API. Mods only observe their loaded Claude process. The existing companion already lists all relay jobs, so no agent-harness-kit integration is needed for A-F. - -If this becomes a requested product, keep the kit in Node. Its checkout remains at `061408df6623f8c6716de3ee9266d7e55018bd7c`; [README.md](/Users/darkomijic/dev-libar/libar-agent-harness-kit/README.md:16) and [tail-session.md](/Users/darkomijic/dev-libar/libar-agent-harness-kit/docs/internal/tail-session.md:260) document redaction, cross-harness adapters, per-source checkpoints, and manual commit. The [current SDK sessions docs](https://code.claude.com/docs/en/agent-sdk/sessions) also provide session enumeration and historical reads. Historical access alone is not a reason to add the kit. Its replay and cross-harness guarantees are. - -## 4. Phased plan and acceptance criteria - -### Phase 1, one small PR: `/relay-jobs` - -Add one hooks module through `hooks/hooks.json`'s `modules` key while retaining its existing `hooks` object. Register one global, valid Mod command name, `relay-jobs`, with `immediate: true`. Use the existing repository-scoped status JSON and a strict minimum decoder. Return requested text. Include version requirements and fallback instructions. No timer, pane, settings editor, or mutation path belongs in this PR. - -Acceptance: - -- Terminal and actual Agent SDK calls produce the same status interpretation with zero model API calls. -- A live Claude-backed T3 thread can execute the exact typed command and show its text. If this fails, the PR's T3 claim is blocked; existing `/relay:status` remains available. Picker discovery is a separate issue. -- Busy view, invalid JSON, a missing Node executable, timeout, and either stream's truncation flag yield explicit diagnostics. None becomes "no jobs". -- Opening or reloading the Mod changes no job files. Disabling Mods leaves all existing relay commands and hooks functional. -- Mod tests and plugin validation pass on 2.1.307; existing command/hook packaging checks stay green. - -### Phase 2: bounded read bridge and report access - -Add protocol v1, bounded snapshot/detail/report operations, canonical backend identity, collection status, and static capabilities. Add `/relay-report`; then enrich `/relay-jobs` to use the bridge. Preserve all legacy CLI JSON and exit behavior. - -Acceptance: - -- Tests cover more than 4 MiB of aggregate job/report data without producing a process response above the bridge's chosen maximum, proposed 512 KiB. -- Paging is bounded and explicitly consistent per job/report, with a resync signal if an attempt changes. No claim of an atomic machine-wide snapshot. -- A busy job can be shown as a retryable item alongside other coherent rows; it is never silently omitted. Existing legacy all-or-nothing busy behavior remains unchanged. -- Archived/current report races, pre-2.2.0 collection history, and default-backend records render correctly. -- Capability reads do not start setup servers; full setup is on demand. - -### Phase 3: durable action contract, then direct text controls - -Implement action IDs, receipt lookup, payload-conflict detection, target preconditions, and short acceptance replies. Add explicit Mod commands for queue/steer/continue/resume/cancel only after these invariants pass. Defer new-job launch until creation idempotency is separately implemented. - -Acceptance: - -- Sequential and concurrent replay of the same ID cannot enqueue a second message or cancellation; reusing an ID for different content returns conflict. -- Kill the bridge after persistence/enqueue but before stdout. Reconcile by the original ID and report pending or the recorded outcome. Never resend under a fresh ID. -- Enforce expected attempt/action eligibility at the point of execution. A pre-read in the Mod is insufficient. A cancel of an outdated target cannot silently kill a newly resumed attempt. -- Reload/clear the Mod between acceptance and acknowledgement; the companion still owns a queryable outcome. -- Kimi queueing, true steering where available, owed work after recovery, completed-job cancellation, and backend failures retain existing semantics. -- Do not claim T3 controls run while busy until a live host dispatch check proves it. - -### Phase 4: terminal/Desktop board, then workflow/setup views - -Build the board from the read bridge. Add controls from phase 3, a compact opt-in indicator, workflow grouping, and setup diagnostics. Ship each as a separate feature rather than one UI rewrite. - -Acceptance: - -- Check narrow/wide terminal layouts and a real Desktop local Code session, not only simulated element trees. -- `clear`, `resume`, `branch`, hot reload, and session end reset/rebuild only presentation state. The same detached worker continues. -- Unreadable/busy snapshots show the last coherent data with a timestamp and stale reason. -- Selected job/attempt identities stay stable across sorting and refresh. Unsupported actions stay unavailable with the backend's reason. -- Multiple Claude sessions can open the view without racing on durable relay state or overwriting shared preference documents. -- An SDK run opens no pane, starts no status-only model turn, and retains the text commands. - -### Phase 5: optional observation experiment - -Record structured message and lifecycle observations beside the existing relay evidence. Measure message latency and correspondence under approval, interruption, clear/resume, adoption, and reload. Do not remove a hook in this phase. - -Acceptance: - -- Demonstrate a durable identity mapping or explicitly record that it is unavailable. Equal text/time is not sufficient identity. -- One delivery writer remains in use, with no duplicate backend work. -- If a host rejects inbound messaging or a user stops the session, the observer does not synthesize permission or restart work. -- Promotion of any responsibility needs a separate parity proposal supported by replay/crash results. A failed experiment can be removed without changing the worker or job records. - -## 5. Companion bridge and contract changes - -### Current behavior verified in 2.2.0 - -I ran the three permitted read commands. Evidence is saved as `evidence/status-all.json`, `result.json`, `setup.json`, and `metadata.json`. - -| Command | Actual output and behavior | Consequence | -|---|---|---| -| `status --json --all` | `{ jobsHome, repoSlug, jobs }`; 473 jobs, 1,290,527 bytes in this snapshot. Per-job objects include computed/stored state, attempt records, report path, provenance, server details, violations, handedBackAt, undelivered and dropped messages. No protocol/version envelope. | Fine for research, poor as a fixed-interval UI protocol. Default repository status limits to 20; `--all` does not. | -| `result --json` | A job-view object plus `report`, inline in full, or null. The sampled completed report was 2,389 bytes and exited 0. | Not the shape of the list envelope. Report size is unbounded by this CLI. Nonzero exit does not automatically mean malformed JSON. | -| `setup --json` | `{ ready, jobsHome, settings, backends, actionsTaken, nextSteps }`; five home blocks here, 9,319 bytes. Blocks include backend/home, binary/version, sign-in, model/effort/model list, `can`, warnings, ready/error and timing. `actionsTaken` was empty. | Useful on demand. `ready` in the overview means at least one home is ready, not every backend, and not a completed real-turn test. | - -The relevant implementations are: - -- `scripts/companion.mjs:60` dispatches operations; `:113` catches errors, writes human stderr, and maps busy to exit 4. Even under `--json`, error output is not a guaranteed JSON envelope. -- `scripts/cli/status.mjs:12` emits result; `:24` emits status; `:42` limits normal lists; `:49` propagates busy views; `:59` constructs the list envelope. -- `scripts/lib/jobs/view.mjs:48` constructs coherent job views and `:88` retries busy reads. `:119` maps result exits to 0/3/4/5. Status for a single coherent job returns normally even if that job failed; it is not the same exit protocol as result. -- `scripts/lib/jobs/view.mjs:350` builds JSON. `:189` omits the default backend field; the new protocol must normalize it through the registry. `:305` and `:312` implement known handback status, including legacy history. -- `scripts/cli/setup.mjs:36` handles setup, `:54` applies switches, and `:72` emits a page. Setup with no switches was read-only configuration-wise. `scripts/lib/setup/page.mjs:89` constructs the page; `:15` defines command/server deadlines. -- `scripts/cli/message.mjs:41` and `:61` accept steer/continue/resume JSON outputs, but provide no caller request-ID option. Busy after a successful send yields `state: null`, not a failed send. `scripts/lib/worker/delivery.mjs:87` defaults acknowledgement waits to 60 seconds. - -### Proposed contract - -Keep `companion.mjs` as a dispatcher. Add a `bridge` case and a module such as `scripts/cli/bridge.mjs`; put reusable logic below `cli` following `docs/architecture.md:24`. Do not put execution rules in the Mod. - -Use `$.process.run(['node', companionPath, 'bridge'], { cwd, stdin: JSON.stringify(request), timeoutMs })`. Resolve the companion under `$.plugin.root`. The Node executable must be configurable/discoverable for GUI-launched hosts. Set cwd explicitly and resolve relative job-home configuration consistently. No shell expansion, no rendered labels as selectors, and no direct Mod reads/writes of execution files. - -A request names `protocol`, `protocolVersion`, `requestId`, `operation`, scope/cwd, and operation input. Every response, including error/busy/conflict, names the same protocol/version/ID, relay version, response status, and either data or a structured error. Publish a schema and protocol support range. These are proposed names, not current companion fields. - -Read operations: - -1. `capabilities`: cheap backend declarations, supported bridge operations, limits, and settings scope. Obtain backend facts from `lib/backends/index.mjs:1` and `types.d.ts:47`, not from a second Mod registry. -2. `snapshot`: bounded summary rows and cursor, filtering by repository/workflow/all. Bound record sizes as well as row count. Return generated time and per-item identity/revision; a list across jobs is not one transaction. -3. `job`: detail with allowed actions, authoritative/backend state distinctions, pending work, attempt identity, and collection status. Keep unavailable collection history distinct from not-collected. -4. `report`: bounded UTF-8 chunks tied to an immutable captured report identity/digest, with attempt, total bytes, next offset, and a changed-report error. Never trust a mutable file path as the identity. -5. `request`: read an action's durable receipt and current outcome without executing it again. - -Action operations: - -- Require the ID before any side effect. Persist a per-job command receipt with a normalized payload hash and chosen inbox message ID under a concurrency-safe lock. Same ID/same payload returns the receipt; different payload conflicts. -- Reuse the existing message/outcome machinery. `lib/jobs/messages.mjs:17` can accept an explicit ID, `:63` writes outcomes, and `:83` checks all message locations. It does not alone make concurrent reposting idempotent. The relay's own locked posting at `lib/harness/relay.mjs:577` and `:638` shows the stronger pattern to retain. -- Return durable acceptance quickly; do not spend the Mod's 30-second default waiting for a 60-second acknowledgement. Lookup distinguishes accepted, worker-acknowledged, queued-next-turn, executed, dropped, failed, and unresolved. -- Carry `expectedAttempt` or an equivalent revision where an action is attempt-specific. Enforce the precondition in the worker/lease path, not only in a prior status read. Cancellation uses `cli/cancel.mjs:128` and its identity-aware process path; it must not be implemented as a Mod `kill` command. -- After process timeout, rejection, malformed/truncated output, or Mod reload, query by the same ID. Absence/unknown is not proof that an operation never started. Repeating the same ID is safe only after durable deduplication covers every crash window. -- New job launch requires a separate durable request-to-job mapping before spawning. Do not infer creation idempotency from message IDs or Workflow adoption keys. This is deferred until that mapping and its crash tests exist. - -Preserve the current human CLI and JSON contracts. Add the bridge rather than silently wrapping every existing JSON result. The worker remains the owner of `job.json` while leased; the Mod store is not an action ledger. `$.store` belongs to the plugin across sessions and its read-modify-write sequences are not atomic. Prefer independent preference keys and keep execution coordination in Node. `$.state`'s version checks help reject stale UI updates inside one session; they do not replace durable command coordination. - -## 6. Test strategy - -### Mod test kit - -Use `claude plugin validate` and `claude plugin test` against the installed version, with generated declarations excluded from hand maintenance. The scratch run already passed two tests; production tests need the behavior below. The [test guide](https://code.claude.com/docs/en/plugins/mods/test) explains explicit event startup, mocks, and the need to assert effects when failed hooks can be skipped. - -- Stub command registration and fire `session.start` explicitly. Assert command names and `immediate`, then assert actual reply text and process argv/cwd/stdin. A test that only finishes is insufficient. -- Stub process results for success, valid nonzero status results, busy exit 4 without JSON, malformed JSON, incompatible protocol, thrown timeout, and both truncation flags. -- Use `mock.clock` for polling. Verify only one read is in flight and a slow earlier read cannot overwrite a newer view. Test background process disposal independently of worker lifetime. -- Use store mocks and real test-kit state for reload/reset behavior; separately fire `classic.SessionStart` with clear/resume/fork. Assert command registration is not incorrectly repeated on those transitions. -- Mount Pane/AbovePrompt on terminal/Desktop and press/select/input by stable keys. Assert unsupported actions, stale status, paging, close/reopen, and selection after reordered data. Simulated trees do not verify real painting. -- For observation hooks, assert `next` receives the unchanged event and its result stays unchanged. Test refusal, held approval, subagent targeting, duplicate observations, and absence of durable message identity. - -### Existing Node tests and bridge additions - -Keep the existing suite authoritative for execution behavior. Relevant files are `tests/busy-view.test.mjs`, `report-archive.test.mjs`, `backend-views.test.mjs`, `message-account.test.mjs`, `relay-delivery.test.mjs`, `relay-guard.test.mjs`, `recovery.test.mjs`, `runtime-timers.test.mjs`, `process.test.mjs`, `setup-page.test.mjs`, `commands.test.mjs`, and `stop-review-gate.test.mjs`. - -Add protocol tests using isolated scratch roots and fakes for pagination bounds, schema compatibility, collection-history semantics, same-ID conflicts, concurrent callers, and kills at each receipt/enqueue/ack/stdout boundary. Reuse the existing fake backends for Kimi queueing versus true steering and exact report/attempt races. Do not weaken existing guard checks merely to accommodate a Mod. - -For implementation PRs, run `npm run check`, targeted Node tests, Mod validation/tests, then the normal full suite when execution code changes. This research did not run the relay job suites or change implementation code; it ran only the isolated Mod experiments, Mod tests, and permitted read-only companion commands. - -### Live acceptance - -Use throwaway jobs only in an explicitly authorized implementation-validation session. Check the configured Claude binary/home in each target host, because Desktop and remote T3 hosts can differ. - -Exercise terminal, real Desktop, and a real Claude-backed T3 thread. Record zero-turn status/report command output, Mod load/discovery, SDK `ui_log` handling, busy-command queue behavior, and survival of jobs across Mod reload/clear/close. For mutations, explicitly test lost replies and recovery, stale-attempt cancellation, Kimi follow-ups, relay adoption, and exact handback. A T3-native change should add `ui_log` mapping and dynamic command updates without treating ordinary notices as model prompts. - -## 7. Corrections to the preliminary research - -1. **The broad host matrix is confirmed, but "no interactive drawing" is incomplete as a product decision.** The SDK can run commands without a model turn and receives `ui_log`. T3 must handle that event before it becomes visible. A UI-first terminal plan does not match this user's main host. -2. **An installed Mod can work headlessly; a Mod newly authored inside an unattended session may not load.** The [creation guide](https://code.claude.com/docs/en/plugins/mods/create#sessions-that-skip-the-approval) distinguishes explicit installed/local plugin loading from session-authored Mods that need approval. The scratch plugin used explicit `--plugin-dir`/SDK local-plugin loading. -3. **The proposed job-board-first sequence should change.** Lead with one deterministic status command, then a bounded bridge. The current `--all` call already returns about 1.29 MB for 473 jobs; report and list growth can hit the verified 4 MiB process cap. -4. **The old declarations are superseded.** This investigation generated 2.1.307 declarations. `classic.PreToolUse` still has the exceptional flattened envelope, and its result remains `allow`/`ask`/`deny` plus updated input/context. Other classic results use normalized fields. The public events guide's blanket stdin-shape wording should not override these types. -5. **The static-analysis restriction was slightly overstated.** Current [creation docs](https://code.claude.com/docs/en/plugins/mods/create#check-what-claude-code-reads-from-your-mod) permit passing `$` to a top-level helper in the same file. Imported helpers/methods and namespace aliasing remain restricted. Literal event registrations are still required. -6. **The runtime limits are confirmed and need two additions.** `process.run` also caps each output stream and reports truncation explicitly. `process.spawn` exists for streaming and ends its child with the iterator/module lifecycle. Neither should own a detached relay job. The Mod itself still has no Node APIs, npm imports, or timer globals; host access goes through `$`. Store/state limits and reset behavior remain relevant; `$.state.set` also has version preconditions. -7. **Current relay metadata is richer than the preliminary plan.** 2.2.0 adds `handedBackAt`, per-attempt `relayVersion`, Workflow filtering, more reliable missing-start recovery, and an eight-block-budget fix. Use these facts before inventing new history machinery. Collection history is unknown for old attempts. -8. **The current guard includes `StructuredOutput`.** Preserve its Workflow schema refusal, exact/state-only handback behavior, lazy start, job binding, and recorded violations. Typed hooks are not a drop-in replacement for this logic or its input fields. -9. **The timeout/idempotency warning is confirmed experimentally.** A process timed out after its side effect. CLI message JSON still lacks caller-controlled request IDs, although internal relay delivery already chooses durable IDs under a lock. Expose that discipline before adding retryable controls. -10. **Backend-specific controls and setup caveats are confirmed.** Kimi cannot steer over ACP; setup's `can` objects are available today. Setup overview readiness is existential and does not verify a real turn. An automatic live "health check" would be new paid work, not a harmless setup refresh. -11. **Machine-wide observation and the kit remain separate concerns.** User-scope installation does not collect every process. The kit's checked commit still has the documented redaction/checkpoint/cross-harness facilities; SDK session enumeration/history is also current. Neither is required for a relay job board. -12. **The sample choices are confirmed, but their role is limited.** The official [playground README](https://github.com/anthropics/claude-code-playground/blob/main/claude-code/mods/README.md) lists `token-weather`, `blast-radius`, and `replay-theater`. They are UI patterns, not proof of relay parity, T3 rendering, or durable action handling. - -The preliminary architecture boundary is sound: relay owns execution, an optional Node history component owns ingestion, and the Mod owns presentation and deliberate interaction. What changes is the order of investment and the specificity of the compatibility claim. - -## 8. Open questions only the user can answer - -- Which T3 Claude provider profiles and remote machines need support, and how much of your relay use is in Claude-backed threads versus other providers? A Mod cannot extend the latter. -- Is a small T3 change to display `ui_log`, refresh Mod commands, and route busy-session commands in scope, or must relay work with T3 unchanged? Without it, I would ship requested text commands and defer passive T3 notices. -- Which direct actions should be allowed by default, and should cancellation require a second explicit confirmation or one fully specified command? These are product choices after the durable contract is implemented. -- Should uncollected reports appear only when requested, as passive notices, or as a bounded pointer in the next prompt's context? I recommend requested text first and no automatic model turn. -- Is cross-repository visibility desired by default, or should every session begin scoped to its repository? I recommend repository scope with an explicit all-repositories action. diff --git a/docs/mods/plan.md b/docs/mods/plan.md deleted file mode 100644 index 0794459a..00000000 --- a/docs/mods/plan.md +++ /dev/null @@ -1,323 +0,0 @@ -# Relay and Claude Code Mods: the plan - -Written 22 October 2026 against relay 2.2.0 (main @ f1283c9) and Claude Code 2.1.307. It combines three -independent plans in this directory: - -- [claude-opus-5-5.md](claude-opus-5-5.md) (Opus 5.5, high) -- [gpt-6.1-sol.md](gpt-6.1-sol.md) (GPT-6.1 Sol, xhigh) -- [gpt-6-astra.md](gpt-6-astra.md) (GPT-6 Astra, xhigh) - -All three followed the same brief, [BRIEF.md](BRIEF.md), and checked the preliminary research -([summary](preliminary-research.md)) against the code and the 2.1.307 declarations. The evidence they cite under `/tmp/relay-mods-*` is local scratch and is not committed. -Parts of it hold local job data. The brief framed the work around one user's T3 Code setup. The product -decisions below replace that framing: relay is open source, and most of its users run Claude Code in a terminal -or in Desktop. - -## Rules this plan follows - -1. **Terminal and Desktop come first, and T3 Code is supported where it can be.** Most relay users don't run it - inside T3 Code. Everything the module draws targets the terminal and Desktop. What runs without drawing - (event hooks, commands) also works in Agent SDK hosts, and is expected to work in T3. -2. **Relay agents should behave like Claude agents.** A main thread or an orchestrator should steer, continue, - stop and spawn a `relay:job` agent with the same calls and the same results as a Claude subagent. -3. **Everyone runs a current Claude Code.** The floor becomes the build the module is verified on, 2.1.307. No - code or prose exists for older builds. -4. **One solution for each thing.** When the module takes over a job, the old way goes. A fallback exists only on - the branch while the module is being proven. Each PR's last commit removes it after the live suite passes, so - no release ever ships a fallback. -5. **The module takes the events only a Mod sees, the commands, and the drawing; tool-call handling stays in - command hooks.** A Mod hook runs above the plugin's command hooks and is skipped when it fails. That makes it - the wrong place for the guard and the right place for `session.send`, which no command hook sees. -6. **One test machine.** All development and live testing run on the Fedora ThinkPad, which does nothing else - meanwhile. -7. **Every PR reviews the rest of the plan.** A phase's PR ends with a review of every later phase, in light of - what the PR learned: what the live suite showed, what the declarations or the code turned out to be, what was - harder or easier than planned. The review records which assumptions held, which phases change and why, and which - questions are new. Its last commit updates this file to match, so the plan on `main` is always the current one. - The PR body summarises the review. - -## What the investigation established - -These findings come from experiments that the three plans repeated independently. The cited plan sections hold -the transcripts and the line numbers in the declarations. - -- **The module loads everywhere the plugin loads.** That covers the terminal, Desktop, `claude -p` and Agent SDK - hosts such as T3. Panes, the status line, toasts and log rows are drawn in the terminal and Desktop only. In an - SDK host, `$.session.surfaces()` is empty and `$.ui.open` returns `no-surface`. The `ui_*` messages reach the - host, and T3 renders none of them today (Opus §2, Sol §2, Astra §2). -- **A command the module answers costs no model turn.** `command.run` answers report `num_turns: 0` and - `total_cost_usd: 0`. The module can answer the plugin's own markdown command and keep its `relay:` name (Opus, - relaycopy). -- **`session.send` fires for every message that leaves the conversation.** That includes the SendMessage tool - from the main thread or from any subagent, and the module's own `$.session.send` (2.1.307 declarations, - `SessionSendInput`, `SessionSendOrigin`). It fired 6 ms after the tool call. A classic `PostToolUse` on - `SendMessage` fired at 51 ms and doesn't see `$.session.send` (Opus §2). The receiver's `session.receive` - fires only at its next tool round, 25.9 s later in the experiment. -- **`$.agent.list()` lists the session's agents with their ids.** `session.send`'s `to` can be a name or an id, - so the module can resolve either. -- **`agent.spawn` fires for the Agent tool and for a Workflow script's `agent()`.** It can deny a spawn with a - reason, and the caller gets that reason as an error. -- **The Mod API has no event for an agent being stopped.** `TaskStop` is a tool call, so a command hook sees it. -- **`$.process.run` cuts each stream at 4 MiB and still exits 0.** It only sets the truncation flags. It can also - time out after its child has done its work (Sol §2, both measured). `status --all --json` is 1.29 MB for 473 - jobs. - -## Steering, and the other agent calls - -The [design](../design.md#the-interface-the-main-thread-sees) compares a `relay:job` agent with a Claude -agent. These are the calls where the two still differ, and what this plan does about each. - -| Call | Claude agent | `relay:job` agent today | After this plan | -|---|---|---|---| -| `SendMessage` while it runs | reaches it between tool calls | reaches the job within about two minutes | about two seconds, plus the backend's own delivery (below) | -| `SendMessage` after it ends | continues it with its context | a new turn on the same backend session | unchanged: already the same | -| `TaskStop` | stops it | stops only the relay; the job keeps running | stops the relay and cancels the job | -| Spawn with a bad parameter | the Agent call fails at once | a relay starts, `start` fails, the relay hands the error back | the Agent call, or the Workflow `agent()`, fails at once with the parser's message | -| A message from the agent to its parent or a peer | `SendMessage` | not possible: a backend has no way to send one | investigated in Phase 6 | - -**How a steer travels today.** The lead's `SendMessage` queues the message for the relay agent. The relay is a -Haiku agent blocked in `companion wait`, which the guard bounds at 120 seconds. When the wait returns, the relay -makes its next tool call. The `PreToolUse` guard reads the message from the relay's transcript and records it in -`inbox/relay/` under the relay lock, which makes it run exactly once. The worker then hands it to the backend: - -- Codex: `turn/steer` into the running turn. -- Grok: `_x.ai/interject` into the running turn. -- ZCode: `sendText` with `guide` into the running turn. -- Kimi: as the next turn. ACP has no steer, so a running Kimi turn can't take a message. - -The waiting is the slow part: up to 120 seconds between the send and the guard. - -**What changes.** The module's `session.send` hook sees the message as it leaves the sender. It resolves `to` to -an agent id and runs `companion wake --agent `. When that agent is a relay whose job is running, the companion -writes a wake marker into the job directory. The relay's `wait` sees the marker within a second and returns. The -guard delivers the message on the same path as before. This is the most complete option, for three reasons: - -- One trigger covers every sender: the main thread, an orchestrator subagent, a teammate, and the board's buttons, - which use `$.session.send`. -- It draws nothing, so it works in Agent SDK hosts too (tested). Whether it works in T3 is untested; Phase 1's live suite will check it. -- Delivery keeps one path, the guard's ledger. Nothing else posts a message, so nothing can deliver it twice. - -The plans' alternatives were weaker. Sol and Astra planned separate direct steer commands with their own -receipts, which would be a second delivery path. Astra planned a reconciler driven by receiver-side events, which -fire only at the next tool round. Opus planned a classic `PostToolUse` hook, which can't see `$.session.send`. - -Once the wake is proven, the 120-second wait is no longer how messages arrive; it only bounds a tool call. It -grows to 540 seconds, inside the Bash tool's 600-second maximum. A long job then costs its Haiku relay about a -quarter of the model calls it costs today, and steering stays as fast. The backend still decides what a steer -means: Kimi runs it as the next turn, and the board and the docs say so. - -## Phase 1: the module, and steer wake (one PR) - -- `hooks/hooks.json`: add `"modules": ["./mod/register.js"]` beside `"hooks"`. Opus verified that the two load - together and that `claude plugin validate` passes. -- `hooks/mod/register.js`: one `session.send` hook. It awaits `next(e)`. When the send was delivered, it resolves - `e.to` through `$.agent.list()` and runs `$.process.run(['node', $.plugin.root + '/scripts/companion.mjs', - 'wake', '--agent', id])`. It returns what `next` returned, and it never refuses or rewrites a message. -- `cli/wake.mjs` (new): finds the relay pointer for the agent (`readRelayPointer`, `lib/harness/relay.mjs:292`; - `findRelayJob`, `:312`) and writes `/relay-wake.json` when the job is not terminal. Otherwise it exits 0 - and writes nothing. -- A wake stays pending until a guard check covers it. The guard records the time it read the transcript - (`/relay-wake-checked.json`) before it delivers. `cli/wait.mjs:41-68`, under `--exit-zero-while-running`, - leaves with the "still running" line while a marker is newer than the last check. The wait's own start time - plays no part. A message sent between the guard's read and the next `wait` keeps its marker pending, and that - wait returns at once instead of running out its bound. `lib/jobs/store.mjs:93` `jobPaths` gets both paths. -- Floor: README, CHANGELOG and design.md move to Claude Code 2.1.307. Design.md's "The relay needs no Claude Code - mod" is replaced by what the module does. -- **On the branch only:** the 120-second wait. The PR's last commit raises it to 540 seconds (the guard sets the - wait and Bash timeouts) and replaces "within about two minutes" in the README, `agents/job.md` and design.md. - -Acceptance, all on the Fedora machine: -- Unit: `wake` writes the marker for a running job's relay, and writes nothing for an unknown agent, a terminal - job or a missing jobs home. `wait` returns within 2 s of a marker newer than the last guard check, waits on - through one the guard has covered, and ignores markers without the flag. A marker written after the guard's - read but before the next `wait` starts makes that wait return at once. This test must pass before the wait - grows to 540 seconds. -- Mod test kit: the hook calls `next` once and returns its result unchanged. It resolves a name through - `$.agent.list()`. A thrown `process.run` doesn't change the send's result. -- Live suite, in the terminal and in T3: for each backend, a steer of a running job reaches `inbox/relay/` within - 10 s of the `SendMessage` call, three times out of three. That includes a send from a Workflow subagent, and a - `$.session.send` from a test module. A continue after the hand-back works as before. - -## Phase 2: commands that answer without a model turn (one PR) - -`/relay:status`, `/relay:result`, `/relay:cancel` and `/relay:transfer` start no model turn, cost no tokens and -show the companion's output exactly. How long a command takes is the companion's time, not a turn's. -`status` and `result` take under a second. `cancel` can take more than 10 seconds: it waits -`RELAY_JOBS_CANCEL_GRACE_MS` (10 s) before it kills the worker. `transfer` waits up to two minutes for Codex's -import (`lib/backends/codex/transfer.mjs:11`). Each handler's `$.process.run` timeout is set above its command's -own deadline, at 60 s for `cancel` and 180 s for `transfer`, so the module never cuts off a cancel or a transfer -that would have succeeded. Today `status.md` and `result.md` ask a model to render or repeat that -output, and all four cost a turn. (`transfer` wasn't in the delegates' plans; it follows the same pattern of -running the companion and showing its output.) - -- Four `command.run` handlers on the plugin's own command names. Each runs the companion with argv (never a - shell) and returns `{ text }`. Either truncation flag, a timeout or a missing exit code becomes an error text, - never "no jobs". They run when the current turn ends, on every surface. `immediate` is a property of a command - the module registers itself; the 2.1.307 declarations make it read-only for any other command - (`CommandDescribeInput.immediate`). In the terminal and Desktop, the board (Phase 4) is how to see jobs during a - turn. -- `renderJobList` (`lib/jobs/view.mjs:325`) prints the Markdown table that `status.md` currently asks the model to - build. It becomes the only human format, so `C status` in Bash prints it too. -- `result` keeps `renderResultText` (`view.mjs:278`), because the guard's hand-back compares against it - (`relay-guard.mjs:346`, `:421`). Any change to how it reads goes there, so the hand-back and the command keep - one format. -- **On the branch only:** each handler's `.catch` falls through to the markdown body, which first says "the relay - module did not answer this command". In the PR's last commit, `.catch` returns an error text instead. Each - markdown body then shrinks to one line ("The relay module answers this command. If you read this, it did not - load: say so and stop."), and the frontmatter keeps the name and description for the command picker. - -Acceptance: `claude plugin validate` passes. A Mod test per command checks argv, cwd and the text passed -through, and covers truncation, timeout, busy (exit 4) and stderr. Node goldens cover the status table. In the -live suite, each command shows its output with no model reply in the terminal and in T3. `claude -p -"/relay:status" --output-format stream-json` reports `num_turns: 0`. - -## Phase 3: spawn and stop like a Claude agent (one PR) - -- **Spawn check.** The module adds an `agent.spawn` hook for `subagentType: "relay:job"`. It runs `companion - check-header` on the prompt, which uses `parseHeaderPrompt` and `buildLaunchRequest` (`lib/jobs/request.mjs:29`, - `:123`) and creates nothing. A bad header returns `{ deny: }`. `start` keeps the same parser, - because it builds the job from it. The check only moves the failure to the spawn. -- **TaskStop cancels the job.** Add a `PostToolUse` command hook with the matcher `TaskStop`. When the stopped - task is a relay agent with a running job, it runs `companion cancel` for that job. This reverses design.md's - "TaskStop stops only the agent, and the job keeps running". An orchestrator's TaskStop is an explicit stop, and - the job is what it means to stop. A relay that dies without a TaskStop (session end, a killed process) still - leaves its job running, so jobs still outlive Claude. -- The design table and the `agents/job.md` description change to match. - -Acceptance: a Workflow `agent()` with a bad header throws the parser's message, and a valid one starts as before. -In the live suite, TaskStop on a running relay leaves the job `cancelled` within 15 s for each backend. Ending the -Claude session during a job leaves the job running. Check first that TaskStop's input names the agent the hook -needs. If it doesn't, record what it does name and how the hook maps it. - -## Phase 4: the terminal and Desktop board (separate PRs) - -1. **Status line.** `$.ui.status` shows "2 running · 1 owed" for this repository. The module polls only while - `$.session.surfaces()` is non-empty, with one read in flight. -2. **Board pane** `/relay:board`. It lists the jobs, their backend, state, attempt, owed messages and hand-back - state, and shows the selected job's report in a `Markdown` element. Its actions are: - - **Steer** and **Continue** call `$.session.send` to the job's relay agent, the same path as the lead's - `SendMessage`. They are offered only when `$.agent.list()` holds that relay. The label follows the backend: - "Queue next turn" for Kimi. - - **Cancel** runs the same `companion cancel` as `/relay:cancel`, and names the job and attempt again when - pressed. -3. **Companion JSON, changed in place.** The stop gate, `setup.md` and the jobs skill change in the same PR: - - every `--json` output carries `schema: "relay./1"` and `relayVersion`; - - under `--json`, errors are JSON on stdout (busy keeps exit 4); - - listings are bounded, with a cursor, and every row names its backend (`view.mjs:189` omits Codex today); - - a slim row by default, with full attempt records only for one job; - - cancel takes an expected attempt, checked in `cli/cancel.mjs`, so a press can't cancel a newer attempt - after an automatic resume. - -Rendering reads cached `$.state` and never runs a process. After `/clear`, `/resume` or `/branch` the board -rebuilds from the companion. - -Acceptance: Mod tests mount the pane on `terminal` and `desktop`, press each action and assert its call. With an -empty `surfaces()`, nothing polls. The live suite runs the board in a narrow and a wide terminal on Fedora. -Claude Desktop has no Linux build, so the Desktop check is the test kit's `desktop` mount, plus one manual pass on -a Mac before release (open question 2). - -## Phase 5: a scripted live suite - -Every acceptance step marked "live suite" above is a script, not a manual check. It runs on the Fedora machine -against real backends and writes one record per check, naming the backend, model, relay version, Claude Code -version, surface, result and timing. The suite grows with each phase. Its records are the evidence for a public -capability table per backend, which the [funding principles](#impact-of-the-funding-notes) call for. The table shows -only what the suite has passed. - -## Phase 6: messages from a job (investigation, no code yet) - -A Claude subagent can `SendMessage` its parent or a peer. A relay job can't. Find out whether a backend can be -given a "send to lead" tool (an MCP server the relay adds to the job) whose messages the module delivers with -`$.session.send` as the relay agent's own. Report what each backend allows, and how a reply would find its way -back, before writing any code. - -## Later plans - -These features are being considered and will get plans of their own. They are listed here so that this plan -leaves room for them: - -- **Model-family guidance.** How each model family behaves, what it does well, and how families complement each - other in one piece of work. -- **Orchestration strategies for different kinds of work**, such as planning a coding session, executing one, - architecture design, UX and UI work, and deep research. -- **Orchestration templates.** Skills and UI together: for example, choosing a template on the board to start a - strategy. The template picker would build on Phase 4's board and its companion JSON. - -## What stays where it is - -| Responsibility | Where it stays | Why | -|---|---|---| -| Session exports (`session-lifecycle.mjs`) | `SessionStart` command hook | `CLAUDE_ENV_FILE` is a settings-hook contract. A Mod's `session.start` doesn't fire on `/clear`, `/resume` or `/branch`. | -| Relay guard, including `SubagentHandback` and `StructuredOutput` | `PreToolUse` command hook | In a Mod it would run above the guard's place in the chain and be skipped when it fails. It needs Node for locks and transcript reads either way. | -| `SubagentStop` guard | Command hook | Same reasons, plus the eight-block release and the recorded violations. | -| Stop review gate | Command hook | It runs up to 900 s, past `$.process.run`'s 10-minute maximum. | -| Message delivery | The guard and the `inbox/relay/` ledger | The wake only shortens the wait; it posts nothing. | -| The `relay:job` agent | Unchanged | It supplies the background run, the completion notice, `SendMessage` and the Workflow slot. | -| `/relay:setup`, `/relay:review`, `/relay:adversarial-review`, `/relay:rescue` | Model-run markdown commands | They need the model: they ask questions, choose options or present findings. | - -## Rejected - -- **Moving the guard, hand-back, stop gate or session export into a Mod:** see the table above. -- **Direct steer and continue commands with their own receipts** (Sol D, Astra F2): a second delivery path. The - board's buttons send as the lead does. -- **A reconciler driven by receiver-side events** (Astra F3): those events fire at the next tool round. -- **New command names** (`relay-jobs`, `relay-status`): a second name for the same command, and command pickers - that list markdown commands don't list them. Registering them would make the commands `immediate` in the terminal. - That is the one thing these names would add, and the board covers it. -- **A `bridge` subcommand beside the current JSON** (Sol, Astra): a second interface. The JSON changes in place. -- **A setup form:** `/relay:setup` asks questions and installs things. -- **`$.model.*` summaries of reports, and a model-callable status tool:** reports are shown exactly as written, - and the model already calls the companion through Bash. -- **agent-harness-kit and a history browser:** a separate product. - -## Corrections to the preliminary research - -All three plans confirmed its authority split: relay owns execution, the Mod owns commands and presentation, and -the kit owns history. They corrected these points: - -- **SDK hosts:** they run the module's hooks, commands and process calls, and they receive the `ui_*` messages. - Whether those messages are drawn is up to the host. -- **`immediate` commands:** they run during a turn in the terminal. Over stream-json they run when the turn ends. - Only a command the module registers can be `immediate`. -- **"Relay already exposes enough for a board":** not as an interface for a module. Phase 4 covers the gaps: no - version, errors in stderr text, an unbounded `--all`, and `$.process.run`'s silent truncation. -- **Hook ordering:** Mod hooks run above the plugin's command hooks. The research didn't say so, and it decides - where the guard stays. -- **Static analysis:** `$` may be passed to a top-level helper in the same file. Only imported helpers and methods - are restricted (Sol §7). -- **Declarations:** 2.1.307's replace 2.1.277's. The `classic.PreToolUse` exception still holds. -- **2.2.0 additions:** `handedBackAt`, per-attempt `relayVersion`, `--workflow` listing and missed-start recovery - are already in place. The status table and the board use them. - -## Impact of the funding notes - -The funding principles ([funding.md](funding.md)) add no product feature. Four points affect this plan: - -- **A capability matrix proved by tests** ("compatibility is earned by tests"). Phase 5 makes the live acceptance - checks a scripted suite whose records back such a table. Its rows line up with this plan: steering during - execution (Phase 1), continue (unchanged), stop (Phase 3), dynamic Workflow joining (Phase 3's spawn check), - and agent-to-parent and agent-to-agent messages (Phase 6, unsupported until proven). -- **A short demo** of steering a running job while a second agent joins. It needs Phase 1's - two-second steer to be watchable, and the board to be visible. That is one more reason the terminal board is in - scope. -- **Exact backend semantics.** Kimi takes no mid-turn steer, and read-only - modes are not an OS sandbox. The board labels Kimi's message as "Queue next turn", and the matrix states the - limit instead of marking it supported. -- **No paid tier or paid UI.** The module and the board ship in the same open-source plugin. - -## T3 Code - -In Agent SDK runs, the module's hooks and commands work, so Phases 1 to 3 are expected to work in T3 as well. -No plan has tested this in T3 yet; Phase 1's and Phase 2's live suites will. Phase 4 needs two things -from T3: it has to render the `ui_*` messages, and it has to attach a surface. The module refreshes the status -line only while `$.session.surfaces()` is non-empty. [t3-code.md](t3-code.md) explains what relay would need from T3 and why relay complements T3's -own delegates. It is the article to link from a post to the T3 community. - -## Open questions - -1. **TaskStop:** Phase 3 makes a TaskStop on a relay agent cancel its job, which reverses a documented decision. - Is that what you want? -2. **Desktop:** Fedora can't run Claude Desktop. Is the test kit's `desktop` mount enough, plus one manual pass on - a Mac before each release that changes the board? -3. **Wait bound:** 540 seconds once the wake is proven, or another value under the Bash tool's 600-second - maximum? diff --git a/docs/mods/preliminary-research.md b/docs/mods/preliminary-research.md deleted file mode 100644 index e9944ca9..00000000 --- a/docs/mods/preliminary-research.md +++ /dev/null @@ -1,39 +0,0 @@ -# The preliminary research - -A summary of the research that started this plan, "Claude Mods for relay: what to build, what to reuse, and what -not to replace". It was written on 17 October 2026 by a web research tool, against relay PR #1 at 7dad14c and Mod -declarations from Claude Code 2.1.277. It read the code and the docs; it ran nothing. The three plans in this -directory checked it against relay 2.2.0 and Claude Code 2.1.307. -[plan.md](plan.md#corrections-to-the-preliminary-research) lists what they corrected. - -## Its claims - -- **Mods are a released extension API, not a frozen contract.** They load by default from Claude Code 2.1.287 in - the terminal and from 2.1.286 in Desktop, and the declarations a build generates outrank the docs. `claude -p` - and Agent SDK hosts run a Mod's hooks but draw nothing. -- **Order of work:** a live job board and report inspector first. Then direct controls that respect each - backend's capabilities, then a backend, model and effort selector, then a view for review workflows. Deeper - event integration and a history browser come later. -- **The companion is the boundary.** The Mod calls `status --json`, `result --json` and `setup --json`. Relay keeps - sole ownership of job state, and the Mod never writes job files. A busy read keeps the last view, marked stale. -- **Direct controls** should keep each backend's own meanings apart: steer a running turn, queue a follow-up (Kimi), - continue after the report, and cancel. A destructive action names its target again when it runs. An action - needs a request id, so that a timed-out call can be checked instead of resent. -- **Classic hooks are reachable from Mods (`classic.*`), but not unchanged.** `classic.PreToolUse` gets a tool-call - envelope, and results are normalised. Migrating a hook needs an adapter and parity tests, so relay's four hooks - stay as they are. -- **agent-harness-kit** stays the layer for observing sessions across a machine and across harnesses: discovery, - incremental reads, checkpoints and redaction. A Mod sees only its own Claude process. -- **The runtime is not Node.** It has no filesystem or process modules and no npm imports. Calls must stay - recognisable as `$.namespace.method`. The limits: - - `$.session.messages()`: the newest 4,096 entries; - - `$.fs` reads and writes: 4 MiB per file; - - `$.store`: 4 MiB in total; - - `$.process.run`: 30 s by default, 10 min at most. - - `$.state` resets on `/clear`, `/resume` and `/branch`. -- **Mod-started processes run outside Claude's Bash sandbox,** so a button is not a security boundary. -- **Stages:** a read-only Mod, then controls and configuration, then native events run alongside the current - hooks before any replaces them, then history, then the review-workflow view. -- **Rule:** "The interface may disappear. Observation may lag. The job must still run correctly, and its next - interface must be able to reconstruct what happened." diff --git a/docs/mods/t3-code-discord-post.md b/docs/mods/t3-code-discord-post.md deleted file mode 100644 index 0339f71c..00000000 --- a/docs/mods/t3-code-discord-post.md +++ /dev/null @@ -1,21 +0,0 @@ -# Draft: post for the T3 Code Discord - -Not posted. Replace `` with the URL of `docs/mods/t3-code.md` on `main` once it is merged. - ---- - -Hi all. I maintain relay, an open-source Claude Code plugin that runs Codex, Grok Build, ZCode (GLM) and Kimi -Code jobs as Claude agents: a main thread or a Workflow script starts them with the Agent tool, steers them with -SendMessage and gets their reports back. - -It complements T3 Code rather than competing with it. For a plain fan-out, T3's own delegates are the better -fit. Relay is for dynamic workloads inside Claude Code: Workflow scripts and Claude orchestrators whose agents -need another harness. A Workflow script can't call T3's tools. - -We're adding a Claude Code Mod to relay. Its hooks and commands work in Agent SDK hosts, so they should work in T3 too; we haven't tested that live yet. What a Mod draws doesn't -show: `system/ui_status`, `ui_toast` and `ui_log` arrive in the stream with no handler, and module commands -don't reach the picker. None of that is relay-specific; it affects every Mod. - -I wrote up what we found and the small changes that would help, smallest first: - -Are any of these planned, and would PRs be welcome? diff --git a/docs/mods/t3-code.md b/docs/mods/t3-code.md deleted file mode 100644 index dd81e731..00000000 --- a/docs/mods/t3-code.md +++ /dev/null @@ -1,65 +0,0 @@ -# Relay in T3 Code: what works and what we'd need - -This note is for T3 Code maintainers and users. It explains what relay is, how it fits next to T3 Code, and -what T3 would need for relay's upcoming Claude Code Mod to show everything it can show elsewhere. The -findings come from T3 Code 0.1.1-nightly.20261020.3438 (commit fe27e8e3dc12) with Claude Code 2.1.307 and Agent -SDK 0.3.276. Some of them come from reading T3's shipped JavaScript, not from its source. Corrections are -welcome. - -## What relay is - -[relay](../../README.md) is an open-source Claude Code plugin. It runs jobs on Codex, Grok Build, ZCode (GLM) -and Kimi Code as Claude agents. A main thread, or a Workflow script, starts a `relay:job` agent with the Agent -tool. It steers the job with `SendMessage`, continues it after the report with another message, and gets the -report back as the agent's result. Each job has a detached worker and survives the Claude session. - -## Relay complements T3 Code - -Relay doesn't replace any part of T3 Code. In a T3 thread, T3's own threads, delegates and task tracking come -first. For a plain fan-out of independent agents (reviews, research, one-off fixes), a T3 delegate is the better -choice: it runs on any provider in T3's catalog, as a thread of its own, with that harness's full features, and -no Claude agent waits on it. - -Relay is mainly for **dynamic workloads inside Claude Code**: Workflow scripts and Claude orchestrators whose -agents need to run on another harness. A Workflow script can't call T3's tools. A relay job takes part in it as -an ordinary agent. It holds a Workflow slot, is adopted again when a run resumes, takes steering, and returns a -state block the script can read. Most relay users run it in a plain Claude Code terminal or in Claude Desktop, -not in T3. - -## The Claude Code Mod - -Relay is adding a Claude Code Mod ([the plan](plan.md)). Its parts don't all work in T3: - -| Part | Terminal and Desktop | T3 Code today | -|---|---|---| -| A `session.send` hook that wakes a relay when it is sent a message, so a steer arrives in about two seconds | works | expected: event hooks ran in Agent SDK tests; not yet tested in T3 | -| `/relay:status`, `/relay:result`, `/relay:cancel` and `/relay:transfer` answered by the module, with no model turn | works | expected when typed: the SDK returns the answer as an assistant message, which T3's adapter displays (from reading its code); not yet tested in T3 | -| A status line ("2 running · 1 owed") with `$.ui.status` | drawn | not shown: arrives as `system/ui_status`, with no handler | -| Notices with `$.ui.toast` and `$.ui.log` | drawn | not shown: `system/ui_toast` and `system/ui_log`, with no handler | -| A job board pane with `$.ui.open` | drawn | not placed: the SDK host attaches no surface (`no-surface`) | -| Commands the module registers itself | in the picker | not listed: T3's capability probe runs with `disableAllHooks`, and its Claude adapter doesn't handle `commands_changed` | -| `immediate: true` commands during a turn | run at once | run when the turn ends | - -## What would help, smallest change first - -1. **Render `system/ui_status` and `system/ui_toast`** from the Claude stream, and attach a surface that says - so. For example, show the status as a chip on the thread and the toast as a transient notice. Rendering alone - isn't enough: a Mod checks `$.session.surfaces()` before it refreshes anything, and that list is empty in T3 - today. With both changes, relay's status line would work in T3 with no relay change. What a surface - declaring status-only rendering would look like is a question for T3 and the Claude Code SDK. -2. **Render `system/ui_log`** as a muted row in the thread. Claude Code documents these lines as visible to the - person and not to the model. -3. **List module commands in the picker.** Read them from the session's `commands_changed` message, or run the - capability probe with plugin modules loaded. -4. **Send `immediate` commands at once during a busy turn**, instead of queueing them behind the turn. -5. **Attach a drawing surface for panes.** This is the largest of the five, and relay doesn't need it to be - useful in T3. - -None of these is relay-specific. Each one helps every Claude Code Mod that a T3 user installs. - -## What we'd like to know - -- Are any of these changes planned, or would PRs for them be welcome? -- Is the stream-json `system` subtype the right place for T3 to pick up Mod output, or does T3 prefer another - route? -- Does a remote T3 server (one T3 client, Claude running on another machine) need anything else? diff --git a/docs/relay-2.2.2-validation.md b/docs/relay-2.2.2-validation.md new file mode 100644 index 00000000..d60c1f7b --- /dev/null +++ b/docs/relay-2.2.2-validation.md @@ -0,0 +1,134 @@ +# Relay 2.2.2: review and validation record + +This is the public, self-contained record of the 2.2.2 release review. It covers the changes from 2.2.1, +the independent follow-up review, the defects that review found, and the live checks. All examples and +fixtures were synthetic. Session ids, job ids, local paths, credentials and private logs are deliberately +not part of this record. + +The evidence below was collected on 31 October 2026 unless otherwise dated. The last section records +the final check of the release candidate. + +## Review provenance + +The initial Opus session reported Node 22 and 24 CI green and documented fixes from Astra, CodeRabbit and +Greptile: Grok TOML key detection, the setup warning's refusal wording, the probe description, untracked-name +size accounting, non-ASCII untracked paths, and human text beside a tool result. It also left three limits on +the evidence: Codex's instructions did not consistently trigger the output pragma, Grok's MCP budget had not +been measured live, and short jobs did not show whether larger budgets improved results. + +A second holistic review inspected the whole 2.2.1→2.2.2 diff and the surrounding job, transport and review +contracts. A fresh CodeRabbit run over the then-current PR head independently found the non-ASCII filename +accounting defect and labeled it minor. Partial background audit leads were not counted as completed reviews; +each included finding below was independently reproduced or checked against source. + +## Follow-up findings and resolutions + +| Area | Evidence before the fix | Resolution and durable regression | +| --- | --- | --- | +| Serialized review size | 3,000 empty non-ASCII files passed with 237,780 estimated bytes but produced 303,865 context bytes. Git status octal-escapes the names. | `cli/review.mjs` checks the actual serialized repository context against 256 KiB. `reviews-backends.test.mjs` includes this file set. | +| Decoded review size | One 100,000-byte invalid-UTF-8 file passed with 100,054 estimated bytes but produced 300,126 context bytes; U+FFFD expansion tripled it. | The same final byte check rejects it. The suite includes a NUL-free invalid-UTF-8 file. | +| Exact boundaries | Existing tests measured the estimator's own arithmetic, not the text sent to the backend. | Working-tree and branch tests now derive an exact 256 KiB context and refuse one additional byte. | +| Oversized-file reads | An untracked file already over the limit was read in full while constructing the lightweight context used for refusal. | `lib/review/git.mjs` gives such files a zero-byte inline allowance, so they are named without being read. A mocked read assertion proves the file is not opened. | +| Mixed transcript entries | The final fix preserved human text beside a tool result, but equivalent peer and coordinator entries were silently discarded. | `lib/harness/relay.mjs` preserves text for all three recognized origins. `relay-guard.test.mjs` covers all three and still excludes tool-result-only entries. | +| Unknown transcript origins | A non-metadata entry with an unknown origin fell through as a plain user message; before the prompt it could become the prompt. | Every unrecognized origin is reported and rejected, independent of `isMeta`, both before and after the prompt. | +| Grok TOML detection | A `[mcp]` block inside a valid multiline string was treated as configuration, suppressing Relay's default budget. | Dependency-free detection now masks comments and multiline strings, parses quoted dotted keys and examines only actual inline-table keys. Tests include basic and literal multiline strings, string lookalikes, escaped quoted keys and inline tables. | +| Close-timeout reporting | A worker could replace `did not exit after SIGKILL` with `server ended ... (exit unknown)` although the process might still run. | `lib/worker/worker.mjs` preserves the timeout error. `server-end.test.mjs` exercises the in-process worker path. | +| Documentation | The design page described only the 50 ms SIGTERM stage and coordinator/plain-user messages. Grok's MCP save was described as unconditional, and the README named 2.2.0. | The design now describes the five-second SIGTERM stage, five-second SIGKILL wait, abandonment and current message filtering. Grok's save is described as best-effort. Release metadata is aligned. | + +A post-push CodeRabbit review found two remaining cases, also fixed: an unknown origin carrying both text +and a tool result now reaches origin validation and records a violation, while a tool-result-only entry stays +out; and an early-NUL binary file is rejected from its 4 KiB sample without a full `readFileSync`. Both have +regressions, including a no-full-read assertion for a 300 KiB binary file. + +The original mixed-human-entry, non-ASCII file-read, pre-first-turn cancellation, symlink and SIGTERM→SIGKILL +fixes were also checked. The live peer and coordinator checks below exercise their ordinary delivery paths; +the mixed text/tool-result shapes remain synthetic edge cases, not shapes observed in those live runs. + +## Automated verification before this follow-up + +- PR CI passed the complete suite and type check on Node 22 and Node 24. +- A separate local run on Node 24.19.0 passed 647 tests with zero failures and zero cancellations. +- Version metadata and `git diff --check` passed. +- The 2.2.1 Codex budget suite passed 40 tests. No additional 2.2.1 correctness defect was confirmed. + +## Live validation before this follow-up + +All jobs used short synthetic prompts and isolated job storage. The plugin was loaded from the PR directory, +not installed globally. + +| Path | Versions and settings | Result | +| --- | --- | --- | +| Direct Codex job | Codex CLI 0.166.0, `gpt-6-luna`, low effort, read-only | Completed with the exact expected token. | +| Direct Grok job | Grok Build 1.0.70, `grok-4.7`, low effort, workspace mode | Completed with the exact expected token. Setup independently reproduced the read-only profile refusal. | +| Direct ZCode job | ZCode 3.16.2-38, runtime 0.17.0, GLM-5.3-Flash, low effort, read-only | Completed with the exact expected token. | +| Direct Kimi job | Kimi Code 2.4.1, `kimi-code/kimi-for-coding` (K2.8 Preview), high effort, read-only | Completed with the exact expected token. | +| Regular Claude fan-out | Claude Code 2.1.316, two background `relay:job` agents | Both exact tokens returned and both hand-backs recorded. | +| Dynamic Workflow | Claude Code 2.1.316, computed loop with two `relay:job` calls | Both exact tokens and state blocks returned. Distinct journal keys matched the journal's `started` entries and both hand-backs were recorded. | +| Coordinator correction | A user correction forwarded with `SendMessage` | One queued coordinator entry, one continuation and the requested final token; no parser problem. | +| Peer correction | A general-purpose agent sent one message to the relay agent | One actual `origin: peer` entry, one continuation and `PEER_STEER_OK`; no parser problem. | + +The assertion pass checked ten exact reports across the direct and focused scenarios, eight relay hand-backs, +two Workflow journal keys, and coordinator and peer delivery. Twelve isolated jobs, including an initial +fan-out fixture that was superseded by a non-conflicting fixture, completed with no active jobs and no relay +violations. + +## Evidence that remains limited + +- The live messages arrived as `queued_command` entries. Mixed text/tool-result entries and direct human-origin + wrappers were not observed live; their coverage is synthetic. +- Grok's larger MCP budget is confirmed in its source and environment tests, but its effect on a real MCP result + has not been measured. +- Codex's output instructions are accurate, but earlier live jobs did not consistently use the pragma on their + first call. Short synthetic jobs do not establish that a larger budget improves answers. +- Live success on ordinary paths does not replace the adversarial regressions above or prove behavior on every + future Claude Code transcript shape. + +## Follow-up verification + +The focused review, relay-guard, Grok and server-end suites pass with the new regressions. On Node 24.19.0 the +full `npm test` run passed **648 tests with zero failures and zero cancellations**. `npm run check`, +`npm run check-version` and `git diff --check` passed. + +The updated code also passed live follow-up checks on Claude Code 2.1.316: direct Codex and Grok jobs returned +exact tokens; a dynamic Workflow returned both computed jobs with exact tokens, journal keys and hand-backs; +and a real peer correction was delivered exactly once and returned `POST_FIX_PEER_OK`. All five isolated jobs +completed with no active jobs and no relay violations. The caller-visible Workflow output, report files and +actual transcript origins were checked, not only the Claude sessions' final prose. + + +## Review follow-up on 1 November 2026 + +- With only a project `[mcp] max_output_bytes = 4096`, Relay injected a higher-priority environment + value of 262144. The launcher now leaves the cap to Grok when a project file declares one. A + fake-backend job started below the Git root checks that the environment has no Relay override. + A second job checks that a config above the checkout does not suppress the default. +- An untracked symbolic link included the contents of its external target in a working-tree + review. The collector now names the link and skips its contents in both review modes. The + regression uses a synthetic marker outside the repository and checks the serialized context. +- A near-limit fixture with 100 empty files and one text file was refused even though its + serialized context was 262,037 bytes. Removing the guessed overhead per file leaves the + final UTF-8 byte check authoritative for headings and fences. The regression verifies a + multi-file context of exactly 262,144 bytes reaches the backend. +- A home-managed MCP cap of 4,000 bytes also produced a Relay override of 262,144. The launcher + now checks `managed_config.toml` in the Grok home and the system config directory. The tests + exercise both layers without modifying machine policy. + +## Final candidate check on 3 November 2026 + +The code that changed after the earlier live runs (Grok cap detection and untracked-file collection +for reviews) was read again in full. Every caller of the Grok launch environment passes the working +directory that the project-config walk needs. + +The final candidate then ran live on macOS with Claude Code 2.1.317, Codex CLI 0.166.0, Grok Build +1.0.70, ZCode 3.16.2 and Kimi Code 2.4.1. The plugin was loaded with `--plugin-dir` through a symbolic +link to its folder, the case 2.2.1 fails, with the installed copy disabled, a private jobs home and a +synthetic Git repository. Inherited `CLAUDE*` and `RELAY_COMPANION_*` variables were cleared, so no job +could attach to an outer session. + +| Path | Result | +| --- | --- | +| Four `relay:job` agents in parallel, one per backend, read-only: `gpt-6-luna`, `grok-4.7-build-fast`, `glm-5.3-flash`, `kimi-code/kimi-for-coding` | All four completed with their exact tokens and one recorded hand-back each. Every job record names version 2.2.2. No backend process remained. | +| Grok inline review of a working tree with one tracked edit, two untracked files and an untracked symbolic link to a file outside the repository | Accepted (2.2.1 refused a third file) and found the planted defect. The link appeared as `(skipped: symbolic link)`; a marker in its target appears nowhere in the stored job files. | + +Typed human messages in the terminal UI, a real Grok MCP result above 20,000 bytes and Codex's use of +the output pragma remain as described under "Evidence that remains limited". diff --git a/package-lock.json b/package-lock.json index bee90739..42db4ff8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@libar-dev/relay-plugin-cc", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@libar-dev/relay-plugin-cc", - "version": "2.2.1", + "version": "2.2.2", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index 0692994c..b2784843 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@libar-dev/relay-plugin-cc", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.", diff --git a/plugins/relay/.claude-plugin/plugin.json b/plugins/relay/.claude-plugin/plugin.json index 626c4f80..4c480e9f 100644 --- a/plugins/relay/.claude-plugin/plugin.json +++ b/plugins/relay/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "relay", - "version": "2.2.1", + "version": "2.2.2", "description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.", "author": { "name": "Libar" diff --git a/plugins/relay/CHANGELOG.md b/plugins/relay/CHANGELOG.md index f7911fd9..82f8fc66 100644 --- a/plugins/relay/CHANGELOG.md +++ b/plugins/relay/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 2.2.2 (2026-11-03) + +2.x is the final Relay line and takes bug fixes only. + +- Relay jobs work when the plugin is reached through a symlinked folder. Node gives the hook its real + path, so the guard never ran and every `relay:job` failed with `The hook did not run`. +- A message from another agent, or one a person types into a running agent, reaches the job. Only the + parent's did; the others were dropped with nothing recorded, a typed `cancel` included. Coordinator, + peer and human text all survive a shared text/tool-result entry. A task notification stays out, and + a message of any other origin is reported, whether or not the entry is metadata. +- A backend that ignores EOF and SIGTERM gets SIGKILL within about five seconds, and one that survives + that is given up on after another five seconds, so a worker's cleanup no longer waits forever. It is + not recorded as ended, so the next worker's sweep still finds it, and its close timeout is reported + as a timeout rather than an unknown exit. +- A job cancelled before its first turn never runs its brief, whether its worker arrives late or the job + is resumed later. +- Reviews on Grok, ZCode and Kimi take any number of files within the 256 KiB limit. A third file used + to be refused however small the diff. The limit applies to the actual serialized repository context, + including Git's quoted names and decoded file contents; a non-ASCII name no longer drops the file's + contents from the review. An oversized untracked file is named without being read when the review is + already refused. Reviews name untracked symbolic links without reading their targets. +- Codex's developer instructions say that `max_output_tokens` on a nested `exec_command` alone leaves + the 10,000-token cap in place, that large evidence is better read by line range, and to read what a + truncation notice names before relying on the result. +- Grok jobs get a larger MCP result budget, 262,144 bytes, unless the environment, managed policy, home or project + configuration names one. + Detection distinguishes actual settings from lookalikes in TOML strings and handles quoted, dotted + and inline-table keys. +- Setup warns when Grok refuses its `read-only` profile on the machine, which fails read-only jobs and + every Grok review. +- [docs/harness-output-limits.md](../../docs/harness-output-limits.md) records every harness's limits, and + `scripts/tool-output-probe.mjs` writes the marker file for measuring them again after an upgrade. +- [docs/relay-2.2.2-validation.md](../../docs/relay-2.2.2-validation.md) records the independent review, + the defects it found and their regressions, and the live backend, fan-out and dynamic-Workflow checks. + ## 2.2.1 (2026-10-30) - Codex jobs and reviews no longer lose the middle of large tool results. Codex keeps 10,000 diff --git a/plugins/relay/commands/adversarial-review.md b/plugins/relay/commands/adversarial-review.md index 307796c8..74de3e20 100644 --- a/plugins/relay/commands/adversarial-review.md +++ b/plugins/relay/commands/adversarial-review.md @@ -41,7 +41,7 @@ Backend selection (the flags mirror a job header's keys): - A combination a job would refuse (`--grok-home` without Grok, `--kimi-home` without Kimi) is refused the same way, before anything runs. - Every review is read-only and recorded as a job: `/relay:status` lists it, with `backend:` on Grok, ZCode and Kimi as for a job. -The answer is review JSON. Codex is held to the schema by its own `outputSchema`; Grok, ZCode and Kimi get the schema in the prompt and the plugin checks the answer. An answer that is not review JSON fails the review (` returned no valid review JSON`), keeps the raw answer in the report and exits non-zero; there is no second try. A turn that fails is not an answer: the review says `The turn failed: ` with the backend's error and exits non-zero. Grok, ZCode and Kimi read only the inlined diff: above two files or 256 KiB the review is refused with a message that names `--base`, `--scope` and Codex. A Grok review always runs under Grok's `read-only` profile; where Grok refuses that profile (on a Mac whose `/var/run/docker.sock` is a symlink, or on Linux where it cannot reach a runtime socket such as `/run/podman/podman.sock`), the review fails with Grok's error. A Kimi review runs in Kimi's `default` mode: every permission request is rejected, and relay refuses the file writes and terminal commands Kimi sends through the client. That is not an OS sandbox: Kimi's hooks, the MCP servers and plugins it starts, and its other local operations can still have side effects. +The answer is review JSON. Codex is held to the schema by its own `outputSchema`; Grok, ZCode and Kimi get the schema in the prompt and the plugin checks the answer. An answer that is not review JSON fails the review (` returned no valid review JSON`), keeps the raw answer in the report and exits non-zero; there is no second try. A turn that fails is not an answer: the review says `The turn failed: ` with the backend's error and exits non-zero. Grok, ZCode and Kimi read only the inlined diff: above 256 KiB the review is refused with a message that names `--base`, `--scope` and Codex. A Grok review always runs under Grok's `read-only` profile; where Grok refuses that profile (on a Mac whose `/var/run/docker.sock` is a symlink, or on Linux where it cannot reach a runtime socket such as `/run/podman/podman.sock`), the review fails with Grok's error. A Kimi review runs in Kimi's `default` mode: every permission request is rejected, and relay refuses the file writes and terminal commands Kimi sends through the client. That is not an OS sandbox: Kimi's hooks, the MCP servers and plugins it starts, and its other local operations can still have side effects. Argument handling: - Preserve the user's arguments exactly. diff --git a/plugins/relay/commands/review.md b/plugins/relay/commands/review.md index da3501e2..c1b5c502 100644 --- a/plugins/relay/commands/review.md +++ b/plugins/relay/commands/review.md @@ -40,7 +40,7 @@ Backend selection (the flags mirror a job header's keys): - On Codex, `--effort` is ignored with a `note:` line: Codex's own reviewer takes no effort. - Every review is read-only and recorded as a job: `/relay:status` lists it, with `backend:` on Grok, ZCode and Kimi as for a job. -Grok, ZCode and Kimi read only the inlined diff: above two files or 256 KiB the review is refused with a message that names `--base`, `--scope` and Codex. Return that message as it is. A Grok review always runs under Grok's `read-only` profile; where Grok refuses that profile (on a Mac whose `/var/run/docker.sock` is a symlink, or on Linux where it cannot reach a runtime socket such as `/run/podman/podman.sock`), the review fails with Grok's error. A Kimi review runs in Kimi's `default` mode: every permission request is rejected, and relay refuses the file writes and terminal commands Kimi sends through the client. That is not an OS sandbox: Kimi's hooks, the MCP servers and plugins it starts, and its other local operations can still have side effects. +Grok, ZCode and Kimi read only the inlined diff: above 256 KiB the review is refused with a message that names `--base`, `--scope` and Codex. Return that message as it is. A Grok review always runs under Grok's `read-only` profile; where Grok refuses that profile (on a Mac whose `/var/run/docker.sock` is a symlink, or on Linux where it cannot reach a runtime socket such as `/run/podman/podman.sock`), the review fails with Grok's error. A Kimi review runs in Kimi's `default` mode: every permission request is rejected, and relay refuses the file writes and terminal commands Kimi sends through the client. That is not an OS sandbox: Kimi's hooks, the MCP servers and plugins it starts, and its other local operations can still have side effects. Argument handling: - Preserve the user's arguments exactly. diff --git a/plugins/relay/commands/setup.md b/plugins/relay/commands/setup.md index d897a588..a6c90404 100644 --- a/plugins/relay/commands/setup.md +++ b/plugins/relay/commands/setup.md @@ -16,7 +16,7 @@ Codex home (`~/.codex` or `CODEX_HOME`), `~/.grok` and every `~/.grok-`, Z `--grok-home H` selects a Grok home and `--kimi-home H` a Kimi home, and the command exits non-zero when that backend is not ready. Setup makes no model call: Codex answers `account/read`, `config/read` and `model/list` on an app-server it stops -afterwards, Grok answers `--version` and `grok models`, and for ZCode setup reads `--version`, +afterwards, Grok answers `--version` and `grok models`, and `grok models` again under its read-only profile, so a refused profile shows as a warning, and for ZCode setup reads `--version`, whether the credentials file from `zcode login` is a regular file of more than two bytes (the file is never opened) and the default model, then asks one `zcode app-server` for every model the sign-in serves: it creates a draft session, reads its model list (what ZCode's model picker diff --git a/plugins/relay/scripts/cli/cancel.mjs b/plugins/relay/scripts/cli/cancel.mjs index 45d663c5..841341ed 100644 --- a/plugins/relay/scripts/cli/cancel.mjs +++ b/plugins/relay/scripts/cli/cancel.mjs @@ -62,6 +62,7 @@ function markCancelledAfterKill(jobDir, reason) { ...job, attempts, state: "cancelled", + pendingFirstTurn: false, errorMessage: reason, completedAt: nowIso(), cancelledBy: "cancel command" diff --git a/plugins/relay/scripts/cli/review.mjs b/plugins/relay/scripts/cli/review.mjs index 941f9bfa..82d7b96c 100644 --- a/plugins/relay/scripts/cli/review.mjs +++ b/plugins/relay/scripts/cli/review.mjs @@ -139,19 +139,17 @@ function buildReviewRequest(options, cwd, reviewName) { } -// An inline review needs the whole diff. Untracked files count toward the limits and are -// inlined whole; a refusal precedes job creation and starting a backend process. +// An inline review needs the whole diff, and its serialized repository context must fit the byte +// limit: Git quotes names in status, and decoding can change a file's byte length. A refusal +// precedes job creation and starting a backend process. function inlineReviewContext(cwd, target, backend) { - const context = collectReviewContext(cwd, target, { untrackedInFull: true }); - if (context.inputMode === "inline-diff") { + const context = collectReviewContext(cwd, target, { untrackedInFull: true, maxInlineFiles: Infinity }); + if (context.inputMode === "inline-diff" && Buffer.byteLength(context.content, "utf8") <= DEFAULT_INLINE_DIFF_MAX_BYTES) { return context; } const files = `${context.fileCount} file${context.fileCount === 1 ? "" : "s"}`; - const size = context.diffBytes > DEFAULT_INLINE_DIFF_MAX_BYTES - ? `more than ${DEFAULT_INLINE_DIFF_MAX_BYTES / 1024} KiB` - : `${Math.ceil(context.diffBytes / 1024)} KiB`; throw new UsageError( - `The diff is ${files} / ${size}; a ${backend.label} review reads only an inlined diff. Narrow it with --base or --scope, ${backend.review.inlineDiffAdvice}` + `The diff is ${files} / more than ${DEFAULT_INLINE_DIFF_MAX_BYTES / 1024} KiB; a ${backend.label} review reads only an inlined diff. Narrow it with --base or --scope, ${backend.review.inlineDiffAdvice}` ); } diff --git a/plugins/relay/scripts/hooks/relay-guard.mjs b/plugins/relay/scripts/hooks/relay-guard.mjs index 4b8d9796..612d25eb 100644 --- a/plugins/relay/scripts/hooks/relay-guard.mjs +++ b/plugins/relay/scripts/hooks/relay-guard.mjs @@ -62,6 +62,14 @@ import { SCHEMA_SENTENCE } from "../lib/harness/workflow.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = path.resolve(SCRIPT_DIR, "../.."); const COMPANION = path.join(SCRIPT_DIR, "..", "companion.mjs"); + +/** + * A path with its symlinks resolved. Node gives this module its real path, while Claude Code may run the hook and + * the relay may name the companion through a symlinked plugin folder; paths are compared resolved. + */ +function realPath(file) { + try { return fs.realpathSync(file); } catch { return path.resolve(file); } +} const SUBCOMMANDS = new Set(["start", "wait", "result", "status", "cancel"]); const JOB_ID = /^[0-9A-Za-z][0-9A-Za-z._-]*$/; const SHOWN = 'node "${CLAUDE_PLUGIN_ROOT}/scripts/companion.mjs"'; @@ -112,7 +120,7 @@ export function parseCompanionCommand(command) { return null; } const script = tokens[1].replace("${CLAUDE_PLUGIN_ROOT}", PLUGIN_ROOT); - if (path.resolve(script) !== COMPANION) { + if (realPath(script) !== realPath(COMPANION)) { return null; } const subcommand = tokens[2]; @@ -465,7 +473,7 @@ async function main() { } } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (process.argv[1] && realPath(process.argv[1]) === realPath(fileURLToPath(import.meta.url))) { main().catch((error) => { // Fail open: a broken hook must never trap the relay or any other agent. process.stderr.write(`relay guard: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); diff --git a/plugins/relay/scripts/lib/backends/codex/protocol.d.ts b/plugins/relay/scripts/lib/backends/codex/protocol.d.ts index 007756ff..7af0ff55 100644 --- a/plugins/relay/scripts/lib/backends/codex/protocol.d.ts +++ b/plugins/relay/scripts/lib/backends/codex/protocol.d.ts @@ -142,6 +142,9 @@ export type ServerNotification = export interface ServerClientOptions { backendName?: string; + /** How long after SIGTERM, and then after SIGKILL, `close` waits for the process to end. */ + closeGraceMs?: number; + closeKillWaitMs?: number; env?: NodeJS.ProcessEnv; clientInfo?: ClientInfo; capabilities?: InitializeCapabilities; diff --git a/plugins/relay/scripts/lib/backends/codex/request.mjs b/plugins/relay/scripts/lib/backends/codex/request.mjs index 29d60ee4..a73e40ff 100644 --- a/plugins/relay/scripts/lib/backends/codex/request.mjs +++ b/plugins/relay/scripts/lib/backends/codex/request.mjs @@ -86,7 +86,7 @@ const CODE_MODE_DEFAULT_TOKENS = 10000; function developerInstructionsFor(limit, config) { if (!(limit > CODE_MODE_DEFAULT_TOKENS)) return null; - const relay = `Tool output: this job keeps up to ${limit} tokens of each tool result. In code mode, \`exec\` and \`wait\` return only ${CODE_MODE_DEFAULT_TOKENS} tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with \`// @exec: {"max_output_tokens": ${limit}}\`, or pass \`max_tokens: ${limit}\` to \`wait\`, rather than working from a truncated result.`; + const relay = `Tool output: this job keeps up to ${limit} tokens of each tool result. In code mode, \`exec\` and \`wait\` return only ${CODE_MODE_DEFAULT_TOKENS} tokens of output by default and cut the middle, and \`max_output_tokens\` on a nested \`exec_command\` alone does not change that. When a command can print more (a diff, a log, a whole file), start the exec input with \`// @exec: {"max_output_tokens": ${limit}}\` and pass the same value to the nested \`exec_command\`, or pass \`max_tokens: ${limit}\` to \`wait\`. A result that large fills much of the context, so read a large file or diff by line range where you can. When a result says it was truncated, read the omitted part before relying on it.`; return [typeof config.developer_instructions === "string" ? config.developer_instructions : null, relay].filter(Boolean).join("\n\n"); } diff --git a/plugins/relay/scripts/lib/backends/grok/connection.mjs b/plugins/relay/scripts/lib/backends/grok/connection.mjs index 3b489c21..a74ea316 100644 --- a/plugins/relay/scripts/lib/backends/grok/connection.mjs +++ b/plugins/relay/scripts/lib/backends/grok/connection.mjs @@ -4,6 +4,9 @@ import { heldMessage, evidenceKey, countHeld, grokSessionMessages } from "./hist // Grok's ACP protocol, isolated from the worker's message and process accounting. import fs from "node:fs"; import path from "node:path"; +import { runCommand } from "../../util/process.mjs"; +import { realpathOr, userDir } from "../setup.mjs"; +import { resolveGrokHome } from "./request.mjs"; import { partialLineNote, SpawnedServerClient } from "../transport/stdio-rpc.mjs"; export function resolveGrokBinary(env = process.env) { @@ -20,12 +23,220 @@ export function resolveGrokBinary(env = process.env) { throw new Error(`Grok binary is not executable: ${binary}`); } +// Grok shows the model 20,000 bytes of an MCP result, from its start, and saves the rest to a file, +// so a job gets a larger budget unless the environment, managed, home or project configuration names +// one: a quarter of its 256k-token window at four bytes per token. +const GROK_MCP_OUTPUT_BYTES = 262144; + +// Mask comments and multiline strings, whose contents can look like tables and settings. Ordinary +// quoted keys stay intact for `tomlKeyPath`; this is key detection, not a general TOML parser. +function maskTomlMultilineStrings(text) { + let out = ""; + let quote = null; + let multiline = false; + for (let index = 0; index < text.length;) { + const char = text[index]; + if (quote && multiline) { + if (char === quote) { + let end = index; + while (text[end] === quote) end += 1; + if (end - index >= 3) { + out += " ".repeat(end - index); + index = end; + quote = null; + multiline = false; + continue; + } + } + if (char === "\\" && quote === '"' && index + 1 < text.length) { + out += " "; + index += 2; + continue; + } + out += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (quote) { + out += char; + index += 1; + if (char === "\\" && quote === '"' && index < text.length) { + out += text[index]; + index += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "#") { + while (index < text.length && text[index] !== "\n") index += 1; + continue; + } + if ((char === '"' || char === "'") && text.slice(index, index + 3) === char.repeat(3)) { + quote = char; + multiline = true; + out += " "; + index += 3; + continue; + } + if (char === '"' || char === "'") quote = char; + out += char; + index += 1; + } + return out; +} + +/** @returns {string[] | null} */ +function tomlKeyPath(text) { + const raw = []; + let part = ""; + let quote = null; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quote) { + part += char; + if (char === "\\" && quote === '"') part += text[++index] ?? ""; + else if (char === quote) quote = null; + } else if (char === '"' || char === "'") { + quote = char; + part += char; + } else if (char === ".") { + raw.push(part); + part = ""; + } else { + part += char; + } + } + if (quote) return null; + raw.push(part); + const parts = []; + for (const item of raw) { + const key = item.trim(); + if (!key) return null; + if (key.startsWith("'") && key.endsWith("'")) parts.push(key.slice(1, -1)); + else if (key.startsWith('"') && key.endsWith('"')) { + try { parts.push(JSON.parse(key)); } catch { return null; } + } else parts.push(key); + } + return parts; +} + +function tomlEntry(line) { + let quote = null; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (quote) { + if (char === "\\" && quote === '"') index += 1; + else if (char === quote) quote = null; + } else if (char === '"' || char === "'") quote = char; + else if (char === "=") return [line.slice(0, index), line.slice(index + 1)]; + } + return null; +} + +function inlineTableSetsMcpOutput(value) { + const text = value.trim(); + if (!text.startsWith("{") || !text.endsWith("}")) return false; + const entries = []; + let entry = ""; + let quote = null; + let depth = 0; + const inner = text.slice(1, -1); + for (let index = 0; index < inner.length; index += 1) { + const char = inner[index]; + if (quote) { + entry += char; + if (char === "\\" && quote === '"') entry += inner[++index] ?? ""; + else if (char === quote) quote = null; + } else if (char === '"' || char === "'") { + quote = char; + entry += char; + } else if (char === "{" || char === "[") { + depth += 1; + entry += char; + } else if (char === "}" || char === "]") { + depth -= 1; + entry += char; + } else if (char === "," && depth === 0) { + entries.push(entry); + entry = ""; + } else entry += char; + } + entries.push(entry); + return entries.some((item) => { + const pair = tomlEntry(item); + const key = pair ? tomlKeyPath(pair[0]) : null; + return key?.length === 1 && key[0] === "max_output_bytes"; + }); +} + +function configSetsMcpOutput(file) { + let text; + let fd; + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); + const stat = fs.fstatSync(fd); + // Leave unusual configs to Grok instead of blocking or allocating an unbounded buffer. + if (!stat.isFile() || stat.size > 1024 * 1024) return true; + text = fs.readFileSync(fd, "utf8"); + } catch { + return false; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + /** @type {string[] | null} */ + let table = []; + for (const line of maskTomlMultilineStrings(text).split(/\r?\n/)) { + const header = /^\s*\[(\[?)([^\[\],]*)\]\]?\s*$/.exec(line); + if (header) { + table = header[1] ? null : tomlKeyPath(header[2]); + continue; + } + const entry = tomlEntry(line); + if (!entry || table === null) continue; + const key = tomlKeyPath(entry[0]); + if (!key) continue; + const path = [...table, ...key]; + if (path.length === 2 && path[0] === "mcp" && path[1] === "max_output_bytes") return true; + if (path.length === 1 && path[0] === "mcp" && inlineTableSetsMcpOutput(entry[1])) return true; + } + return false; +} + +// Grok resolves trust and precedence. Leave its environment unset when it can find a project +// cap between the job directory and the checkout root, including a linked worktree's root. +function projectSetsMcpOutput(cwd, env) { + const directory = realpathOr(cwd); + const git = runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: directory }); + const top = git.stdout.replace(/\r?\n$/, ""); + // Grok treats a Git checkout rooted at the user’s home as outside a project. + const root = git.status === 0 && top && top !== realpathOr(userDir(env)) ? top : directory; + const relative = path.relative(root, directory); + // A caller's Git environment can point at a different checkout. + if (relative.startsWith(".." + path.sep) || relative === ".." || path.isAbsolute(relative)) { + return configSetsMcpOutput(path.join(directory, ".grok", "config.toml")); + } + let current = directory; + while (true) { + if (configSetsMcpOutput(path.join(current, ".grok", "config.toml"))) return true; + if (current === root) return false; + current = path.dirname(current); + } +} + export function grokEnvironment(request, env = process.env) { + const home = request.grokHome ?? resolveGrokHome("default", env); /** @type {NodeJS.ProcessEnv} */ - const result = { ...env, GROK_HOME: request.grokHome, GROK_MEMORY: "0" }; + const result = { ...env, GROK_HOME: home, GROK_MEMORY: "0" }; for (const source of ["CLAUDE", "CURSOR"]) { for (const kind of ["HOOKS", "SKILLS", "MCPS", "RULES", "AGENTS", "SESSIONS"]) result[`GROK_${source}_${kind}_ENABLED`] = "0"; } + if (env.GROK_MAX_MCP_OUTPUT_BYTES == null && env.MAX_MCP_OUTPUT_BYTES == null && + !configSetsMcpOutput(path.join(home, "config.toml")) && + !configSetsMcpOutput(path.join(home, "managed_config.toml")) && + !configSetsMcpOutput("/etc/grok/managed_config.toml") && !projectSetsMcpOutput(request.cwd, env)) { + result.GROK_MAX_MCP_OUTPUT_BYTES = String(GROK_MCP_OUTPUT_BYTES); + } delete result.GROK_SANDBOX; if (request.mode !== "yolo") result.GROK_SANDBOX = request.mode === "write" ? "workspace" : "read-only"; return result; diff --git a/plugins/relay/scripts/lib/backends/grok/setup.mjs b/plugins/relay/scripts/lib/backends/grok/setup.mjs index b854e872..d24b7d50 100644 --- a/plugins/relay/scripts/lib/backends/grok/setup.mjs +++ b/plugins/relay/scripts/lib/backends/grok/setup.mjs @@ -17,6 +17,15 @@ function grokConfigDefaults(grokHome) { } } +// The first line of Grok's error when it refused a sandbox profile, which it ends with "Refusing to +// start with"; null for anything else, such as a network failure under the profile, which says +// nothing about whether the profile can run. +function sandboxRefusal(result) { + const text = String(result?.stderr ?? ""); + if (result?.timedOut || result?.error || result?.status === 0 || !/Refusing to start with/.test(text)) return null; + return firstLine(text); +} + function parseGrokModels(text) { const lines = String(text ?? "").split(/\r?\n/); const signedIn = /logged in with\s+([^\s.]+(?:\.[^\s.]+)*)/i.exec(text ?? ""); @@ -52,7 +61,7 @@ async function checkGrokHome(cwd, home, env, shared, limits) { const script = grokScriptNote(shared.binary, env); block.realBinary = script.realBinary; if (script.note) block.warnings.push(script.note); - const grokEnv = grokEnvironment({ grokHome: home.home, mode: "yolo" }, env); + const grokEnv = grokEnvironment({ cwd, grokHome: home.home, mode: "yolo" }, env); const [version, listing] = await Promise.all([ shared.version, runLimited(shared.binary, ["models"], { cwd, env: grokEnv, timeoutMs: limits.commandMs }) @@ -85,6 +94,11 @@ async function checkGrokHome(cwd, home, env, shared, limits) { return block; } block.signIn = { state: "signed-in", method: parsed.signedIn, detail: firstLine(listing.stdout) }; + // The same command under the read-only profile: Grok refuses a profile it cannot enforce before + // it does anything else, and makes no model call either way. + const sandboxed = await runLimited(shared.binary, ["models"], { cwd, env: grokEnvironment({ cwd, grokHome: home.home, mode: "read-only" }, env), timeoutMs: limits.commandMs }); + const refusal = sandboxRefusal(sandboxed); + if (refusal) block.warnings.push(`Grok refuses its read-only profile on this machine, so read-only jobs and every Grok review fail; yolo uses no profile and still runs. Grok said: ${refusal}`); block.models = parsed.models.map(id => ({ id, efforts: null, defaultEffort: null })); block.modelsReason = "efforts need grok agent; not probed"; block.ready = !failure; @@ -113,7 +127,7 @@ export const setupProbe = { }, prepare({ cwd, env, limits }) { const binary = tryResolve(resolveGrokBinary, env); - return { binary, version: versionOf(binary, "grok", cwd, grokEnvironment({ grokHome: path.join(userDir(env), ".grok"), mode: "yolo" }, env), limits) }; + return { binary, version: versionOf(binary, "grok", cwd, grokEnvironment({ cwd, grokHome: path.join(userDir(env), ".grok"), mode: "yolo" }, env), limits) }; }, check(home, shared, { cwd, env, limits }) { return checkGrokHome(cwd, home, env, shared, limits); }, signInText(block) { return block.signIn.state === "signed-out" ? `signed out: ${block.signIn.detail}` : null; }, diff --git a/plugins/relay/scripts/lib/backends/transport/stdio-rpc.mjs b/plugins/relay/scripts/lib/backends/transport/stdio-rpc.mjs index 554d1045..31bba75e 100644 --- a/plugins/relay/scripts/lib/backends/transport/stdio-rpc.mjs +++ b/plugins/relay/scripts/lib/backends/transport/stdio-rpc.mjs @@ -281,7 +281,7 @@ export class SpawnedServerClient extends AppServerClientBase { partialStdoutBytes: this.partialStdoutBytes ?? 0, stderrTail: this.stderr }; - this.options.onEnd?.(this.exitInfo); + if (!this.abandoned) this.options.onEnd?.(this.exitInfo); this.handleExit(this.spawnError ?? createProtocolError(serverEndMessage(this.exitInfo, this.options.backendName))); this.resolveProcessEnd(); }); @@ -313,24 +313,36 @@ export class SpawnedServerClient extends AppServerClientBase { this.proc.stderr.destroy(); } + // Bounded: a backend that ignores EOF and SIGTERM gets SIGKILL, and one that survives that is + // given up on, so the worker's cleanup never waits on it forever. A process given up on is not + // reported as ended: it is still running, its record stays open for the next worker's sweep, and + // its exit, if it ever comes, must not write to a job another worker may own by then. async close() { - if (this.closed) { - await this.processEndPromise; - return; - } - + if (this.closePromise) return this.closePromise; this.closed = true; - - if (this.proc && !this.proc.killed) { + this.closePromise = (async () => { + if (!this.proc) return; + const ended = ms => new Promise(resolve => { + const timer = setTimeout(() => resolve(false), ms); + this.processEndPromise.then(() => { clearTimeout(timer); resolve(true); }); + }); + // ChildProcess.killed means a signal was sent, not that the process exited. + const signal = name => { if (!this.exitSeen) this.proc.kill(name); }; this.proc.stdin.end(); - setTimeout(() => { - if (this.proc && !this.proc.killed && this.proc.exitCode === null) { - this.proc.kill("SIGTERM"); - } - }, 50).unref?.(); - } - - await this.processEndPromise; + if (await ended(50)) return; + signal("SIGTERM"); + if (await ended(this.options.closeGraceMs ?? 4950)) return; + signal("SIGKILL"); + if (await ended(this.options.closeKillWaitMs ?? 5000)) return; + this.abandoned = true; + this.exitInfo = { pid: this.proc.pid ?? null, code: null, signal: null, closeTimedOut: true, stderrTail: this.stderr }; + this.proc.stdin.destroy(); + this.proc.stdout.destroy(); + this.proc.stderr.destroy(); + this.proc.unref(); // or the survivor keeps the worker's process alive + this.handleExit(createProtocolError(`${this.options.backendName ?? "codex"} did not exit after SIGKILL`)); + })(); + return this.closePromise; } sendMessage(message) { diff --git a/plugins/relay/scripts/lib/backends/types.d.ts b/plugins/relay/scripts/lib/backends/types.d.ts index e4f8204d..eb0abe74 100644 --- a/plugins/relay/scripts/lib/backends/types.d.ts +++ b/plugins/relay/scripts/lib/backends/types.d.ts @@ -178,6 +178,7 @@ export interface Connection { export interface ServerState { readonly exitInfo: ExitInfo | null; + readonly exitError: Error | null; /** The process exited; its pipes may still be draining. */ readonly exitSeen: boolean; /** The end is recorded and every pending request has failed. */ @@ -191,7 +192,8 @@ export interface ExitInfo { pid: number | null; code: number | null; signal: string | null; - endedAt: string; + endedAt?: string; + closeTimedOut?: boolean; stdoutClosedBeforeExit: boolean; partialStdoutBytes: number; stderrTail: string; diff --git a/plugins/relay/scripts/lib/harness/relay.mjs b/plugins/relay/scripts/lib/harness/relay.mjs index 8f32d26d..f5d7d045 100644 --- a/plugins/relay/scripts/lib/harness/relay.mjs +++ b/plugins/relay/scripts/lib/harness/relay.mjs @@ -34,6 +34,9 @@ export const RELAY_BLOCK_BUDGET = 8; const COORDINATOR_PREFIX = "The coordinator sent a message while you were working:\n"; const COORDINATOR_SUFFIX = "\n\nAddress this before completing your current task."; +// Claude Code's wrapper around what a person types into a running agent. +const HUMAN_PREFIX = "The user sent a new message while you were working:\n"; +const HUMAN_SUFFIX = "\n\nThis is how Claude Code surfaces messages the user sends mid-turn — within the running turn, often alongside the next tool result, rather than as a separate conversation turn. Address the message above as you continue this turn."; function envNumber(env, name, fallback) { const value = Number(env[name]); @@ -131,12 +134,29 @@ function unwrapCoordinator(text) { return text; } +// A message from another agent of the session has origin kind `peer`. The harness wraps its text +// in an envelope naming the sender and keeps the text as sent in `origin.body`, which the job gets. +function peerBody(origin) { + return typeof origin?.body === "string" ? origin.body : null; +} + +function humanText(content, where, problems) { + if (!Array.isArray(content)) return textOfContent(content); + const nonText = content.filter((block) => block?.type !== "text" && block?.type !== "tool_result").length; + if (nonText) { + problems.push(`${where} is a typed message with ${nonText} block(s) that are not text; the job gets its text only`); + } + return textOfContent(content.filter((block) => block?.type === "text")); +} + // The relay's first prompt and every message it received after it, in order. A message is // recognised by its entry type and origin, never by its text: -// - an `attachment` entry of type `queued_command` whose origin is the coordinator -// (SendMessage while the relay works); -// - a `user` entry whose origin is the coordinator (a message that resumes a relay that -// has handed back), unwrapped from the harness's fixed wrapper when it has one; +// - an `attachment` entry of type `queued_command` whose origin is the coordinator, another +// agent (`peer`) or a person (`human`), sent while the relay works; +// - a `user` entry of one of those origins (a message that resumes a relay that has handed +// back), unwrapped from the harness's fixed wrapper when it has one; +// A task notification is the harness's own and never a message; any other origin is a problem, +// so a message nobody can deliver is reported rather than lost. // - a plain `user` turn that is not metadata (`isMeta`) and not a tool result. // Under the Workflow tool the harness's two user entries are recognised by their marker line // (workflow-harness.mjs): the user request is skipped, the computed task is the prompt, @@ -186,23 +206,32 @@ export function parseRelayTranscript(text) { }; if (entry.type === "attachment" && entry.attachment?.type === "queued_command") { - const origin = entry.attachment.origin?.kind ?? entry.origin?.kind ?? null; - if (origin !== "coordinator") { - if (origin == null) { - problems.push(`${where} is a queued command with no origin`); - } + const originEntry = entry.attachment.origin ?? entry.origin ?? null; + const origin = originEntry?.kind; + if (originEntry != null && typeof origin !== "string") { + problems.push(`${where} has an origin with no kind`); + return; + } + if (origin === "task-notification") { + return; + } + if (origin !== "coordinator" && origin !== "peer" && origin !== "human") { + problems.push(originEntry == null ? `${where} is a queued command with no origin` + : `${where} is a queued command of origin ${origin}, which the relay does not deliver`); return; } if (prompt == null) { problems.push(`${where} is a message that came before the prompt`); return; } - const messageText = typeof entry.attachment.prompt === "string" ? entry.attachment.prompt : textOfContent(entry.attachment.prompt); + const messageText = origin === "peer" ? peerBody(originEntry) + : origin === "human" ? humanText(entry.attachment.prompt, where, problems) + : typeof entry.attachment.prompt === "string" ? entry.attachment.prompt : textOfContent(entry.attachment.prompt); if (messageText == null) { problems.push(`${where} is a queued message whose text could not be read`); return; } - addMessage(messageText, "queued_command"); + addMessage(messageText, origin === "coordinator" ? "queued_command" : origin); return; } @@ -222,11 +251,36 @@ export function parseRelayTranscript(text) { if (!isUser || !message) { return; } - if (isToolResult(message.content)) { + const origin = entry.origin?.kind; + const hasTextBlock = Array.isArray(message.content) + && message.content.some((block) => block?.type === "text"); + // A tool-result-only entry is the harness's own. If it also carries text, validate the + // origin before deciding whether the text is a message, including an unsupported origin. + if (isToolResult(message.content) && !hasTextBlock) { + return; + } + const fromCoordinator = origin === "coordinator"; + const fromPeer = origin === "peer"; + const fromHuman = origin === "human"; + if (entry.origin != null && typeof origin !== "string") { + problems.push(`${where} has an origin with no kind`); + return; + } + if (origin === "task-notification") { + return; + } + if (entry.origin != null && !fromCoordinator && !fromPeer && !fromHuman) { + problems.push(`${where} is a user entry of origin ${origin}, which the relay does not deliver`); + return; + } + if (isToolResult(message.content) && !fromCoordinator && !fromPeer && !fromHuman) { return; } - const fromCoordinator = entry.origin?.kind === "coordinator"; if (prompt == null) { + if (fromPeer || fromHuman) { + problems.push(`${where} is a message that came before the prompt`); + return; + } if (entry.isMeta || fromCoordinator) { return; } @@ -249,10 +303,33 @@ export function parseRelayTranscript(text) { prompt = { uuid: entry.uuid ?? null, text: promptText, notes: [], framed: false }; return; } - if (entry.isMeta && !fromCoordinator) { + if (entry.isMeta && entry.origin == null) { return; // harness metadata, marked as such by the entry itself } - const messageText = textOfContent(message.content); + if (fromPeer) { + const body = peerBody(entry.origin); + if (body == null) { + problems.push(`${where} is a message from another agent whose text could not be read`); + return; + } + addMessage(body, "peer"); + return; + } + if (fromHuman) { + let body = humanText(message.content, where, problems); + if (body == null) { + problems.push(`${where} is a message whose text could not be read`); + return; + } + if (entry.isMeta && body.startsWith(HUMAN_PREFIX) && body.endsWith(HUMAN_SUFFIX) && body.length >= HUMAN_PREFIX.length + HUMAN_SUFFIX.length) { + body = body.slice(HUMAN_PREFIX.length, body.length - HUMAN_SUFFIX.length); + } + addMessage(body, "human"); + return; + } + const messageText = fromCoordinator && Array.isArray(message.content) + ? textOfContent(message.content.filter((block) => block?.type === "text")) + : textOfContent(message.content); if (messageText == null) { problems.push(`${where} is a message whose text could not be read`); return; diff --git a/plugins/relay/scripts/lib/review/git.mjs b/plugins/relay/scripts/lib/review/git.mjs index a6ccc35c..e582720d 100644 --- a/plugins/relay/scripts/lib/review/git.mjs +++ b/plugins/relay/scripts/lib/review/git.mjs @@ -23,7 +23,7 @@ function listUniqueFiles(...groups) { function normalizeMaxInlineFiles(value) { const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed < 0) { + if (Number.isNaN(parsed) || parsed < 0) { return DEFAULT_INLINE_DIFF_MAX_FILES; } return Math.floor(parsed); @@ -122,7 +122,8 @@ function getCurrentBranch(cwd) { function getWorkingTreeState(cwd) { const staged = gitChecked(cwd, ["diff", "--cached", "--name-only"]).stdout.trim().split("\n").filter(Boolean); const unstaged = gitChecked(cwd, ["diff", "--name-only"]).stdout.trim().split("\n").filter(Boolean); - const untracked = gitChecked(cwd, ["ls-files", "--others", "--exclude-standard"]).stdout.trim().split("\n").filter(Boolean); + // `-z`: without it Git quotes a non-ASCII name, and the file could not be read for the review. + const untracked = gitChecked(cwd, ["ls-files", "-z", "--others", "--exclude-standard"]).stdout.split("\0").filter(Boolean); return { staged, @@ -199,22 +200,35 @@ function formatUntrackedFile(cwd, relativePath, maxBytes = MAX_UNTRACKED_BYTES) const absolutePath = path.join(cwd, relativePath); let stat; try { - stat = fs.statSync(absolutePath); + stat = fs.lstatSync(absolutePath); } catch { return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`; } if (stat.isDirectory()) { return `### ${relativePath}\n(skipped: directory)`; } + if (stat.isSymbolicLink()) { + return `### ${relativePath}\n(skipped: symbolic link)`; + } + if (!stat.isFile()) { + return `### ${relativePath}\n(skipped: not a regular file)`; + } if (stat.size > maxBytes) { return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${maxBytes} byte limit)`; } let buffer; + let fd = null; try { - buffer = fs.readFileSync(absolutePath); + fd = fs.openSync(absolutePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + const sample = Buffer.alloc(Math.min(stat.size, 4096)); + fs.readSync(fd, sample, 0, sample.length, 0); + if (!isProbablyText(sample)) return `### ${relativePath}\n(skipped: binary file)`; + buffer = fs.readFileSync(fd); } catch { return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`; + } finally { + if (fd !== null) fs.closeSync(fd); } if (!isProbablyText(buffer)) { return `### ${relativePath}\n(skipped: binary file)`; @@ -290,19 +304,23 @@ function collectBranchContext(cwd, baseRef, options = {}) { }; } -// The bytes an inline context adds for untracked files: each readable text file's size. A -// directory, an unreadable file or a binary file is named, not inlined, and adds nothing. -// Stops counting once the total passes `maxBytes`. +// The bytes an inline context adds for untracked files: every file's name, in Git's status and in +// its own heading, plus each readable text file's size. A directory, an unreadable file or a +// binary file is named, not inlined, so only its name counts. Stops counting once the total +// passes `maxBytes`. function measureUntrackedTextBytes(cwd, files, maxBytes) { let total = 0; for (const relativePath of files) { if (total > maxBytes) break; + // Section overhead varies. Charging a guessed amount can refuse a context that fits. + // The caller checks the rendered size, including status codes, fences and headings. + total += 2 * Buffer.byteLength(relativePath); const absolutePath = path.join(cwd, relativePath); let fd = null; try { - const stat = fs.statSync(absolutePath); + const stat = fs.lstatSync(absolutePath); if (!stat.isFile()) continue; - fd = fs.openSync(absolutePath, "r"); + fd = fs.openSync(absolutePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); const sample = Buffer.alloc(Math.min(stat.size, 4096)); fs.readSync(fd, sample, 0, sample.length, 0); if (isProbablyText(sample)) total += stat.size; @@ -351,7 +369,11 @@ export function collectReviewContext(cwd, target, options = {}) { options.includeDiff ?? (listUniqueFiles(state.staged, state.unstaged, state.untracked).length <= maxInlineFiles && diffBytes <= maxInlineDiffBytes); - details = collectWorkingTreeContext(repoRoot, state, { includeDiff, untrackedMaxBytes: options.untrackedInFull ? Infinity : MAX_UNTRACKED_BYTES }); + details = collectWorkingTreeContext(repoRoot, state, { + includeDiff, + // A refused inline review names its untracked files without reading them. + untrackedMaxBytes: options.untrackedInFull ? (includeDiff ? Infinity : 0) : MAX_UNTRACKED_BYTES + }); } else { const comparison = buildBranchComparison(repoRoot, target.baseRef); const fileCount = gitChecked(repoRoot, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean).length; diff --git a/plugins/relay/scripts/lib/worker/worker.mjs b/plugins/relay/scripts/lib/worker/worker.mjs index a4e9bc3c..1d4932ab 100644 --- a/plugins/relay/scripts/lib/worker/worker.mjs +++ b/plugins/relay/scripts/lib/worker/worker.mjs @@ -273,8 +273,12 @@ async function runWorkerRound(jobDir, options, held) { honourTeardowns(); // Work: the first brief if it never started, then messages a dead worker claimed but - // never delivered, then new inbox messages. - if (job.pendingFirstTurn) { + // never delivered, then new inbox messages. A job cancelled before its first turn never runs + // its brief later, so its claim on it ends here, before a `resume` can take the job out of + // `cancelled`; a failed first turn keeps it for `resume`. + if (job.pendingFirstTurn && job.state === "cancelled") { + save({ pendingFirstTurn: false }); + } else if (job.pendingFirstTurn) { queue.push({ prompt: fs.readFileSync(paths.brief, "utf8"), source: "brief", message: null, clientId: `brief-${job.id}`, recoveryCandidate: true }); } accept(owedMessages(jobDir), true); @@ -312,7 +316,7 @@ async function runWorkerRound(jobDir, options, held) { if (cancelRequested) { if (!TERMINAL.has(job.state)) { outcome(cancelMessage, { action: "cancelled" }); - return releaseAndReturn({ ...me_record, state: "cancelled", completedAt: nowIso(), errorMessage: "Cancelled before a turn ran." }); + return releaseAndReturn({ ...me_record, state: "cancelled", pendingFirstTurn: false, completedAt: nowIso(), errorMessage: "Cancelled before a turn ran." }); } outcome(cancelMessage, { action: "nothing-to-cancel", state: job.state }); return releaseAndReturn(me_record); @@ -322,7 +326,7 @@ async function runWorkerRound(jobDir, options, held) { if (cancelRequested) { outcome(cancelMessage, { action: "cancelled" }); dropQueued("the job was cancelled"); - return releaseAndReturn({ ...me_record, state: "cancelled", completedAt: nowIso(), errorMessage: "Cancelled before a turn ran." }); + return releaseAndReturn({ ...me_record, state: "cancelled", pendingFirstTurn: false, completedAt: nowIso(), errorMessage: "Cancelled before a turn ran." }); } // Begin an attempt: nonterminal state, attempt record and report ownership are written @@ -634,7 +638,10 @@ async function runWorkerRound(jobDir, options, held) { } if (result.turnStatus !== "completed" && connection.server.exitInfo) { - throw new Error(backend.endMessage(connection.server.exitInfo)); + const end = connection.server.exitInfo; + throw new Error(end.closeTimedOut + ? connection.server.exitError?.message ?? result.error?.message ?? `${backend.label} did not exit after SIGKILL` + : backend.endMessage(end)); } const finalMessage = String(result.finalMessage ?? ""); @@ -734,7 +741,7 @@ async function runWorkerRound(jobDir, options, held) { if (last && !TERMINAL.has(last.state)) { updateAttempt({ state: "cancelled", endedAt: nowIso() }); } - save({ state: "cancelled", errorMessage: "Cancelled.", completedAt: nowIso() }); + save({ state: "cancelled", pendingFirstTurn: false, errorMessage: "Cancelled.", completedAt: nowIso() }); } else { save({ state: last?.state ?? "failed", errorMessage: last?.errorMessage ?? null, completedAt: nowIso() }); } diff --git a/plugins/relay/skills/jobs/SKILL.md b/plugins/relay/skills/jobs/SKILL.md index 8acbdf79..0c937874 100644 --- a/plugins/relay/skills/jobs/SKILL.md +++ b/plugins/relay/skills/jobs/SKILL.md @@ -148,7 +148,7 @@ ignored with a `note:`. `/relay:review` and `/relay:adversarial-review` (`C review`, `C adversarial-review`) take the header's keys as flags: `--backend codex|grok|zcode|kimi` (Codex unless given; nothing stores a default), `--model`, `--effort`, `--grok-home`, `--kimi-home`, beside `--base ` and `--scope auto|working-tree|branch`. A review is read-only and runs in the foreground, or as a Claude background task with `--background`; it is recorded as a job (`kind: review` or `adversarial-review`), and its block prints `backend:` as a job's does. - Codex: the review runs in the Codex home, as a Codex job does. `/relay:review` is Codex's own reviewer, which takes no effort: `--effort` there is a `note:`. `/relay:adversarial-review` sends the plugin's prompt with the schema as Codex's `outputSchema`. -- Grok, ZCode and Kimi: the plugin's prompts (`prompts/review.md`, `prompts/adversarial-review.md`) on a fresh read-only session, with the diff inlined. Above two files or 256 KiB the review is refused; narrow it with `--base` or `--scope`, or run it on Codex. The adversarial prompt carries the schema as text and the plugin checks the answer. +- Grok, ZCode and Kimi: the plugin's prompts (`prompts/review.md`, `prompts/adversarial-review.md`) on a fresh read-only session, with the diff inlined. Above 256 KiB the review is refused; narrow it with `--base` or `--scope`, or run it on Codex. The adversarial prompt carries the schema as text and the plugin checks the answer. - An adversarial answer that is not review JSON fails the review on every backend: `error: returned no valid review JSON`, the raw answer in the report, a non-zero exit. There is no second try. - A review turn that fails is reported as `The turn failed: `, with the backend's error as `error:` and a non-zero exit. Anything it had sent stays raw and is never read as review JSON. diff --git a/plugins/relay/skills/jobs/grok-home.md b/plugins/relay/skills/jobs/grok-home.md index 37ffa6bc..d5cc570d 100644 --- a/plugins/relay/skills/jobs/grok-home.md +++ b/plugins/relay/skills/jobs/grok-home.md @@ -73,6 +73,8 @@ mode: yolo uses `read-only`. A profile Grok refuses fails the job with Grok's error; there is no fallback. On macOS, Grok can refuse `read-only` when `/var/run/docker.sock` is a symlink. On Linux, it refuses `read-only` when it cannot reach a runtime socket it denies, such as -`/run/podman/podman.sock` under a root-only `/run/podman`; `write` still runs. +`/run/podman/podman.sock` under a root-only `/run/podman`; `write` still runs. Setup warns when this +happens here. A job gets `GROK_MAX_MCP_OUTPUT_BYTES=262144`, unless the environment, managed policy, home or project config +sets the cap. Grok applies a project’s `[mcp] max_output_bytes` according to its trust settings. Grok's macOS profiles do not enforce child-process network restrictions. `network` on a sandboxed job prints a note. diff --git a/scripts/tool-output-probe.mjs b/scripts/tool-output-probe.mjs new file mode 100644 index 00000000..ac964965 --- /dev/null +++ b/scripts/tool-output-probe.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Writes a synthetic file for measuring how a harness shortens a tool result: lines L000001 to +// L, with a random marker word on the first, middle and last line. The markers go to +// stdout as JSON for the caller to keep; give a job the file, never the markers, and compare the +// markers it reports. See docs/harness-output-limits.md. +// +// node scripts/tool-output-probe.mjs [--lines 16000] +import crypto from "node:crypto"; +import fs from "node:fs"; + +const [file, flag, value] = process.argv.slice(2); +const lines = flag === "--lines" ? Number(value) : 16000; +if (!file || !Number.isSafeInteger(lines) || lines < 3) { + console.error("usage: node scripts/tool-output-probe.mjs [--lines N], N at least 3"); + process.exit(1); +} +const marker = (name) => `${name}-${crypto.randomBytes(6).toString("hex")}`; +const markers = { begin: marker("BEGIN"), middle: marker("MIDDLE"), end: marker("END") }; +const middle = Math.ceil(lines / 2); +const text = Array.from({ length: lines }, (_, index) => { + const line = index + 1; + const tag = line === 1 ? markers.begin : line === middle ? markers.middle : line === lines ? markers.end : null; + return `L${String(line).padStart(6, "0")}${tag ? ` ${tag}` : ""}\n`; +}).join(""); +fs.writeFileSync(file, text); +console.log(JSON.stringify({ file, lines, bytes: Buffer.byteLength(text), middleLine: middle, markers })); diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 4bf004cd..ad58cadd 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -122,7 +122,8 @@ test("rescue turns --backend, --grok-home and --kimi-home into header lines; the for (const name of ["review", "adversarial-review"]) { const source = read(`commands/${name}.md`); assert.match(source, /^argument-hint: '\[--wait\|--background\] \[--backend codex\|grok\|zcode\|kimi\] \[--model \] \[--effort \] \[--grok-home H\] \[--kimi-home H\] /m, name); - assert.match(source, /above two files or 256 KiB the review is refused/, name); + assert.match(source, /above 256 KiB the review is refused/, name); + assert.doesNotMatch(source, /above two files/, name); assert.match(source, /where Grok refuses that profile \(on a Mac whose `\/var\/run\/docker\.sock` is a symlink, or on Linux where it cannot reach a runtime socket such as `\/run\/podman\/podman\.sock`\), the review fails with Grok's error/, name); } assert.match(read("commands/adversarial-review.md"), /` returned no valid review JSON`/); diff --git a/tests/fakes/codex.mjs b/tests/fakes/codex.mjs index 99fd2fb6..7cbe41c0 100644 --- a/tests/fakes/codex.mjs +++ b/tests/fakes/codex.mjs @@ -417,7 +417,7 @@ if (BEHAVIOR === "home-probe") { } const rl = readline.createInterface({ input: process.stdin }); -// The client closes the server by ending its stdin, and sends SIGTERM 50 ms later if it still +// The client closes the server by ending its stdin, and escalates through SIGTERM and SIGKILL if it still // runs, which a loaded machine can cause. An idle server, with no turn or timer pending, exits 0 // either way, through process.exit: a natural exit drops the SIGTERM handler before it ends. A // server with work pending keeps running when stdin ends, and SIGTERM kills it, as before. @@ -791,7 +791,7 @@ rl.on("line", (line) => { const end = () => { if (deathKind === "kill") process.kill(process.pid, "SIGKILL"); else if (deathKind === "partial") { - // With stdout ended, the client closes stdin and sends SIGTERM 50 ms later if this + // With stdout ended, the client closes stdin and escalates through SIGTERM and SIGKILL if this // process still runs, which a loaded machine can cause. It exits 0 either way, // through process.exit: a natural exit drops the SIGTERM handler before it ends. process.on("SIGTERM", () => process.exit(0)); diff --git a/tests/fakes/grok.mjs b/tests/fakes/grok.mjs index 2b77b310..e4c50a0d 100644 --- a/tests/fakes/grok.mjs +++ b/tests/fakes/grok.mjs @@ -44,6 +44,8 @@ function endAnswered(turn, reason) { fs.appendFileSync(file + '.invocations', JSON.stringify({ args: process.argv.slice(2), env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('GROK_'))) }) + '\\n'); if (process.argv[2] === '--version' && options.versionFail) { console.error('VERSION DENIED'); process.exit(9); } if (process.argv[2] === '--version') { console.log('grok 1.0.fake'); process.exit(0); } +if (process.argv[2] === 'models' && options.refuseReadOnly && process.env.GROK_SANDBOX === 'read-only') { console.error('error: sandbox profile resolve failed: socket deny resolution failed'); console.error('error: this sandbox could not enforce its deny list. Refusing to start with denied paths unprotected.'); process.exit(1); } +if (process.argv[2] === 'models' && options.networkFailsUnderReadOnly && process.env.GROK_SANDBOX === 'read-only') { console.error('error: request to sandbox.example.invalid failed'); process.exit(1); } if (process.argv[2] === 'models' && options.modelsHangMs) { setTimeout(() => process.exit(0), options.modelsHangMs); return; } if (process.argv[2] === 'models') { console.log(options.signedOut ? 'Not signed in' : 'You are logged in with grok.com.\\n\\nDefault model: grok-4.7\\n\\nAvailable models:\\n * grok-4.7 (default)\\n - grok-4.6'); process.exit(options.signedOut ? 1 : 0); } state.starts.push({ args: process.argv.slice(2), env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('GROK_'))), cwd: process.cwd() }); save(); diff --git a/tests/fixtures/codex-golden.json b/tests/fixtures/codex-golden.json index fcdb62a9..992572fd 100644 --- a/tests/fixtures/codex-golden.json +++ b/tests/fixtures/codex-golden.json @@ -20,7 +20,7 @@ }, "serviceName": "claude_code_codex_plugin", "ephemeral": false, - "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle, and `max_output_tokens` on a nested `exec_command` alone does not change that. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}` and pass the same value to the nested `exec_command`, or pass `max_tokens: 65536` to `wait`. A result that large fills much of the context, so read a large file or diff by line range where you can. When a result says it was truncated, read the omitted part before relying on it." } }, { @@ -71,7 +71,7 @@ "web_search": "live", "tool_output_token_limit": 65536 }, - "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle, and `max_output_tokens` on a nested `exec_command` alone does not change that. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}` and pass the same value to the nested `exec_command`, or pass `max_tokens: 65536` to `wait`. A result that large fills much of the context, so read a large file or diff by line range where you can. When a result says it was truncated, read the omitted part before relying on it." } }, { @@ -107,7 +107,7 @@ "web_search": "live", "tool_output_token_limit": 65536 }, - "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle, and `max_output_tokens` on a nested `exec_command` alone does not change that. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}` and pass the same value to the nested `exec_command`, or pass `max_tokens: 65536` to `wait`. A result that large fills much of the context, so read a large file or diff by line range where you can. When a result says it was truncated, read the omitted part before relying on it." } }, { @@ -438,7 +438,7 @@ }, "serviceName": "claude_code_codex_plugin", "ephemeral": false, - "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle, and `max_output_tokens` on a nested `exec_command` alone does not change that. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}` and pass the same value to the nested `exec_command`, or pass `max_tokens: 65536` to `wait`. A result that large fills much of the context, so read a large file or diff by line range where you can. When a result says it was truncated, read the omitted part before relying on it." } }, { @@ -489,7 +489,7 @@ "web_search": "live", "tool_output_token_limit": 65536 }, - "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle, and `max_output_tokens` on a nested `exec_command` alone does not change that. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}` and pass the same value to the nested `exec_command`, or pass `max_tokens: 65536` to `wait`. A result that large fills much of the context, so read a large file or diff by line range where you can. When a result says it was truncated, read the omitted part before relying on it." } }, { @@ -525,7 +525,7 @@ "web_search": "live", "tool_output_token_limit": 65536 }, - "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle, and `max_output_tokens` on a nested `exec_command` alone does not change that. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}` and pass the same value to the nested `exec_command`, or pass `max_tokens: 65536` to `wait`. A result that large fills much of the context, so read a large file or diff by line range where you can. When a result says it was truncated, read the omitted part before relying on it." } }, { diff --git a/tests/git.test.mjs b/tests/git.test.mjs index 33ef46c6..e98140f0 100644 --- a/tests/git.test.mjs +++ b/tests/git.test.mjs @@ -132,7 +132,7 @@ test("collectReviewContext skips untracked directories in working tree review", assert.match(context.content, /### \.claude\/worktrees\/agent-test\/\n\(skipped: directory\)/); }); -test("collectReviewContext skips broken untracked symlinks instead of crashing", () => { +test("collectReviewContext names untracked symlinks without reading their targets", () => { const cwd = makeTempDir(); initGitRepo(cwd); fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); @@ -140,12 +140,16 @@ test("collectReviewContext skips broken untracked symlinks instead of crashing", run("git", ["commit", "-m", "init"], { cwd }); fs.symlinkSync("missing-target", path.join(cwd, "broken-link")); + const external = path.join(makeTempDir(), "external"); + fs.writeFileSync(external, "OUTSIDE_CONTENT"); + fs.symlinkSync(external, path.join(cwd, "external-link")); const target = resolveReviewTarget(cwd, {}); - const context = collectReviewContext(cwd, target); - - assert.equal(target.mode, "working-tree"); - assert.match(context.content, /### broken-link/); - assert.match(context.content, /skipped: broken symlink or unreadable file/i); + for (const options of [{}, { untrackedInFull: true, maxInlineFiles: Infinity }]) { + const context = collectReviewContext(cwd, target, options); + assert.match(context.content, /### broken-link\n\(skipped: symbolic link\)/); + assert.match(context.content, /### external-link\n\(skipped: symbolic link\)/); + assert.doesNotMatch(context.content, /OUTSIDE_CONTENT/); + } }); test("collectReviewContext falls back to lightweight context for larger adversarial reviews", () => { diff --git a/tests/grok-delivery.test.mjs b/tests/grok-delivery.test.mjs index be24e740..5d5d280e 100644 --- a/tests/grok-delivery.test.mjs +++ b/tests/grok-delivery.test.mjs @@ -771,7 +771,9 @@ import { buildLaunchRequest } from "../plugins/relay/scripts/lib/jobs/request.mj const [block] = output.backends; assert.equal(output.ready, !options.signedOut && !options.versionFail); assert.equal(result.status, output.ready ? 0 : 1); - assert.deepEqual(ctx.grok().invocations.map(call => call.args).sort(), [['--version'], ['models']]); + const models = ctx.grok().invocations.filter(call => call.args[0] === 'models'); + assert.deepEqual(models.map(call => call.env.GROK_SANDBOX), options.signedOut || options.versionFail ? [undefined] : [undefined, 'read-only']); + assert.equal(ctx.grok().invocations.length, models.length + 1); assert.equal(ctx.grok().invocations.find(call => call.args[0] === 'models').env.GROK_HOME, path.join(ctx.user, '.grok-jobs')); assert.equal(ctx.grok().requests.length, 0); if (options.signedOut) { assert.equal(block.signIn.state, 'signed-out'); assert.match(block.signIn.detail, /Not signed in/); } diff --git a/tests/grok.test.mjs b/tests/grok.test.mjs index 73931324..5fe6a4c0 100644 --- a/tests/grok.test.mjs +++ b/tests/grok.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { resolveGrokHome } from "../plugins/relay/scripts/lib/backends/grok/request.mjs"; import { buildLaunchRequest, parseHeaderPrompt } from "../plugins/relay/scripts/lib/jobs/request.mjs"; -import { grokEventRecorder } from "../plugins/relay/scripts/lib/backends/grok/connection.mjs"; +import { grokEnvironment, grokEventRecorder } from "../plugins/relay/scripts/lib/backends/grok/connection.mjs"; import { companion, finishJob, jobDir, promptFile, readJob, setup, startJob, waitFor } from "./support/job-helpers.mjs"; import { initGitRepo, run } from './support/helpers.mjs'; import { installFakeGrok } from "./fakes/grok.mjs"; @@ -160,9 +160,105 @@ test('setup checks version and models without opening a session', t => { const [block] = JSON.parse(result.stdout).backends; assert.equal(block.ready, true); assert.equal(block.version, 'grok 1.0.fake'); assert.equal(block.home, path.join(ctx.user, '.grok-jobs')); - assert.deepEqual(ctx.grok().invocations.map(call => call.args).sort(), [['--version'], ['models']]); + assert.deepEqual(ctx.grok().invocations.map(call => call.args).sort(), [['--version'], ['models'], ['models']]); + assert.deepEqual(ctx.grok().invocations.filter(call => call.args[0] === 'models').map(call => call.env.GROK_SANDBOX ?? null).sort(), ['read-only', null].sort()); assert.equal(ctx.grok().invocations.find(call => call.args[0] === 'models').env.GROK_HOME, block.home); assert.equal(ctx.grok().requests.length, 0); + assert.deepEqual(block.warnings.filter(warning => /read-only profile/.test(warning)), []); +}); + +test('setup warns when Grok refuses its read-only profile, and stays ready; another failure under it is no warning', t => { + const refused = fixture(t, { refuseReadOnly: true }); + const result = companion(refused, ['setup', '--backend', 'grok', '--json']); + assert.equal(result.status, 0, result.stderr); + const [block] = JSON.parse(result.stdout).backends; + assert.equal(block.ready, true); + const warnings = block.warnings.filter(warning => /read-only profile/.test(warning)); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /refuses its read-only profile.*every Grok review fail.*sandbox profile resolve failed/); + assert.doesNotMatch(warnings[0], /write/); + const other = fixture(t, { networkFailsUnderReadOnly: true }); + assert.deepEqual(JSON.parse(companion(other, ['setup', '--backend', 'grok', '--json']).stdout).backends[0].warnings.filter(warning => /read-only profile/.test(warning)), []); +}); + +test('a Grok job gets a larger MCP budget, unless the environment or a config layer names one', async t => { + // Exercise both managed layers without reading or writing the host's policy. + const systemConfig = path.join(fixture(t).root, 'managed_config.toml'); + const open = fs.openSync; + t.mock.method(fs, 'openSync', (file, ...args) => + open(file === '/etc/grok/managed_config.toml' ? systemConfig : file, ...args)); + const budget = (prepare) => { + const ctx = fixture(t); + delete ctx.env.GROK_MAX_MCP_OUTPUT_BYTES; + delete ctx.env.MAX_MCP_OUTPUT_BYTES; + prepare?.(ctx); + return grokEnvironment({ cwd: ctx.work, grokHome: path.join(ctx.user, '.grok'), mode: 'yolo' }, ctx.env); + }; + assert.equal(budget().GROK_MAX_MCP_OUTPUT_BYTES, '262144'); + assert.equal(budget(ctx => { ctx.env.GROK_MAX_MCP_OUTPUT_BYTES = '1000'; }).GROK_MAX_MCP_OUTPUT_BYTES, '1000'); + assert.equal(budget(ctx => { ctx.env.MAX_MCP_OUTPUT_BYTES = '2000'; }).GROK_MAX_MCP_OUTPUT_BYTES, undefined); + const configured = budget(ctx => { + fs.mkdirSync(path.join(ctx.user, '.grok'), { recursive: true }); + fs.writeFileSync(path.join(ctx.user, '.grok', 'config.toml'), '[mcp]\nmax_output_bytes = 3000\n'); + }); + assert.equal(configured.GROK_MAX_MCP_OUTPUT_BYTES, undefined); + const managed = budget(ctx => { + fs.mkdirSync(path.join(ctx.user, '.grok')); + fs.writeFileSync(path.join(ctx.user, '.grok', 'managed_config.toml'), '[mcp]\nmax_output_bytes = 4000\n'); + }); + assert.equal(managed.GROK_MAX_MCP_OUTPUT_BYTES, undefined); + fs.writeFileSync(systemConfig, 'mcp.max_output_bytes = 5000\n'); + assert.equal(budget().GROK_MAX_MCP_OUTPUT_BYTES, undefined); + fs.unlinkSync(systemConfig); + // A job in a subdirectory inherits the project cap. A config above its checkout is unrelated. + const projectConfig = (ctx, directory) => { + initGitRepo(ctx.work); + fs.mkdirSync(path.join(directory, '.grok'), { recursive: true }); + fs.writeFileSync(path.join(directory, '.grok', 'config.toml'), '[mcp]\nmax_output_bytes = 4000\n'); + ctx.work = path.join(ctx.work, 'nested'); + fs.mkdirSync(ctx.work); + }; + assert.equal(budget(ctx => projectConfig(ctx, ctx.work)).GROK_MAX_MCP_OUTPUT_BYTES, undefined); + assert.equal(budget(ctx => projectConfig(ctx, ctx.root)).GROK_MAX_MCP_OUTPUT_BYTES, '262144'); + const nested = fixture(t); + projectConfig(nested, nested.work); + const { id } = startJob(nested, HEADER, '[quick] Project budget.'); + await finishJob(nested, id); + assert.equal(nested.grok().starts[0].env.GROK_MAX_MCP_OUTPUT_BYTES, undefined); + const elsewhere = budget(ctx => { + fs.mkdirSync(path.join(ctx.user, '.grok'), { recursive: true }); + fs.writeFileSync(path.join(ctx.user, '.grok', 'config.toml'), '[other]\nmax_output_bytes = 3000\n'); + }); + assert.equal(elsewhere.GROK_MAX_MCP_OUTPUT_BYTES, '262144'); + const withConfig = text => budget(ctx => { + fs.mkdirSync(path.join(ctx.user, '.grok'), { recursive: true }); + fs.writeFileSync(path.join(ctx.user, '.grok', 'config.toml'), text); + }); + for (const text of [ + '[mcp] # budget\nmax_output_bytes = 3000\n', + '[ mcp ]\n"max_output_bytes" = 3000\n', + '[mcp]\n\'max_output_bytes\' = 3000\n', + '["mcp"]\r\nmax_output_bytes=3000\r\n', + 'mcp.max_output_bytes = 3000\n', + '"mcp"."max_output_bytes" = 3000\n', + '"m\\u0063p".max_output_bytes = 3000\n', + 'mcp = { max_output_bytes = 3000 }\n', + 'mcp = { note = "x", "max_output_bytes" = 3000 }\n' + ]) { + assert.equal(withConfig(text).GROK_MAX_MCP_OUTPUT_BYTES, undefined, text); + } + for (const text of [ + '[mcp]\n# max_output_bytes = 3000\n', + '[mcp.server]\nmax_output_bytes = 3000\n', + '[mcp]\ntimeout = 1\n[other]\nmax_output_bytes = 3000\n', + '[other]\ntext = """\n[mcp]\nmax_output_bytes = 3000\n"""\n', + "[other]\ntext = '''\n[mcp]\nmax_output_bytes = 3000\n'''\n", + 'mcp = { note = "max_output_bytes = 3000" }\n', + '"mcp.max_output_bytes" = 3000\n', + '"m c p".max_output_bytes = 3000\n' + ]) { + assert.equal(withConfig(text).GROK_MAX_MCP_OUTPUT_BYTES, '262144', text); + } }); for (const home of ['default', 'jobs']) test(`Workflow retry adopts the same Grok job with ${home} home and returns its state block`, async () => { diff --git a/tests/recovery.test.mjs b/tests/recovery.test.mjs index ce86ad50..df12c52d 100644 --- a/tests/recovery.test.mjs +++ b/tests/recovery.test.mjs @@ -1,3 +1,47 @@ +test("a worker that arrives after the cancel does not run the brief", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir, "job", { turnMs: 50 }); + const work = makeTempDir("relay-work-cancelled-"); + const home = makeTempDir("relay-jobs-cancelled-"); + const jobId = `job-cancelled-${Date.now()}`; + const { jobDir } = createJobDir(work, jobId, { RELAY_JOBS_HOME: home }); + fs.writeFileSync(path.join(jobDir, "brief.md"), "Must never run.\n"); + writeJob(jobDir, { + id: jobId, kind: "task", state: "cancelled", createdAt: new Date().toISOString(), completedAt: new Date().toISOString(), + errorMessage: "Cancelled before a turn ran.", + request: buildLaunchRequest({ model: "gpt-test", effort: "low", cd: work }), pendingFirstTurn: true, attempts: [] + }); + const job = await runWorker(jobDir, { env: { ...buildEnv(binDir), RELAY_JOBS_POLL_MS: "50" } }); + const after = JSON.parse(fs.readFileSync(path.join(jobDir, "job.json"), "utf8")); + assert.equal(after.state, "cancelled"); + assert.deepEqual(after.attempts, [], "no attempt began"); + assert.equal(after.pendingFirstTurn, false, "its claim on the brief ends"); + assert.ok(job == null || job.state === "cancelled"); +}); + +test("a job cancelled before its first turn never runs its brief, through a resume and a later continue", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir, "job", { turnMs: 50 }); + const work = makeTempDir("relay-work-cancelled-"); + const home = makeTempDir("relay-jobs-cancelled-"); + const jobId = `job-cancelled-resume-${Date.now()}`; + const { jobDir } = createJobDir(work, jobId, { RELAY_JOBS_HOME: home }); + fs.writeFileSync(path.join(jobDir, "brief.md"), "Must never run.\n"); + writeJob(jobDir, { + id: jobId, kind: "task", state: "cancelled", createdAt: new Date().toISOString(), completedAt: new Date().toISOString(), + errorMessage: "Cancelled.", + request: buildLaunchRequest({ model: "gpt-test", effort: "low", cd: work }), pendingFirstTurn: true, attempts: [] + }); + const env = { ...buildEnv(binDir), RELAY_JOBS_POLL_MS: "50" }; + postInboxMessage(jobDir, { kind: "resume", text: "Do this instead." }); + await runWorker(jobDir, { env }); + postInboxMessage(jobDir, { kind: "continue", text: "And then this." }); + await runWorker(jobDir, { env }); + const after = JSON.parse(fs.readFileSync(path.join(jobDir, "job.json"), "utf8")); + assert.deepEqual(after.attempts.map(attempt => attempt.source), ["resume", "continue"]); + assert.equal(after.pendingFirstTurn, false); +}); + import fs from "node:fs"; import path from "node:path"; import process from "node:process"; diff --git a/tests/relay-guard.test.mjs b/tests/relay-guard.test.mjs index 7c0bb186..92877c9f 100644 --- a/tests/relay-guard.test.mjs +++ b/tests/relay-guard.test.mjs @@ -137,6 +137,68 @@ test("the parser reads the real prompt byte for byte and recognises messages by assert.deepEqual(parsed.problems, []); }); +test("a queued message from another agent or a person reaches the job; a task notification and an unknown origin do not", () => { + const ctx = relayContext(); + const queued = (uuid, prompt, origin) => ({ type: "attachment", uuid, attachment: { type: "queued_command", prompt, origin } }); + const entries = [ + ctx.prompt(PROMPT), + queued("peer-1", "\nFrom the peer.\n", { kind: "peer", from: "a1", body: "From the peer." }), + queued("human-1", "cancel", { kind: "human" }), + queued("notice-1", "A task ended.", { kind: "task-notification" }), + queued("coordinator-1", "From the parent.", { kind: "coordinator" }), + queued("mystery-1", "From somewhere.", { kind: "mystery" }), + queued("nameless-1", "No kind.", { from: "a1" }) + ]; + const parsed = parseRelayTranscript(entries.map((entry) => `${JSON.stringify(entry)}\n`).join("")); + assert.deepEqual(parsed.messages.map((message) => [message.text, message.shape]), [ + ["From the peer.", "peer"], ["cancel", "human"], ["From the parent.", "queued_command"] + ]); + assert.deepEqual(parsed.problems, [ + "entry mystery-1 is a queued command of origin mystery, which the relay does not deliver", + "entry nameless-1 has an origin with no kind" + ]); +}); + +test("coordinator, peer and human entries reach the job; unknown origins are reported, never delivered", () => { + const ctx = relayContext(); + const user = (uuid, content, origin, extra = {}) => ({ type: "user", uuid, origin, message: { role: "user", content }, ...extra }); + const wrapped = `The user sent a new message while you were working:\nStop now.\n\nThis is how Claude Code surfaces messages the user sends mid-turn — within the running turn, often alongside the next tool result, rather than as a separate conversation turn. Address the message above as you continue this turn.`; + const mixed = [{ type: "tool_result", tool_use_id: "t", content: "x" }, { type: "text", text: "cancel" }]; + const entries = [ + user("early-1", "Too early.", { kind: "peer", body: "Too early." }), + user("early-mystery", "Not the prompt.", { kind: "mystery" }), + ctx.prompt(PROMPT), + user("peer-1", "Envelope.", { kind: "peer", body: "The body." }, { isMeta: true }), + user("peer-2", "No body.", { kind: "peer" }), + user("human-1", wrapped, { kind: "human" }, { isMeta: true }), + user("human-2", [{ type: "text", text: "Typed." }, { type: "image", source: {} }], { kind: "human" }), + user("human-3", mixed, { kind: "human" }), + user("human-4", [{ type: "tool_result", tool_use_id: "t", content: "x" }], { kind: "human" }), + user("peer-mixed", mixed, { kind: "peer", body: "cancel" }), + user("coordinator-mixed", mixed, { kind: "coordinator" }), + user("mystery-2", "Not a plain user message.", { kind: "mystery" }), + user("mystery-mixed", mixed, { kind: "mystery" }), + user("mystery-tool-only", [{ type: "tool_result", tool_use_id: "t", content: "x" }], { kind: "mystery" }), + user("notice-1", "A task ended.", { kind: "task-notification" }), + user("meta-1", "Odd metadata.", { kind: "mystery" }, { isMeta: true }) + ]; + const parsed = parseRelayTranscript(entries.map((entry) => `${JSON.stringify(entry)}\n`).join("")); + assert.equal(parsed.prompt.text, PROMPT); + assert.deepEqual(parsed.messages.map((message) => [message.text, message.shape]), [ + ["The body.", "peer"], ["Stop now.", "human"], ["Typed.", "human"], ["cancel", "human"], + ["cancel", "peer"], ["cancel", "coordinator"] + ]); + assert.deepEqual(parsed.problems, [ + "entry early-1 is a message that came before the prompt", + "entry early-mystery is a user entry of origin mystery, which the relay does not deliver", + "entry peer-2 is a message from another agent whose text could not be read", + "entry human-2 is a typed message with 1 block(s) that are not text; the job gets its text only", + "entry mystery-2 is a user entry of origin mystery, which the relay does not deliver", + "entry mystery-mixed is a user entry of origin mystery, which the relay does not deliver", + "entry meta-1 is a user entry of origin mystery, which the relay does not deliver" + ]); +}); + test("a plain user message that starts with is still a message", () => { const ctx = relayContext(); const text = "Please include the second finding."; @@ -1287,3 +1349,19 @@ test("a relay that never runs start gets its job started by the hook: at its han assert.equal(updated(handback).message, resultOf(ctx, jobId), `${what}: the hand-back is the job's result`); } }); + +test("a plugin reached through a symlinked folder: the hook runs, and knows the companion by either path", () => { + // Claude Code can install a plugin through a symlinked config folder. Node gives the hook its real path, while the + // hook is run and the relay names the companion through the symlink. + const link = path.join(makeTempDir("relay-link-"), "plugin"); + fs.symlinkSync(PLUGIN_ROOT, link); + assert.deepEqual(parseCompanionCommand(`node "${path.join(link, "scripts", "companion.mjs")}" start --from-relay`), + { subcommand: "start", jobId: null, fromRelay: true }); + + const ctx = relayContext("job"); + const input = hookInput(ctx, "PreToolUse", { tool_name: "Bash", tool_input: { command: "sw_vers -productVersion" } }); + const r = spawnSync("node", [path.join(link, "scripts", "hooks", "relay-guard.mjs")], + { input: JSON.stringify(input), env: ctx.env, encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr); + assert.equal(JSON.parse(r.stdout).hookSpecificOutput.permissionDecision, "deny"); +}); diff --git a/tests/reviews-backends.test.mjs b/tests/reviews-backends.test.mjs index 322dc708..a77d0869 100644 --- a/tests/reviews-backends.test.mjs +++ b/tests/reviews-backends.test.mjs @@ -9,6 +9,7 @@ import { installFakeGrok } from "./fakes/grok.mjs"; import { installFakeZcode } from "./fakes/zcode.mjs"; import { installFakeKimi } from "./fakes/kimi.mjs"; import { initGitRepo, run } from "./support/helpers.mjs"; +import { collectReviewContext } from "../plugins/relay/scripts/lib/review/git.mjs"; const VERDICT = { verdict: "needs-attention", @@ -374,56 +375,106 @@ test("a failed adversarial-review turn says the turn failed with the backend's e const LIMIT = 256 * 1024; const refusal = (what, label) => `The diff is ${what}; a ${label} review reads only an inlined diff. Narrow it with --base or --scope, or run it on Codex.\n`; -test("the inline limits are exact: two files and 256 KiB pass, a third file or one byte more is refused before a job or a server", t => { - // Two changed files pass; a third is refused. - const ctx = zcode(t, {}, { "src/a.js": "a\n", "src/b.js": "b\n" }); +test("the inline limit is exact: any number of files within 256 KiB passes, one byte more is refused before a job or a server", t => { + // The number of files is not a limit: all of them reach the prompt. + const ctx = zcode(t, {}, Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`src/file${index}.js`, `export const FILE_MARKER_${index} = 1;\n`]))); assert.equal(companion(ctx, ["review", "--backend", "zcode"]).status, 0); - fs.writeFileSync(path.join(ctx.work, "src", "app.js"), "c\n"); - const before = jobDirs(ctx); - const three = companion(ctx, ["review", "--backend", "zcode"]); - assert.equal(three.status, 1); - assert.equal(three.stderr, refusal("3 files / 1 KiB", "ZCode")); - assert.deepEqual(jobDirs(ctx), before); - assert.equal(ctx.zcode().starts.length, 1); - - // A binary untracked file is named, not inlined, and counts as a file; its size, here above - // the byte limit, does not count, since nothing of it reaches the prompt. + for (let index = 0; index < 12; index++) assert.match(ctx.zcode().turns[0].text, new RegExp(`FILE_MARKER_${index} `)); + + // A binary untracked file is named, not inlined; its size, here above the byte limit, does + // not count, since nothing of it reaches the prompt. const binary = zcode(t, { answer: JSON.stringify(VERDICT) }, undefined, { "src/blob.bin": Buffer.concat([Buffer.from([0, 1, 2, 0]), Buffer.alloc(300 * 1024, 7)]) }); + const binaryReads = t.mock.method(fs, "readFileSync"); + const binaryContext = collectReviewContext(binary.work, { mode: "working-tree", label: "working tree diff" }, { untrackedInFull: true, maxInlineFiles: Infinity }); + assert.match(binaryContext.content, /### src\/blob\.bin\n\(skipped: binary file\)/); + assert.equal(binaryReads.mock.callCount(), 0, "the binary sample is enough; the whole file is never read"); + binaryReads.mock.restore(); assert.equal(companion(binary, ["adversarial-review", "--backend", "zcode"]).status, 0); assert.match(binary.zcode().turns[0].text, /### src\/blob\.bin\n\(skipped: binary file\)/); - fs.writeFileSync(path.join(binary.work, "src", "c.js"), "c\n"); - assert.equal(companion(binary, ["adversarial-review", "--backend", "zcode"]).stderr, refusal("3 files / 1 KiB", "ZCode")); assert.equal(binary.zcode().starts.length, 1); - // Working tree: an untracked file of exactly 256 KiB is inlined whole; one byte more is refused. - const bytes = grok(t, {}, {}, { "src/new.txt": sized(LIMIT, "WHOLE_FILE_END") }); + const inlineContext = (cwd, target = { mode: "working-tree", label: "working tree diff" }) => + collectReviewContext(cwd, target, { untrackedInFull: true, maxInlineFiles: Infinity }); + + // Working tree: the serialized context, names and section text included, fills exactly 256 KiB. + const bytes = grok(t, {}, {}, { "src/new.txt": "" }); + const contextBytes = () => Buffer.byteLength(inlineContext(bytes.work).content, "utf8"); + const fits = LIMIT - contextBytes() + 1; + fs.writeFileSync(path.join(bytes.work, "src", "new.txt"), sized(fits, "WHOLE_FILE_END")); + assert.equal(contextBytes(), LIMIT); assert.equal(companion(bytes, ["review", "--backend", "grok"]).status, 0); - assert.ok(bytes.grok().turns[0].text.includes(sized(LIMIT, "WHOLE_FILE_END").trimEnd())); - fs.writeFileSync(path.join(bytes.work, "src", "new.txt"), sized(LIMIT + 1, "WHOLE_FILE_END")); + assert.ok(bytes.grok().turns[0].text.includes(sized(fits, "WHOLE_FILE_END").trimEnd())); + fs.writeFileSync(path.join(bytes.work, "src", "new.txt"), sized(fits + 1, "WHOLE_FILE_END")); const over = companion(bytes, ["review", "--backend", "grok"]); assert.equal(over.status, 1); assert.equal(over.stderr, refusal("1 file / more than 256 KiB", "Grok")); assert.equal(jobDirs(bytes).length, 1); assert.equal(bytes.grok().starts.length, 1); - // Branch: a diff of exactly 256 KiB against --base passes; one byte more is refused. + // Per-file formatting varies. Empty files and one text file can fill the exact serialized + // limit even when a fixed allowance per entry would reject the review. + const smallNames = Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`empty${i}`, ""])); + const many = grok(t, {}, {}, { ...smallNames, payload: "x" }); + const space = LIMIT - Buffer.byteLength(inlineContext(many.work).content); + fs.writeFileSync(path.join(many.work, "payload"), "x".repeat(space + 1)); + assert.equal(Buffer.byteLength(inlineContext(many.work).content), LIMIT); + assert.equal(companion(many, ["review", "--backend", "grok"]).status, 0); + + // File names alone can pass the limit: many empty or binary files are refused, not sent. + const names = Object.fromEntries(Array.from({ length: 1400 }, (_, index) => [`src/${"long-file-name-".repeat(6)}${index}.txt`, ""])); + const empties = grok(t, {}, {}, names); + const refusedNames = companion(empties, ["review", "--backend", "grok"]); + assert.equal(refusedNames.status, 1); + assert.equal(refusedNames.stderr, refusal("1400 files / more than 256 KiB", "Grok")); + assert.equal(empties.grok().starts.length, 0); + + // Git status octal-escapes non-ASCII names. Raw-name accounting passes this set; its rendered + // context is over the limit, so the final serialized-size check must refuse it. + const quotedNames = Object.fromEntries(Array.from({ length: 3000 }, (_, index) => [`café-日本語-${index}.txt`, ""])); + const quoted = grok(t, {}, {}, quotedNames); + assert.equal(companion(quoted, ["review", "--backend", "grok"]).status, 1); + assert.equal(quoted.grok().starts.length, 0); + assert.equal(jobDirs(quoted).length, 0); + + // Bytes that decode to U+FFFD can triple the rendered size without containing NUL. + const invalid = grok(t, {}, {}, {}); + fs.writeFileSync(path.join(invalid.work, "invalid.bin"), Buffer.alloc(100000, 0xff)); + assert.equal(companion(invalid, ["review", "--backend", "grok"]).status, 1); + assert.equal(invalid.grok().starts.length, 0); + assert.equal(jobDirs(invalid).length, 0); + + // A file already known to be over the limit is named in the lightweight context, never read. + const large = grok(t, {}, {}, { "src/large.txt": sized(300 * 1024, "MUST_NOT_BE_READ") }); + const largeReads = t.mock.method(fs, "readFileSync"); + const lightweight = inlineContext(large.work); + assert.equal(lightweight.inputMode, "self-collect"); + assert.equal(largeReads.mock.callCount(), 0); + largeReads.mock.restore(); + assert.doesNotMatch(lightweight.content, /MUST_NOT_BE_READ/); + + // A non-ASCII name is read, not skipped as unreadable: Git would quote it without `-z`. + const accented = grok(t, {}, {}, { "src/café-日本語.txt": "ACCENTED_NAME_CONTENT\n" }); + assert.equal(companion(accented, ["review", "--backend", "grok"]).status, 0); + assert.match(accented.grok().turns[0].text, /### src\/café-日本語\.txt\n```\nACCENTED_NAME_CONTENT/); + + // Branch: the serialized context also has an exact boundary, including its log and section text. const branch = zcode(t, { answer: JSON.stringify(VERDICT) }, null); const git = (...args) => assert.equal(run("git", args, { cwd: branch.work }).status, 0, args.join(" ")); initGitRepo(branch.work); fs.writeFileSync(path.join(branch.work, "base.txt"), "base\n"); git("add", "."); git("commit", "-m", "base"); git("checkout", "-b", "feature"); - const diffBytes = () => Buffer.byteLength(run("git", ["diff", "--binary", "--no-ext-diff", "--submodule=diff", "main..HEAD"], { cwd: branch.work }).stdout); + const branchBytes = () => Buffer.byteLength(inlineContext(branch.work, { mode: "branch", label: "branch diff", baseRef: "main" }).content, "utf8"); const commitBig = (length, amend) => { fs.writeFileSync(path.join(branch.work, "big.txt"), `${"y".repeat(length - 1)}\n`); git("add", "."); git("commit", ...(amend ? ["--amend"] : []), "-m", "big"); }; commitBig(1000, false); - const content = 1000 + LIMIT - diffBytes(); + const content = 1000 + LIMIT - branchBytes(); commitBig(content, true); - assert.equal(diffBytes(), LIMIT); + assert.equal(branchBytes(), LIMIT); assert.equal(companion(branch, ["adversarial-review", "--backend", "zcode", "--base", "main"]).status, 0); commitBig(content + 1, true); - assert.equal(diffBytes(), LIMIT + 1); + assert.equal(branchBytes(), LIMIT + 1); assert.equal(companion(branch, ["adversarial-review", "--backend", "zcode", "--base", "main"]).stderr, refusal("1 file / more than 256 KiB", "ZCode")); assert.equal(jobDirs(branch).length, 1); assert.equal(branch.zcode().starts.length, 1); diff --git a/tests/server-end.test.mjs b/tests/server-end.test.mjs index 671ac281..d69e76fb 100644 --- a/tests/server-end.test.mjs +++ b/tests/server-end.test.mjs @@ -15,6 +15,8 @@ import { finishJob, HEADER, readJob, setup, startJob, waitFor } from "./support/ import { makeTempDir, writeExecutable } from "./support/helpers.mjs"; import fs from "node:fs"; import { DEFAULT_RESUME_MESSAGE, resumeDeadJobOnce } from "../plugins/relay/scripts/lib/worker/recovery.mjs"; +import { runWorker } from "../plugins/relay/scripts/lib/worker/worker.mjs"; +import { postInboxMessage } from "../plugins/relay/scripts/lib/jobs/messages.mjs"; import { isWorkerAlive } from "../plugins/relay/scripts/lib/jobs/lease.mjs"; import { companion, jobDirOf, readJobJson, relayContext, waitFor as waitForForAppserverdeath } from "./support/relay-fixture.mjs"; @@ -357,6 +359,87 @@ import { companion, jobDirOf, readJobJson, relayContext, waitFor as waitForForAp assert.equal(ctx.fakeState().appServerStarts, 1); }); + test("close escalates to SIGKILL when a backend ignores EOF and SIGTERM", async (t) => { + const dir = makeTempDir(); + const script = path.join(dir, "stubborn.mjs"); + fs.writeFileSync(script, `process.on("SIGTERM", () => {}); +let buffer = ""; +process.stdin.on("data", (chunk) => { + buffer += chunk; + for (const line of buffer.split("\\n").slice(0, -1)) process.stdout.write(JSON.stringify({ id: JSON.parse(line).id, result: {} }) + "\\n"); + buffer = buffer.slice(buffer.lastIndexOf("\\n") + 1); +}); +process.stdin.on("end", () => {}); +setInterval(() => {}, 1000); +`); + const ends = []; + const client = new SpawnedServerClient(dir, { + binary: process.execPath, args: [script], closeGraceMs: 100, closeKillWaitMs: 2000, onEnd: info => ends.push(info) + }); + t.after(() => { try { client.proc?.kill("SIGKILL"); } catch {} }); + await client.initialize(); + const started = Date.now(); + await client.close(); + assert.ok(Date.now() - started < 2000, "close did not wait for the grace period only"); + assert.equal(ends.at(-1).signal, "SIGKILL"); + assert.equal(await client.close(), undefined, "a second close settles too"); + }); + + test("close gives up when the process survives SIGKILL, rejects what is pending, and never reports it ended", async () => { + const dir = makeTempDir(); + const script = path.join(dir, "fake.mjs"); + fs.writeFileSync(script, `process.stdin.on("data", (chunk) => { for (const line of String(chunk).split("\\n").filter(Boolean)) { const { id, method } = JSON.parse(line); if (method === "initialize") process.stdout.write(JSON.stringify({ id, result: {} }) + "\\n"); } }); +setInterval(() => {}, 1000); +`); + const ends = []; + const client = new SpawnedServerClient(dir, { + binary: process.execPath, args: [script], closeGraceMs: 10, closeKillWaitMs: 50, onEnd: info => ends.push(info) + }); + await client.initialize(); + const pending = client.request("never/answered", {}).then(() => null, error => error); + // Stands in for a process no signal can end: signals are dropped, so `close` runs out its time. + const kill = client.proc.kill.bind(client.proc); + client.proc.kill = () => false; + try { + await client.close(); + assert.deepEqual(ends, [], "the process is still running, so its record stays open for the sweep"); + assert.equal(client.exitInfo.closeTimedOut, true); + assert.match((await pending).message, /did not exit after SIGKILL/); + } finally { + client.proc.ref(); // `close` let go of it, and this test still waits for its exit + kill("SIGKILL"); + } + // Its exit, when it comes, writes nothing: another worker may own the job by then. + await client.processEndPromise; + assert.deepEqual(ends, []); + }); + + test("a worker reports a close timeout, not an unknown process exit", async (t) => { + const ctx = relayContext("job", { turnMs: 50 }); + const id = start(ctx); + await finish(ctx, id); + const connect = CodexAppServerClient.connect; + t.mock.method(CodexAppServerClient, "connect", async function (...args) { + const client = await connect.apply(this, args); + const request = client.request.bind(client); + client.request = async (method, params) => { + if (method === "turn/start") { + client.exitInfo = { pid: client.proc.pid ?? null, code: null, signal: null, closeTimedOut: true, stderrTail: "" }; + client.exitError = new Error("codex did not exit after SIGKILL"); + throw client.exitError; + } + return request(method, params); + }; + return client; + }); + const dir = jobDirOf(ctx, id); + postInboxMessage(dir, { kind: "continue", text: "Trigger a close timeout" }); + const job = await runWorker(dir, { env: ctx.env }); + assert.equal(job.state, "failed"); + assert.match(job.errorMessage, /did not exit after SIGKILL/); + assert.doesNotMatch(job.errorMessage, /exit unknown/); + }); + test("multi-megabyte resume and MCP lines remain intact", async (t) => { const ctx = relayContext("job", { turnMs: 50, env: { FAKE_CODEX_LARGE_LINES: "1" } }); const id = start(ctx); diff --git a/tests/setup-page.test.mjs b/tests/setup-page.test.mjs index 700f3149..0eaf65ae 100644 --- a/tests/setup-page.test.mjs +++ b/tests/setup-page.test.mjs @@ -100,8 +100,8 @@ test("the overview shows shared backend fields and Codex budget metadata, ready, const codexState = ctx.fakeState(); assert.deepEqual(codexState.requests ?? [], []); assert.deepEqual(codexState.spawnHomes, [path.join(ctx.user, ".codex")]); - assert.deepEqual(ctx.grok().invocations.map(call => call.args.join(" ")).sort(), ["--version", "models", "models"]); - assert.deepEqual(ctx.grok().invocations.filter(call => call.args[0] === "models").map(call => call.env.GROK_HOME).sort(), [path.join(ctx.user, ".grok"), path.join(ctx.user, ".grok-jobs")]); + assert.deepEqual(ctx.grok().invocations.map(call => call.args.join(" ")).sort(), ["--version", "models", "models", "models", "models"]); + assert.deepEqual(ctx.grok().invocations.filter(call => call.args[0] === "models").map(call => call.env.GROK_HOME).sort(), [path.join(ctx.user, ".grok"), path.join(ctx.user, ".grok"), path.join(ctx.user, ".grok-jobs"), path.join(ctx.user, ".grok-jobs")]); assert.equal(ctx.grok().starts.length, 0); assert.deepEqual(ctx.zcode().invocations.map(call => call.args.join(" ")).sort(), ["--version", "app-server"]); assert.deepEqual(ctx.zcode().requests.filter(r => r.method).map(r => r.method), ["runtime/capabilities", "session/create", "session/close"]); @@ -280,7 +280,8 @@ test("every home's check runs at once with the others, and setup ends what each const grokPids = wrapBinary(ctx, "grok", { barrier, gate: "models", parties: 3 }); const { status, page: report } = page(ctx, [], { RELAY_SETUP_SERVER_MS: "8000", RELAY_SETUP_COMMAND_MS: "8000" }); assert.equal(status, 0); - assert.equal(fs.readdirSync(barrier).length, 3); + // The three checks that met at the barrier, and each Grok home's read-only check after it. + assert.equal(fs.readdirSync(barrier).length, 5); assert.deepEqual(report.backends.map(block => [block.backend, block.selector, block.ready, block.error]), [ ["codex", null, true, null], ["grok", null, true, null], ["grok", "jobs", true, null], ["zcode", null, true, null], ["kimi", null, true, null] ]); diff --git a/tests/tool-output-budget.test.mjs b/tests/tool-output-budget.test.mjs index bf96c267..30907ed0 100644 --- a/tests/tool-output-budget.test.mjs +++ b/tests/tool-output-budget.test.mjs @@ -169,6 +169,8 @@ for (const [label, response, expected] of [ for (const [label, header, configRead, expected] of [ ["a budget above code mode's default tells the model how to ask for it", "model: gpt-6.1-sol", { config: {} }, { has: "\"max_output_tokens\": 131072" }], + ["the instruction says the nested value alone is not enough, and to read what a truncation names", "model: gpt-6.1-sol", { config: {} }, { has: "alone does not change that" }], + ["the instruction tells the model to read large evidence by range", "model: gpt-6.1-sol", { config: {} }, { has: "read the omitted part before relying on it" }], ["a configured budget reaches code mode, after the user's developer instructions", "model: gpt-6.1-sol", { config: { tool_output_token_limit: 65536, developer_instructions: "Keep answers short." } }, { starts: "Keep answers short.\n\n", has: "\"max_output_tokens\": 65536" }], ["a budget at or below code mode's default adds no instructions", "model: gpt-6.1-sol\ntool-output-token-limit: 8192", { config: {} }, null], ["inherit without a configured budget adds no instructions", "model: gpt-6.1-sol\ntool-output-token-limit: inherit", { config: {} }, null]