feat(subagent): add Codex product provider

This commit is contained in:
pku-xht
2026-08-04 16:02:17 +08:00
parent 4af4c10075
commit 1daa35b6e3
45 changed files with 3170 additions and 126 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1
2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 14e8dde04d9526aaffc0e58be049e13858362887
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md
2026-07-07-claude-code-and-codex-subagent-backends.md: 86a2e3489a84408e24c6c8091bc52b747b9069b9
2026-07-07-claude-code-and-codex-subagent-backends.zh.md: ef2098b3afe3e5602ed93de1984c91a5c4c1e79e

View File

@@ -1,4 +1,4 @@
# Agent Note: Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)
# Agent Note: Claude Code and Codex subagent providers
Status: proposed
@@ -6,84 +6,69 @@ English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md)
## Problem
The subagent seam ([the seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine.
The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, use the parent Session's workspace, return a final answer or explicit failure, and leave no managed product process behind.
Product integration must not create a second owner for task text, cwd, cancellation, result settlement, or process trees. It must also prove the real product path in required keyless tests: a fake wrapper or direct model HTTP request cannot establish that the Loader, provider registration, official product protocol, authentication, final answer, and teardown compose correctly.
## Proposal
Two sibling provider packages, structural variants of the ACP backend, plus one extraction:
Two sibling one-shot providers register fixed deployment names and are exposed through two fixed `dsh-tool-subagent` instances:
- `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter.
- `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package.
- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change.
- `@deepseek-ai/dsh-subagent-codex` registers `codex`, driven through `codex app-server --stdio`, and is implemented.
- `@deepseek-ai/dsh-subagent-claude-code` will register `claude-code`, driven through the official Claude Agent SDK and its bundled CLI, and remains pending.
Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk.
The model-facing tools are `subagent_codex` and `subagent_claude_code`. Each tool binds one provider at deployment time, accepts a standalone task, and omits the background parameter in the initial compositions. Product selection is not another model argument.
## Verified interface facts (pinned versions)
Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and use the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and one non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground disposal; the shared subprocess service owns environment scrubbing, process-tree termination, and whole-tree exit observation.
Both integration surfaces were verified against pinned implementations before this proposal — types and bundled source read, keyless spikes run — not from vendor docs alone. The pins are the verification baseline, not a runtime contract: the backends perform no runtime version probe (no `codex --version` gate, no SDK version sniffing). Compatibility is enforced at development time — every dependency bump re-runs the keyless suites against the real load path — and at runtime by failing loudly: a protocol-level surprise settles `error` via `onError`, never a silent misbehavior.
## Codex provider
**`@anthropic-ai/claude-agent-sdk` 0.3.202.** `options.env` REPLACES the child environment (no merge with `process.env`), which is exactly what the scrub needs. `settingSources` defaults to loading ALL filesystem settings — isolation requires explicitly passing `[]`. Result subtypes are `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`. On abort the SDK escalates the CLI child itself: stdin EOF immediately, SIGTERM ~2s later if the child ignores it (observed; no leftover processes) — no bespoke kill fallback needed. `outputFormat: {type: 'json_schema'}` and an `agents` option exist, giving future landing points for the seam's `outputSchema` capability and named subagent types; both are out of scope here.
The Codex provider has fixed name `codex` and fixed command `codex app-server --stdio`. Its public configuration contains only explicit `env` entries and a positive finite `disposeGraceMs`; it does not expose command, cwd, model, base URL, API key, sandbox, approval, product home, or session settings. Production resolves Codex from `PATH` and uses the host's native Codex configuration and authentication. Credential-shaped ambient variables are scrubbed by `dsh-subprocess`, while explicit `env` values merge afterward.
**codex CLI 0.142.5, `codex app-server` (v2 vocabulary).** LF-delimited JSON, JSON-RPC 2.0 shapes with the `"jsonrpc"` header omitted.
Before publication, the provider validates a non-empty text-only task, starts the managed app-server, performs `initialize` → `initialized`, and creates an `ephemeral: true` thread in the parent workspace. The returned run owns exactly one `turn/start`; product thread and turn ids stay private and are not persisted in the parent Session.
- Lifecycle: `initialize{clientInfo}` + `initialized` → `thread/start` (accepts `cwd`, `model`, `sandbox`, `approvalPolicy`, `ephemeral`; succeeds unauthenticated) → `turn/start{threadId, input:[{type:'text',text}]}` returns an `inProgress` turn immediately; the terminal signal is the `turn/completed` notification carrying `Turn{status: completed|interrupted|failed|inProgress, error}`.
- Approvals are server-initiated requests — `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request` — answered with `accept`/`decline`-family decisions.
- Auth: `account/login/start{type:'apiKey', apiKey}` is a first-class RPC and `account/read` reports `requiresOpenaiAuth` — and an unauthenticated `turn/start` does NOT fail fast (it hangs in retry), so the backend MUST pre-check auth and settle `error` loudly instead of waiting on the turn.
- Isolation: `CODEX_HOME` redirection is honored (the `initialize` response echoes it, so tests can assert isolation), and `ephemeral: true` threads leave no session files at all.
`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins, with the latest nullable-phase message as the compatibility fallback; commentary never replaces an answer. A completed turn without an answer, a failed or interrupted remote turn, malformed payload, protocol closure, early process exit, or unknown server request becomes a shared `error`. Local cancellation wins the race and remains `aborted`.
## Isolation and credentials
The unattended wire declines command and file approvals, grants no requested permissions for the turn, and declines MCP elicitation. It fails closed for every other server request instead of waiting for UI that this provider does not supply.
Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`.
Publication transfers the wire and process handle to one holder. Idempotent disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. An unpublished startup failure performs the same cleanup before `start()` rejects.
## Permission and approval policy
## Claude Code provider
Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP.
The Claude Code sibling follows the same fixed-name, self-contained, one-shot, parent-cwd, shared-result, and managed-tree boundaries. Its product-specific implementation will use the official Agent SDK's `query()` and spawn hook, keep SDK protocol ownership separate from `dsh-subprocess` process-tree ownership, omit human-interaction callbacks, and derive only a strict final SDK result after the message iterator ends normally.
## StopReason mapping
The Claude package will expose the same two configuration concerns, `env` and `disposeGraceMs`. Product installation, native settings, and login remain deployment responsibilities rather than plugin-managed state. This note stays proposed until that sibling and the combined two-product evidence are implemented.
Claude Code: `success` → `completed`; `error_max_turns`, `error_during_execution`, `error_max_budget_usd`, `error_max_structured_output_retries` → `error` (aligning with the ACP call on `max_turn_requests`: an unfinished task is not success); generator abort → `aborted`; anything unknown → `error`. Codex: `Turn.status` `completed` → `completed`; `interrupted` → `aborted`; `failed` with `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`, any other `failed` → `error`; transport/spawn/auth-precheck failure → `error` (or `aborted` if cancel was requested). In both, `cancel()` is the ACP shape: flag + abort/interrupt + a cancel-settled race arm so an uncooperative child cannot stall the result.
## Evidence contract
Liveness posture, stated explicitly: teardown timing is config, turn duration is not. Both backends take the dispose ladder's grace periods as defaulted validated config fields (the ACP backend's `disposeEofGraceMs`/`disposeGraceMs` shape, carried by the extraction), but there is deliberately NO turn-duration or startup timeout — matching ACP, liveness during a turn belongs to the caller via `cancel()`/the abort signal, a subagent turn is legitimately minutes long, and the Codex auth precheck removes the one verified guaranteed-hang; a deployment wanting a wall-clock bound cancels from the parent.
Each product owns package-level branch-complete tests, a required real-product spec, and a real Loader snapshot. The real-product tier must use the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product configuration, and a loopback fixed-answer model; it fails rather than skips when the binary, authentication request, task, answer, cancellation, or process-exit proof is missing.
## Testing
Named at every tier per the root AGENTS.md rule, and de-risked up front:
- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape.
- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy.
- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile.
The Codex evidence pins `@openai/codex@0.146.0` / `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and every managed handle reaching whole-tree quiescence. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, full persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still uses `codex` from `PATH`.
## Alternatives considered
### Why not the official `@openai/codex-sdk` instead of a hand-rolled client?
**Direct model HTTP or `codex exec`.** These paths bypass the products' official extensible process protocols and cannot prove product configuration, tools, approvals, lifecycle, or teardown. The providers use app-server and the official Agent SDK instead.
The dispose ladder and env scrub require owning the child process (spawn args, env, signals, exit await); the SDK hides the process. The wire format is trivial to frame (LF JSON), the shapes are generatable per pinned version (`codex app-server generate-json-schema`), and the repo precedent (`hook-protocol`) is to own thin protocol cores rather than wrap someone's runtime. The SDK would save protocol-evolution maintenance but costs the exact control this backend exists to have.
**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership before two production consumers demonstrated a missing common contract, so product-specific adapters call the existing seams directly.
### Why not a model-visible `subagent_type` parameter (one Task-style tool)?
**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service.
Claude Code's own Task tool puts the subagent type in the model-facing schema, selecting a prompt-plus-toolset persona. Here the choice is between EXECUTION ENGINES, and only the deployer knows which engines have credentials configured — so selection stays deployment config, preserving `dsh-tool-subagent`'s documented one-provider-per-tool contract. A persona-style type selector would be a separate Agent Note against the tool, not the backends.
**Product doubles as required evidence.** Doubles are useful for exhaustive private protocol branches but do not prove package exports, official binaries, authentication, or real process behavior. Required evidence drives the official product against loopback model fixtures.
### Why not login-state credentials and the user's own config?
**Plugin-managed login, product home, models, or permissions.** Those settings would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only explicit environment overlay and teardown grace; unattended interaction fails closed.
Inheriting `~/.claude` / `~/.codex` (subscription login, user settings, skills, MCP servers) would make child behavior depend on host-machine state and punch an implicit exception through the "credentials enter explicitly via `config.env`, never ambiently" rule the ACP backend and bash executor established. API-key-only plus forced config-dir isolation keeps runs reproducible; deployments wanting shared state can point the config-dir field at a persistent directory deliberately.
### Why not a driver-injection seam for the Claude Code keyless tests?
Injecting a fake `query()` would mock our own boundary and leave the real SDK load path untested (the real-over-mock policy in docs/testing.md). The risk that justified considering it — the SDK↔CLI stream-json control protocol being internal — was retired by the spike: the fake-CLI harness works against the real pinned SDK today. If an SDK upgrade breaks the mock, the keyless suite fails the upgrade PR, which is the gate working.
### Why not ACP adapters (e.g. `claude-code-acp`) reusing the existing backend?
Community shims wrap both engines in ACP, which would make them "just config" on `dsh-subagent-acp`. But that inserts an unofficial third-party layer between the harness and the engine, erases the native control surfaces this Agent Note exposes (permissionMode, sandboxMode/approvalPolicy, config-dir isolation, apiKey RPC), and trades first-party protocol stability for a shim's release cadence. First-party surfaces — the Agent SDK and the app-server — are the supported integration points.
**Continuation, progress, and shared parent context.** The first user result needs one self-contained task and one final answer. Product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and background collection need separate user contracts and are not prebuilt.
## Acceptance criteria
On a machine with both engines and keys configured: a REPL-driven model completes one real file task through `subagent_claude_code` and one through `subagent_codex`, the tool result being the child's final answer, with only `tool/call` + `tool/result` in the parent session log. Keyless suites pass at 100% per-file coverage in a credential-less environment, asserting isolation (scrubbed child env, no temp config dirs left after dispose) and that child behavior is unchanged by the presence or absence of `~/.claude` / `~/.codex`. Cancelling a parent turn quiesces both backends in bounded time with no leftover child processes. E2e suites self-skip cleanly, naming the missing prerequisite.
The proposal is complete when both fixed tools reach their corresponding real products through the Loader, return exact final answers or explicit failure/cancellation, persist the complete model-visible parent transcript, and prove managed process-tree quiescence in required keyless CI. Both packages have complete configuration, lifecycle, failure, model-experience, and limitation documentation; the generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests.
The implemented Codex half already satisfies this contract for its fixed tool and 0.146.0 product baseline. The note remains proposed because the Claude Code sibling and combined final evidence are not yet implemented.
## Risks
- `codex app-server` is CLI-flagged experimental and its v1/v2 vocabularies coexist; the client pins 0.142.5, implements v2 only, and consumes unknown methods/notifications without crashing, but a future codex bump can still force rework (regenerate schemas and re-run the keyless suite on every bump — the development-time enforcement behind the no-runtime-version-probe stance above).
- The Claude Code fake-CLI mock rides an internal protocol: any SDK upgrade must go through the keyless suite, and a breaking control-protocol change means reworking the mock (fallback: the driver-injection seam rejected above becomes the escape hatch).
- The SDK's optionalDependencies weigh ~280MB per platform — accepted, and confined to the one backend package.
- The SDK's SIGKILL branch beyond EOF→SIGTERM was not observed and is trusted; e2e keeps a no-leftover-process assertion.
- Codex is a deployment prerequisite (no npm-bundled binary); a missing or incompatible binary surfaces as a loud spawn/protocol `error`, not a version probe.
- Every run pays a fresh child process and only the final answer surfaces — thoughts, tool cards, and usage are consumed and dropped; pooling, intermediate-progress surfacing, `sendMessage`/`resume`, `outputSchema` via the SDK's `outputFormat`, and named subagent types via the SDK's `agents` option are all deliberate deferrals.
- The Codex app-server protocol is product-versioned and may change; production performs no runtime version probe, so every supported baseline change must refresh schema investigation and real-product compatibility evidence.
- Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's own authority.
- Every delegation pays for a fresh process and independent model context, and only final text reaches the parent.
- Product tool or file side effects are not rolled back when a run fails or is cancelled.
- Unattended approval denial keeps the initial provider safe from interactive hangs but cannot satisfy tasks that require new permission.

View File

@@ -1,4 +1,4 @@
# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent(智能体)的进程外委派)
# Agent Note: Claude Code 与 Codex subagent 提供方
Status: proposed
@@ -6,84 +6,69 @@ Status: proposed
## 问题
subagent seam([seam Agent Note(agent 决策记录)](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP(Agent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为机械上相似的兄弟。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。
命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子 agent 的运行方式即可委派工作,但 harness 需要接入真实 Codex 与 Claude Code 产品的第一方路径。一个实用的首版必须能把一个自包含任务交给任一产品,使用父会话的工作区,返回最终答案或明确失败,并且不留下任何受管产品进程。
产品集成不得让任务文本、工作目录、取消、结果结算或进程树出现第二个所有者。它还必须在强制无密钥测试中证明真实产品链路:假包装层或直接向模型发起的 HTTP 请求无法证明 Loader、提供方注册、官方产品协议、认证、最终答案和清理能够正确组合运行。
## 提案
两个兄弟提供方包(package),作为 ACP 后端的结构变体,另加一次提取:
两个同级的单次执行提供方注册固定部署名称,并通过两个固定的 `dsh-tool-subagent` 实例对外提供:
- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI(命令行界面)作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——「claude」保留给未来的 `dsh-llm` 适配器。
- `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread/turn,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。
- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose(资源释放)阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。
- `@deepseek-ai/dsh-subagent-codex` 注册 `codex`,由 `codex app-server --stdio` 驱动,现已实现。
- `@deepseek-ai/dsh-subagent-claude-code` 将注册 `claude-code`,由官方 Claude Agent SDK 及其捆绑的 CLI(命令行界面)驱动,目前仍待实现。
两个提供方逐字复制 ACP 后端的 seam 姿态:每次 `start` 创建全新子进程、恰好一次提示词往返、所有能力均为 `false`、`inheritsParentContext: false`、忽略 `request.parent`/`request.agentOptions`、`id = SessionId(randomUUID())`,且 `result` 从不 reject——子进程级失败扁平化为 stop reason,原始错误则通过 `onError` spec 回调送到 `ctx.logger`。模型暴露无需新代码:每个提供方各加载一次 `dsh-tool-subagent`,使用不同的 `toolName`(`subagent_claude_code`、`subagent_codex`)。无需新的会话事件——唯一的模型可见产物是工具结果,因此可重建性与 ACP 完全相同。明确边界:会话日志重建模型可见的 transcript(文本记录),而不是工作区变更历史——获准写入的子进程将文件作为日志之外的环境副作用进行修改,与 bash 工具和 ACP 后端现有行为完全一致;回放复现请求,而非磁盘。
面向模型的工具为 `subagent_codex` 和 `subagent_claude_code`。每个工具在部署时绑定一个提供方,接受一个独立任务,并在初始组合中省略后台参数。产品选择不作为额外的模型参数。
## 已验证的接口事实(固定版本)
两个提供方均报告 `inheritsParentContext: false`,不声明任何可选启动能力,并使用父会话的工作目录而不复制父会话对话。每次调用都会创建一个全新的产品进程和一次不可恢复的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台 dispose(资源释放);共享子进程服务负责环境清洗、进程树终止和整棵进程树的退出观测。
两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过大声失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。
## Codex 提供方
**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行逐级加强对 CLI 子进程的终止措施:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 Agent Note 范围内。
Codex 提供方的固定名称为 `codex`,固定命令为 `codex app-server --stdio`。其公开配置只包含显式 `env` 条目和取正有限值的 `disposeGraceMs`;不公开命令、工作目录、模型、基础 URL、API 密钥、沙箱、审批、产品主目录或会话设置。生产环境从 `PATH` 解析 Codex,并使用宿主机原生的 Codex 配置和认证。`dsh-subprocess` 会清洗环境中形似凭证的变量,之后再合并显式 `env` 值。
**codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。
在发布运行实例前,提供方会验证任务非空且仅含文本,启动受管 app-server,依次执行 `initialize` → `initialized`,并在父工作区中创建一个 `ephemeral: true` 线程。返回的运行实例只负责一次 `turn/start`;产品线程 ID 和轮次 ID 始终为私有信息,不会持久化到父会话中。
- 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的轮次;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。
- 审批是服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。
- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时大声结算为 `error`,而非等待轮次。
- 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),`ephemeral: true` 的 thread 不留任何会话文件。
`turn/completed` 是判定远端终止状态的权威依据。最新一条内容非空且带有 `phase: "final_answer"` 的 `agentMessage` 优先;阶段字段可为空值的最新消息作为兼容回退。过程说明绝不取代答案。已完成但无答案的轮次、失败或中断的远端轮次、格式错误的载荷、协议关闭、进程提前退出或未知服务端请求,都会结算为共享的 `error`。本地取消会在竞态中胜出,结果仍为 `aborted`。
## 隔离与凭证
无人值守通信层会拒绝命令审批和文件审批,对于该轮次请求的权限一概不予授予,并拒绝 MCP elicitation。对于其他所有服务端请求,它都会以失败响应,而不会等待本提供方并未提供的 UI。
部署只使用 API key 认证,子进程不得看到宿主用户的 Claude Code / Codex 配置:行为必须只由 `cordis.yml` 决定。每次运行获得一个全新的 `mkdtemp` 配置目录——Claude Code 使用 `CLAUDE_CONFIG_DIR`(并显式设置 `settingSources: []`),Codex 使用 `CODEX_HOME`——dispose 时尽力删除;配置字段也可以固定一个持久目录。子进程环境通过提取逐字复用 ACP 后端的 `buildChildEnv` 语义:转发环境变量,但移除凭证形态的变量(`/KEY|SECRET|TOKEN/i`),再叠加 `config.env`——因此 `PATH`、`HOME`、`TMPDIR`、locale 和代理变量保留,CLI 正常运行;只有环境中的凭证形态变量被清洗(Claude Code 的 `ANTHROPIC_API_KEY` 通过 `config.env` 显式进入),Codex key 则通过 `account/login/start` RPC 进入隔离的 `CODEX_HOME`,而非手写 `auth.json`。
发布时,协议连接和进程句柄会移交给唯一持有者。幂等 dispose 会尽力中断已知轮次、关闭协议连接、结束 stdin、调用共享的逐级终止流程,并等待整棵进程树退出。若启动在发布前失败,`start()` 会先执行同样的清理,再以拒绝结束。
## 权限与审批策略
## Claude Code 提供方
每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有漏过请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中提示词不会到达人类,与 ACP 一致。
Claude Code 同级提供方沿用相同边界:名称固定、任务自包含、仅执行一次、使用父级工作目录、结果由共享服务结算,且进程树受管。其产品专用实现将使用官方 Agent SDK 的 `query()` 与 spawn 钩子,将 SDK 协议所有权同 `dsh-subprocess` 的进程树所有权分开,不设置人机交互回调,并且仅在消息迭代器正常结束后提取严格的最终 SDK 结果。
## StopReason 映射
Claude 包将公开相同的两个配置项:`env` 和 `disposeGraceMs`。产品安装、原生设置和登录仍由部署方负责,插件不管理这些内容。在该同级提供方及两种产品的组合证据实现之前,本文仍处于 proposed 状态。
Claude Code:`success` → `completed`;`error_max_turns`、`error_during_execution`、`error_max_budget_usd`、`error_max_structured_output_retries` → `error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不是成功);生成器中止 → `aborted`;未知值 → `error`。Codex:`Turn.status` 为 `completed` → `completed`;`interrupted` → `aborted`;`failed` 且 `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`,其他 `failed` → `error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。
## 证据契约
活性姿态,明确声明:teardown 时序是配置项,轮次时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意不设轮次时长或启动超时——与 ACP 一致:轮次期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent 轮次持续数分钟也属合理,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。
每个产品都有包(package)级分支完备测试、一项必需的真实产品规格测试,以及一份真实 Loader 快照。真实产品层必须使用受测的确切官方发行包、非空的假产品密钥、隔离的临时工作区与产品配置,以及固定答案的环回模型;如果缺少二进制文件、认证请求、任务、答案、取消或进程退出证明中的任一项,该层必须失败而非跳过。
## 测试
依照根 AGENTS.md 规则在每个层级明确命名,并预先消除风险:
- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR(热模块替换)提供方清理、导出形状、子进程环境隔离断言和临时目录删除;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并讲 plain stream-json,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。
- **有密钥 e2e 测试**:每个后端的真实引擎执行并由磁盘验证真实文件工作,固定使用开放后的配置,以免验收与不造成损害的默认值冲突——Claude Code 使用 `permissionMode: 'acceptEdits'`,Codex 使用 `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'`;自跳过会准确报告缺失的是二进制还是 key。CI 没有密钥,因此依照有密钥策略在本地运行。
- **快照测试**:以 `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` 推迟——即 ACP 后端也推迟的独立回放形状([按会话回放 Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md));在此期间由无密钥套件提供确定性覆盖。
Codex 证据固定使用 `@openai/codex@0.146.0` / `codex-cli 0.146.0`。其真实产品规格测试会观测确切的 Bearer 密钥、原始任务、字节完全一致的最终答案、无人值守下命令被拒绝且不产生文件副作用、本地取消,以及每个受管句柄对应的整棵进程树均达到完全停稳。其 Loader 快照固定记录不含后台参数的工具 schema、确切的工具调用与工具结果、完整持久化的父会话、产品请求,以及清理前的完全停稳状态。该 npm 包是用于提供可复现证据的开发依赖;生产环境仍使用 `PATH` 中的 `codex`。
## 曾考虑的替代方案
### 为什么不用官方 `@openai/codex-sdk` 而手写客户端?
**直接向模型发起 HTTP 请求或 `codex exec`。** 这些路径会绕过产品官方的可扩展进程协议,无法证明产品配置、工具、审批、生命周期或清理。提供方改用 app-server 和官方 Agent SDK。
dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式(wire format)极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),仓库先例(`hook-protocol`)是拥有薄协议核心而非包装他人的运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。
**共享产品进程辅助包。** 现有 subagent seam 和子进程 seam 已经负责所有共享任务、结果、环境和进程树关注点。在两个生产消费方证明通用契约确有缺口之前,新辅助包会造成所有权重复,因此产品专用适配器直接调用现有 seam。
### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)?
**面向模型的产品选择器。** 产品可用性与认证属于部署事实。两个固定工具让各自的 schema 和提供方绑定保持显式,并避免向通用服务加入动态选择状态。
Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个提示词 + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 Agent Note,而非针对后端。
**将产品替身作为必需证据。** 替身适合完整覆盖私有协议分支,但无法证明包导出、官方二进制文件、认证或真实进程行为。必需证据使用环回模型 fixture(测试前置数据)驱动官方产品。
### 为什么不用登录态凭证和用户自身的配置?
**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外另立一个管理权威,并把单次执行提供方变成账户管理功能。提供方只公开显式环境叠加和清理宽限期;无人值守交互一律以失败响应。
继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill(技能)、MCP 服务器)会使子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打开一个隐式例外。仅 API key 加强制配置目录隔离使运行可复现;需要共享状态的部署可以有意将配置目录字段指向一个持久目录。
### 为什么不为 Claude Code 无密钥测试注入驱动层 seam?
注入假的 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部实现——已被 spike 消除:假 CLI harness 今天能对真实固定版本的 SDK 正常工作。如果 SDK 升级破坏了 mock,无密钥套件会让升级 PR(Pull Request)失败,这正是门禁在发挥作用。
### 为什么不用 ACP 适配器(如 `claude-code-acp`)复用既有后端?
社区 shim 将两个引擎包装为 ACP,这会使它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方的第三方层,抹去了本 Agent Note 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏替换了第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。
**续接、进度与共享父级上下文。** 首版面向用户的功能只需接收一个自包含任务,并返回一个最终答案。产品会话、恢复、后续请求、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集各自需要独立的用户契约,本提案不会预先构建这些内容。
## 验收标准
在两个引擎和密钥均已配置的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终答案,父会话日志中仅有 `tool/call` + `tool/result`。无密钥套件在无凭证环境下以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程环境、dispose 后无残留临时配置目录),并断言 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内完全停稳,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。
当两个固定工具都能通过 Loader 接入各自的真实产品,返回精确的最终答案或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制无密钥 CI 中证明受管进程树完全停稳时,本提案即告完成。两个包都具备覆盖配置、生命周期、失败、模型体验与限制的完整文档;生成的包记录、配置记录、能力记录、依赖记录和第三方记录均与已发布的 manifest(元数据清单)一致。
已实现的 Codex 部分已经针对其固定工具和 0.146.0 产品基线满足此契约。本文仍处于 proposed 状态,因为 Claude Code 同级提供方和两种产品的最终组合证据尚未实现。
## 风险
- `codex app-server` 被 CLI 标记为实验性,其 v1/v2 词汇共存;客户端固定 0.142.5、仅实现 v2、对未知方法/通知消费而不崩溃,但未来 codex 升级仍可能迫使返工(每次升级重新生成 schema 并重跑无密钥套件——这是上述「不做运行时版本探测」立场背后的开发时强制执行)。
- Claude Code 假 CLI mock 依赖一个内部协议:任何 SDK 升级都必须通过无密钥套件,控制协议的破坏性变更意味着返工 mock(回退方案:上面否决的驱动注入 seam 成为逃生舱口)。
- SDK 的 optionalDependencies 每平台约 280MB——已接受,限制在单个后端包内。
- SDK 的 SIGKILL 分支(EOF→SIGTERM 之后)未被观察到,信任其实现;e2e 保留无残留进程断言。
- Codex 是部署前置条件(无 npm 内置二进制);缺失或不兼容的二进制以大声的 spawn/协议 `error` 呈现,而非版本探测。
- 每次运行付出一个全新子进程的代价,且仅最终答案浮出——思考、工具卡片和用量被消费后丢弃;连接池、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意推迟。
- Codex app-server 协议随产品版本演进,可能发生变化;生产环境不执行运行时版本探测,因此每次变更受支持的基线时,都必须重新开展 schema 调查并更新真实产品兼容性证据。
- 产品原生配置使行为取决于部署环境中安装的产品及其账户状态。强制测试会隔离这些输入,而生产环境则刻意让这些输入继续由产品自身掌控。
- 每次委派都要承担启动全新进程和使用独立模型上下文的成本,而且只有最终文本会传回父 agent。
- 运行失败或被取消时,产品工具或文件副作用不会回滚。
- 无人值守模式下拒绝审批可防止初始提供方因交互而挂起,但无法满足需要新权限的任务。