From 1daa35b6e3a8fca62b9e42abed6a0d8cfc6ce398 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 16:02:17 +0800 Subject: [PATCH 01/27] feat(subagent): add Codex product provider --- ...code-and-codex-subagent-backends.i18n.yaml | 6 +- ...claude-code-and-codex-subagent-backends.md | 85 +- ...ude-code-and-codex-subagent-backends.zh.md | 85 +- THIRD_PARTY_NOTICES.md | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 7 +- docs/config-catalog.md | 19 + docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- docs/module-graph.md | 8 + .../subagent/subagent-codex/cordis.yml | 42 + .../subagent/subagent-codex/fixture.ts | 102 ++ .../subagent-codex/evidence.expected.json | 38 + .../subagent-codex/session.expected.jsonl | 25 + .../subagent-product-providers.snapshot.ts | 167 +++ examples/package.json | 2 + knip.json | 13 + packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 3 +- packages/subagent/README.zh.md | 3 +- .../subagent/subagent-codex/README.i18n.yaml | 6 + packages/subagent/subagent-codex/README.md | 88 ++ packages/subagent/subagent-codex/README.zh.md | 88 ++ packages/subagent/subagent-codex/package.json | 53 + packages/subagent/subagent-codex/src/index.ts | 89 ++ .../subagent/subagent-codex/src/invariant.ts | 30 + packages/subagent/subagent-codex/src/run.ts | 209 ++++ packages/subagent/subagent-codex/src/wire.ts | 366 ++++++ .../subagent-codex/tests/real-product.spec.ts | 230 ++++ .../subagent-codex/tests/responses-fixture.ts | 283 +++++ .../tests/subagent-codex.spec.ts | 1053 +++++++++++++++++ .../subagent/subagent-codex/tsconfig.json | 42 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 1 + packages/subagent/subagent/README.zh.md | 1 + pnpm-lock.yaml | 111 ++ scripts/gen-doc-graphs.ts | 6 +- tsconfig.host.json | 1 + vitest.config.ts | 1 + 45 files changed, 3170 insertions(+), 126 deletions(-) create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts create mode 100644 examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl create mode 100644 examples/acp-agent/tests/subagent-product-providers.snapshot.ts create mode 100644 packages/subagent/subagent-codex/README.i18n.yaml create mode 100644 packages/subagent/subagent-codex/README.md create mode 100644 packages/subagent/subagent-codex/README.zh.md create mode 100644 packages/subagent/subagent-codex/package.json create mode 100644 packages/subagent/subagent-codex/src/index.ts create mode 100644 packages/subagent/subagent-codex/src/invariant.ts create mode 100644 packages/subagent/subagent-codex/src/run.ts create mode 100644 packages/subagent/subagent-codex/src/wire.ts create mode 100644 packages/subagent/subagent-codex/tests/real-product.spec.ts create mode 100644 packages/subagent/subagent-codex/tests/responses-fixture.ts create mode 100644 packages/subagent/subagent-codex/tests/subagent-codex.spec.ts create mode 100644 packages/subagent/subagent-codex/tsconfig.json diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index ecb4e98def..27fb29dffd 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -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 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index ee8576f97a..86a2e3489a 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -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. diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 14e8dde04d..ef2098b3af 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -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。 +- 运行失败或被取消时,产品工具或文件副作用不会回滚。 +- 无人值守模式下拒绝审批可防止初始提供方因交互而挂起,但无法满足需要新权限的任务。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8cd2964da6..5c1eb10acf 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -96,6 +96,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index b5136c31df..d2c60f23e1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/architecture.md -architecture.md: ee50e249f8e588a3f92f57db929c6bbfb1c853dd -architecture.zh.md: c2e596cd10c21be2bcaad11323277d04ef9e74a1 +architecture.md: af6c6fee0c11fcf2935956044996137367932a8b +architecture.zh.md: f399deb29efd3d64b63427c597de9f612ac37643 diff --git a/docs/architecture.md b/docs/architecture.md index ee50e249f8..af6c6fee0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -167,7 +167,7 @@ Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is on A swappable capability usually has **interface / implementation / consumer** layers: service/events, backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. -Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, use ACP children, or delegate one self-contained turn to a real product provider such as Codex ([subagent.md](core-data-structures/subagent.md)). `dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c2e596cd10..f399deb29e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -167,7 +167,7 @@ idle inject: 可替换功能通常具有**接口/实现/消费方**三层:服务和事件、后端、面向模型的工具和提示词。Bash 是参考实现;[功能图](capability-seams.md)映射了每个包族。 -例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 +例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀、使用 ACP(Agent Client Protocol)子 agent,或将一个独立完整的轮次委派给 Codex 等真实产品提供方([subagent.md](core-data-structures/subagent.md))。 `dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 3976c1fb24..44af0b5e76 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -103,6 +103,7 @@ flowchart LR pkg_bash_sandbox["bash-sandbox"] pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_codex["subagent-codex"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] @@ -223,6 +224,7 @@ flowchart LR pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents + pkg_subagent_codex --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents pkg_subprocess --> svc_subprocess @@ -311,6 +313,7 @@ flowchart LR svc_subprocess --> pkg_bash_sandbox svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp + svc_subprocess --> pkg_subagent_codex svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -370,7 +373,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | - | The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | @@ -381,7 +384,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f85d39dcd7..8ac330c3f9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1519,6 +1519,25 @@ export type PermissionPolicy = 'allow' | 'reject' Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) +## `@deepseek-ai/dsh-subagent-codex` + +Requires: `subagents` · `subprocess` + +```ts config-catalog +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for app-server process-tree termination. */ + disposeGraceMs?: number +} +``` + +Source: [`packages/subagent/subagent-codex/src/index.ts:29`](../packages/subagent/subagent-codex/src/index.ts) + ## `@deepseek-ai/dsh-subagent-dsh-sdk` Requires: `subagents` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index a4c20672c0..f2438bea7a 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/cookbook/extension-cookbook.md -extension-cookbook.md: 07073c39f8a9b998b09b0815257d995b174c8be7 -extension-cookbook.zh.md: 10664af9a39f0a1663c316869ba8ef02f67c29fe +extension-cookbook.md: 379bd2644a4a7de005c8ea56d4857b6a6f9143b8 +extension-cookbook.zh.md: 09a5163c3b47e52e0e0e88f000a8f04302fa93e4 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 07073c39f8..379bd2644a 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -118,7 +118,7 @@ Every product feature maps to a listener on a documented extension seam — the | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | -| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`) + `dsh-tool-subagent` exposing one configured provider to the model | +| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`) + `dsh-tool-subagent` exposing one configured provider to the model | | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 10664af9a3..09a5163c3b 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -118,7 +118,7 @@ export function apply(ctx: Context) { | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | -| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | +| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d5de81fa45..d5682a47bc 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/core-data-structures/subagent.md -subagent.md: c5fbf80ae71f99606dd86e38f06a4511b4ae4c73 -subagent.zh.md: 42c1fa7cb10863c1aa4ae975171b901207c08b85 +subagent.md: 917913470da389dccac83e455cf23486a94c23b1 +subagent.zh.md: efe12a5a5fe40d664f3071036157acd704b10c57 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c5fbf80ae7..917913470d 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 42c1fa7cb1..efe12a5a5f 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为四个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/module-graph.md b/docs/module-graph.md index b2a70d7ab7..35f832154b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_codex["subagent-codex"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] @@ -1004,6 +1005,12 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_sdk_protocol + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -1225,6 +1232,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml new file mode 100644 index 0000000000..cc50ea2587 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -0,0 +1,42 @@ +# Test-only composition: one real Codex app-server delegation through the +# Loader, fixed provider tool, common foreground settlement, and JSONL store. +- id: fixture + name: './fixture.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.DSH_TEST_OPENAI_API_KEY + CODEX_HOME: !!js process.cwd() + '/codex-home' + HOME: !!js process.cwd() + XDG_CONFIG_HOME: !!js process.cwd() + '/xdg' + PATH: !!js process.env.PATH + HTTP_PROXY: '' + HTTPS_PROXY: '' + ALL_PROXY: '' + NO_PROXY: '127.0.0.1,localhost' + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'Delegate the task through the fixed Codex tool.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts new file mode 100644 index 0000000000..9618c83654 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts @@ -0,0 +1,102 @@ +/** Deterministic parent model and process-quiescence observer for the Codex Loader snapshot. */ + +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + SubprocessHandle, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' + +const CODEX_TASK = 'Return the Loader snapshot sentinel exactly.' +const QUIESCENCE_FILE = '.codex-quiescence.json' + +function toolResultText(options: GenerateOptions): string { + return options.messages.at(-1)?.content + .filter(block => block.type === 'tool-result') + .flatMap(block => block.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') ?? '' +} + +class CodexDelegatingAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const result = toolResultText(options) + if (result.length === 0) { + const args = JSON.stringify({ + description: 'Codex Loader snapshot', + prompt: CODEX_TASK, + }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 0, + id: CallId('call-codex-loader'), + name: 'subagent_codex', + argumentsDelta: args, + } + yield { + type: 'block-end', + index: 0, + block: { + type: 'tool-call', + id: CallId('call-codex-loader'), + name: 'subagent_codex', + arguments: args, + }, + } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + + const reply = `Codex child returned: ${result}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +interface ObservedProcess { + readonly spec: SubprocessSpawnSpec + readonly handle: SubprocessHandle +} + +export const name = 'codex-loader-snapshot-fixture' +export const inject = ['llm', 'subprocess'] + +/** + * Register the deterministic parent adapter and record whether every spawned + * product tree was already quiet when the assembled application disposed. + * @param ctx - Loader context supplying the LLM and subprocess seams. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new CodexDelegatingAdapter()) + ctx.effect(() => { + const observed: ObservedProcess[] = [] + const originalSpawn = ctx.subprocess.spawn.bind(ctx.subprocess) + ctx.subprocess.spawn = (spec: SubprocessSpawnSpec): SubprocessHandle => { + const handle = originalSpawn(spec) + observed.push({ spec, handle }) + return handle + } + return async () => { + ctx.subprocess.spawn = originalSpawn + const alreadyExited = AbortSignal.abort() + const processes = await Promise.all(observed.map(async ({ spec, handle }) => ({ + argv: [...spec.argv], + quiescent: await handle.waitForExit(alreadyExited), + outcome: await handle.done, + }))) + await writeFile( + join(process.cwd(), QUIESCENCE_FILE), + `${JSON.stringify({ processes })}\n`, + ) + } + }, 'codex Loader snapshot process observer') +} diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json b/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json new file mode 100644 index 0000000000..f6f9b995ae --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json @@ -0,0 +1,38 @@ +{ + "stdout": { + "type": "result", + "success": true, + "sessionId": "{{sessionId}}", + "turn": 1, + "result": "Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0", + "reason": { + "kind": "completed" + }, + "usage": { + "inputTokens": 20, + "outputTokens": 61 + } + }, + "request": { + "method": "POST", + "path": "/v1/responses", + "authorization": "Bearer dsh-fake-openai-loader-key", + "taskObserved": true + }, + "quiescence": { + "processes": [ + { + "argv": [ + "codex", + "app-server", + "--stdio" + ], + "quiescent": true, + "outcome": { + "exitCode": 0, + "signal": null + } + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl new file mode 100644 index 0000000000..e15b81ddf0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl @@ -0,0 +1,25 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Delegate through Codex once."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Delegate through Codex once.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"mock","model":"mock-delegate"},"system":"{{system}}","tools":[{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent_codex","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}}]},"reason":"initial"}} +{"type":"request/context","seq":5,"time":0,"data":{"provider":"mock","model":"mock-delegate"}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-codex-loader","name":"subagent_codex","argumentsDelta":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}} +{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-codex-loader"},"content":[{"type":"tool-result","toolCallId":"call-codex-loader","content":[{"type":"text","text":"REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":56}}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":56}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts new file mode 100644 index 0000000000..6df5b7f411 --- /dev/null +++ b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts @@ -0,0 +1,167 @@ +/** + * Real-product Loader snapshots for fixed subagent providers. + * + * PR1 owns the Codex scenario. PR2 extends this file with the sibling Claude + * Code scenario and reruns both from its final stacked candidate. + */ + +import { dirname, delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { + normalizeSessionLog, + normalizeStdout, + scrubSystemPrompts, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' +import { startResponsesFixture } from '../../../packages/subagent/subagent-codex/tests/responses-fixture.ts' + +const testsDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) +const fixtureDir = join(testsDir, 'fixtures/subagent/subagent-codex') +const configPath = join(fixtureDir, 'cordis.yml') +const snapshotDir = join(testsDir, 'snapshots/subagent-codex') +const sessionExpected = join(snapshotDir, 'session.expected.jsonl') +const evidenceExpected = join(snapshotDir, 'evidence.expected.json') +const cliBin = join(repoRoot, 'packages/examples/cli-demo/src/bin.ts') +const repoTsconfig = join(repoRoot, 'tsconfig.json') +const codexBinDir = join( + repoRoot, + 'packages/subagent/subagent-codex/node_modules/.bin', +) +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' +const CODEX_SENTINEL = 'REAL_CODEX_LOADER_SENTINEL_0_146_0' +const FAKE_KEY = 'dsh-fake-openai-loader-key' + +interface PersistedSession { + readonly content: string + readonly header: { + readonly id: string + readonly cwd: string + } +} + +async function onlySession(root: string): Promise { + const paths = (await readdir(root, { recursive: true })) + .filter(path => path.endsWith('.jsonl')) + expect(paths).toHaveLength(1) + const path = paths[0] + if (path === undefined) throw new Error('Codex Loader snapshot persisted no session') + const content = await readFile(join(root, path), 'utf8') + const header = JSON.parse(content.slice(0, content.indexOf('\n'))) as PersistedSession['header'] + return { content, header } +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +describe('real product subagent providers through the Loader', () => { + it('pins the Codex tool, result, persisted Session, and process quiescence', async () => { + const responses = await startResponsesFixture([ + { kind: 'complete', text: CODEX_SENTINEL }, + ]) + let session: PersistedSession | undefined + let quiescence: unknown + try { + const result = await runLoaderSmoke({ + label: 'Codex subagent Loader snapshot', + tempDirPrefix: 'dsh-subagent-codex-loader-', + binScript: cliBin, + configPath, + binArgs: [ + '--config', + configPath, + '--output-format', + 'json', + 'Delegate through Codex once.', + ], + tsconfigPath: repoTsconfig, + processTimeoutMs: 45_000, + env: { + DSH_TEST_OPENAI_API_KEY: FAKE_KEY, + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + }, + async prepare(cwd): Promise { + const codexHome = join(cwd, 'codex-home') + await mkdir(codexHome) + await writeFile(join(codexHome, 'config.toml'), [ + 'model = "fixture-model"', + 'model_provider = "fixture"', + 'approval_policy = "on-request"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.fixture]', + 'name = "Fixture Responses"', + `base_url = "${responses.baseUrl}"`, + 'env_key = "OPENAI_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + }, + async inspect(cwd): Promise { + session = await onlySession(join(cwd, '.sessions')) + quiescence = JSON.parse(await readFile(join(cwd, '.codex-quiescence.json'), 'utf8')) + }, + }) + + expect(result.stderr).toBe('') + expect(session).toBeDefined() + if (session === undefined) throw new Error('Codex Loader snapshot session was not inspected') + const context: NormalizeContext = { + sessionIds: [session.header.id], + cwd: session.header.cwd, + } + const normalizedSession = scrubSystemPrompts(normalizeSessionLog(session.content, context)) + const request = responses.requests[0] + expect(request).toBeDefined() + if (request === undefined) throw new Error('Codex Loader snapshot made no Responses request') + const evidence = `${JSON.stringify({ + stdout: JSON.parse(normalizeStdout(result.stdout, context)) as unknown, + request: { + method: request.method, + path: request.path, + authorization: request.headers.authorization, + taskObserved: responseInputTexts(request.body) + .includes('Return the Loader snapshot sentinel exactly.'), + }, + quiescence, + }, null, 2)}\n` + + if (refreshing) { + await mkdir(snapshotDir, { recursive: true }) + await Promise.all([ + writeFile(sessionExpected, normalizedSession), + writeFile(evidenceExpected, evidence), + ]) + } + expect(normalizedSession).toBe(await readFile(sessionExpected, 'utf8')) + expect(evidence).toBe(await readFile(evidenceExpected, 'utf8')) + } finally { + await responses.close() + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS + 30_000) +}) diff --git a/examples/package.json b/examples/package.json index 849d8fc3ff..83e20a5b2b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,9 +63,11 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", + "@deepseek-ai/dsh-subagent-codex": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", diff --git a/knip.json b/knip.json index fd941b03ca..75909cc8d3 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,7 @@ "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", + "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", @@ -540,6 +541,18 @@ "tests/**/*.ts" ] }, + "packages/subagent/subagent-codex": { + "entry": [ + "tests/**/*.spec.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@openai/codex" + ] + }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 360db31ade..875a9c93a7 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/README.md -README.md: f9b04b4aa80b6feacf5d0d1fa4cf6b3b2aebc211 -README.zh.md: 0afc01a00ae9089f603531345c8a3ac4dd760326 +README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5 +README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd diff --git a/packages/subagent/README.md b/packages/subagent/README.md index f9b04b4aa8..abe1432d3c 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -11,11 +11,12 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | +| `subagent-codex/` | Out-of-process backend: a real Codex app-server process with one ephemeral thread and turn | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) | -The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 0afc01a00a..3df2b6c62d 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -11,11 +11,12 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | | `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | +| `subagent-codex/` | 进程外后端:一个真实的 Codex app-server 进程,包含一个临时 thread 和一个轮次 | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) | | `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) | -接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml new file mode 100644 index 0000000000..bee793e09a --- /dev/null +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -0,0 +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 packages/subagent/subagent-codex/README.md +README.md: ca92f935539812351dd578dca700c9a0113dcd46 +README.zh.md: 6f6690ea51970dd39c738ad0ec4f55c2a5ab2467 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md new file mode 100644 index 0000000000..ca92f93553 --- /dev/null +++ b/packages/subagent/subagent-codex/README.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-subagent-codex + +English | [中文](README.zh.md) + +This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. + +## Start and ownership + +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. + +The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. + +The unattended provider answers command and file approvals with `decline`, answers permission requests with an empty turn-scoped permission set, and declines MCP elicitation. Any other server request fails the run instead of waiting for interaction that this provider cannot supply. + +Local cancellation wins the result race and maps to `aborted`; a remote interrupted or failed turn maps to `error`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` when the current ids are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. + +## Capabilities and context + +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | +| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | + +Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. + +```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed +``` + +## Product compatibility and evidence + +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: package tests drive the real binary against a loopback Responses service with a non-empty fake key, and the Loader snapshot fixes the model-visible tool schema, exact tool result, persisted parent Session, original child task, authentication header, and pre-teardown process-tree quiescence. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. + +## Model Experience + +### Child request + +#### What the model sees + +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration. + +#### Token effect + +The child pays for an independent Codex context and turn. Child tokens do not enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Reuse depends only on Codex's own provider, model, instructions, tools, and ephemeral-thread request. + +### Parent tool result, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, the parent sees only the selected final Codex answer or the consumer's exact error for a non-completed result. Codex commentary, reasoning, tool activity, stderr, workspace diffs, and product ids are not copied into the parent Session. + +#### Token effect + +Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself. + +#### KV Cache effect + +Append-only: the new tool result follows the reusable parent request prefix. + +## Known Limitations and Deferred Work + +- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, and real-product tests. +- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. +- **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. +- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md new file mode 100644 index 0000000000..6f6690ea51 --- /dev/null +++ b/packages/subagent/subagent-codex/README.zh.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-subagent-codex + +[English](README.md) | 中文 + +本包(package)注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。 + +## 启动与所有权 + +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 + +已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 + +无人值守的提供方对命令与文件审批答复 `decline`,对权限请求返回作用域限于当前轮次的空权限集,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方无法提供的交互。 + +本地取消会在结果竞态中胜出并映射为 `aborted`;远端轮次若中断或失败,则映射为 `error`。`dispose()` 具有幂等性:如果当前标识符已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 + +## 能力与上下文 + +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | +| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | + +生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 + +```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed +``` + +## 产品兼容性与证据 + +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:包测试使用非空的伪密钥,驱动真实二进制程序连接回环 Responses 服务;Loader 快照则锁定模型可见的工具 schema、确切的工具结果、已持久化的父会话、原始子任务、身份验证请求头,以及清理前进程树的完全停稳状态。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 + +## 模型体验 + +### 子任务请求 + +#### 模型看到的内容 + +Codex 子任务会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。 + +#### 对 token 的影响 + +子任务需为独立的 Codex 上下文和轮次承担 token 开销。子任务 token 不会进入父级上下文。 + +#### 对 KV Cache 的影响 + +这与父请求缓存相互独立。能否复用只取决于 Codex 自身的提供方、模型、指令、工具和临时线程请求。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父级模型只会看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异和产品标识符均不会复制到父会话。 + +#### 对 token 的影响 + +父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。 + +#### 对 KV Cache 的影响 + +仅追加:新的工具结果接在可复用的父请求前缀之后。 + +## 已知限制与后续工作 + +- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 +- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消和真实产品测试。 +- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 +- **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json new file mode 100644 index 0000000000..ea4a2a2e45 --- /dev/null +++ b/packages/subagent/subagent-codex/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-subagent-codex", + "description": "One-shot Codex subagent provider over the official app-server protocol", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@openai/codex": "0.146.0", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts new file mode 100644 index 0000000000..00fe95d817 --- /dev/null +++ b/packages/subagent/subagent-codex/src/index.ts @@ -0,0 +1,89 @@ +/** + * Fixed Codex one-shot subagent provider. Every accepted run starts a fresh + * official `codex app-server --stdio` process in the delegating Session's + * workspace and publishes only after an ephemeral thread exists. + * + * @module @deepseek-ai/dsh-subagent-codex + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { + assertPositiveFinite, + NO_START_CAPABILITIES, + resolveChildCwd, + type ResolvedSubagentStartRequest, + type SubagentCapabilities, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { + DEFAULT_DISPOSE_GRACE_MS, + startCodexRun, + type CodexRunSpec, +} from './run.ts' + +export const name = 'subagent-codex' +export const inject = ['subagents', 'subprocess'] + +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for app-server process-tree termination. */ + disposeGraceMs?: number +} + +export const Config: z = z.object({ + env: z.dict(z.string()).default({}), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), +}) + +type ResolvedConfig = Required + +class CodexProvider implements SubagentProvider { + readonly name = 'codex' + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + + constructor( + private readonly ctx: Context, + private readonly config: ResolvedConfig, + ) {} + + start(request: ResolvedSubagentStartRequest) { + const spec: CodexRunSpec = { + cwd: resolveChildCwd( + 'subagent-codex', + undefined, + request.parent.session.header.cwd, + ), + env: this.config.env, + disposeGraceMs: this.config.disposeGraceMs, + spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), + onError: (error, stopReason) => { + this.ctx.logger.warn( + `subagent-codex: child run failed (${stopReason}): ${error.message}`, + ) + }, + } + return startCodexRun(request, spec) + } +} + +/** + * Register the fixed `codex` provider. + * @param ctx - context carrying shared subagent and subprocess services. + * @param config - explicit child environment and disposal grace. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite( + 'subagent-codex', + 'disposeGraceMs', + resolved.disposeGraceMs, + ) + ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) +} diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts new file mode 100644 index 0000000000..a0c094af9c --- /dev/null +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-codex`. + * @module @deepseek-ai/dsh-subagent-codex/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex' + +/** Cordis companion plugin name. */ +export const name = 'subagent-codex-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: lifecycle pairing belongs to the shared subagent + * service and process-tree ownership belongs to the subprocess service. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - plugin context carrying the invariant registry. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts new file mode 100644 index 0000000000..f58b0a6877 --- /dev/null +++ b/packages/subagent/subagent-codex/src/run.ts @@ -0,0 +1,209 @@ +/** + * One-shot Codex child lifecycle: spawn the real app-server through the + * subprocess seam, publish only after initialization and ephemeral thread + * creation, flatten post-publication failures, and dispose to whole-tree + * quiescence. + * + * @module @deepseek-ai/dsh-subagent-codex/run + */ + +import { randomUUID } from 'node:crypto' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + settleRunResult, + subprocessRunHandle, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, + type SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { CodexAppServerWire } from './wire.ts' + +/** Default POSIX grace between subprocess termination tiers. */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/** Fully resolved inputs for one Codex app-server run. */ +export interface CodexRunSpec { + /** Parent Session workspace, also supplied to `thread/start`. */ + readonly cwd: string + /** Explicit deployment/test environment layered after the shared scrub. */ + readonly env: Record + /** Subprocess termination grace and final tree-exit bound. */ + readonly disposeGraceMs: number + /** Shared subprocess service spawn operation. */ + readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle + /** Diagnostic sink for a post-publication error flattened into a result. */ + readonly onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Validate and preserve the one-shot task before crossing the process seam. + * @param prompt - task content accepted from the shared subagent service. + * @returns the exact non-empty text block sequence. + */ +export function textTask(prompt: readonly ContentBlock[]): string[] { + if (prompt.length === 0) { + throw new Error('subagent-codex: the one-shot task must contain only text blocks') + } + const texts: string[] = [] + for (const block of prompt) { + if (block.type !== 'text') { + throw new Error('subagent-codex: the one-shot task must contain only text blocks') + } + texts.push(block.text) + } + if (texts.every(text => text.trim().length === 0)) { + throw new Error('subagent-codex: the one-shot task must not be empty') + } + return texts +} + +async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, ms) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) + } +} + +/** + * Close the private wire, terminate the managed process tree, and wait for the + * subprocess owner to prove it is gone. + * @param wire - private app-server protocol connection. + * @param child - shared-service handle that owns the process tree. + * @param graceMs - termination grace used to bound final exit observation. + */ +export async function disposeCodexChild( + wire: CodexAppServerWire, + child: SubprocessHandle, + graceMs: number, +): Promise { + wire.close() + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } + try { + child.stdin?.end() + } catch { + // A concurrently closed stdin does not change tree ownership below. + } + child.terminate() + if (!(await treeExitsWithin(child, graceMs * 2))) { + throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') + } + await child.done +} + +/** + * Start the real `codex app-server --stdio` child and publish its one-shot run. + * @param request - resolved shared subagent request. + * @param spec - workspace, environment, process seam, and diagnostic policy. + * @returns the published run after initialization and ephemeral thread creation. + */ +export async function startCodexRun( + request: SubagentStartRequest, + spec: CodexRunSpec, +): Promise { + const texts = textTask(request.prompt) + if (request.signal.aborted) { + throw new Error('subagent-codex: request was aborted before app-server startup') + } + + const child = spec.spawn({ + argv: ['codex', 'app-server', '--stdio'], + cwd: spec.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: spec.disposeGraceMs, + env: spec.env, + }) + if (child.stdin === undefined || child.stdout === undefined) { + child.terminate() + await child.waitForExit() + throw new Error('subagent-codex: subprocess implementation dropped a piped protocol stream') + } + + const wire = new CodexAppServerWire(child.stdout, child.stdin) + const disposeProcess = (): Promise => + disposeCodexChild(wire, child, spec.disposeGraceMs) + + const processFailure: Promise = child.done.then( + outcome => Promise.reject(new Error( + 'subagent-codex: app-server exited before the run settled ' + + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, + )), + (error: unknown) => Promise.reject(thrown(error)), + ) + // A normal post-result dispose also closes the process. Keep that expected + // late rejection observed after the result race has already settled. + processFailure.catch(() => {}) + + const flags = { cancelled: false } + const runAbort = new AbortController() + let settleCancellation!: () => void + const cancellation = new Promise((resolve) => { settleCancellation = resolve }) + const requestCancel = (): void => { + if (flags.cancelled) return + flags.cancelled = true + runAbort.abort(new Error('subagent-codex: run cancelled locally')) + settleCancellation() + wire.interrupt() + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + try { + wire.start() + await Promise.race([wire.initialize(request.signal), processFailure]) + await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + try { + await disposeProcess() + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-codex: startup failed and app-server cleanup also failed', + ) + } + if (flags.cancelled) { + throw new Error('subagent-codex: request was aborted before app-server startup') + } + throw thrown(error) + } + + const collectOutput = (): ContentBlock[] => wire.collectOutput() + const result: Promise = settleRunResult({ + attempt: () => Promise.race([ + wire.runTurn(texts, runAbort.signal, () => flags.cancelled), + processFailure, + cancellation.then((): SubagentResult => ({ + output: collectOutput(), + stopReason: 'aborted', + })), + ]), + collectOutput, + cancelled: () => flags.cancelled, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: disposeProcess, + }) +} diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts new file mode 100644 index 0000000000..e8f743d1fa --- /dev/null +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -0,0 +1,366 @@ +/** + * Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC + * transport owns framing and request correlation; this module owns only the + * product methods, current thread/turn association, unattended approval + * responses, and terminal-answer selection. + * + * @module @deepseek-ai/dsh-subagent-codex/wire + */ + +import type { Readable, Writable } from 'node:stream' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult } from '@deepseek-ai/dsh-subagent' +import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' + +type JsonObject = Record + +interface Deferred { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +function object(value: unknown, label: string): JsonObject { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`subagent-codex: app-server returned invalid ${label}`) + } + return value as JsonObject +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`subagent-codex: app-server returned invalid ${label}`) + } + return value +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed protocol and stream failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`) +} + +async function raceAbort(pending: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + void pending.catch(() => {}) + throw abortError(signal) + } + let rejectAbort!: (error: Error) => void + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { rejectAbort(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([pending, aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +/** + * One app-server connection and its single ephemeral thread/turn. + * + * The class deliberately exposes no generic request surface. Supporting + * another product method must first become part of the provider contract. + */ +export class CodexAppServerWire { + private readonly transport: JsonRpcLineTransport + private readonly fatal = deferred() + private threadId: string | undefined + private turnId: string | undefined + private pendingTurnId: string | undefined + private turnCompleted: Deferred | undefined + private readonly earlyTurnNotifications: Array<{ + readonly method: string + readonly params: JsonObject + }> = [] + private readonly finalAnswers: string[] = [] + private readonly unphasedAnswers: string[] = [] + private started = false + private closed = false + + constructor( + private readonly input: Readable, + output: Writable, + ) { + this.transport = new JsonRpcLineTransport(input, output) + this.transport.onRequest((method, params) => this.handleServerRequest(method, params)) + this.transport.onNotification((method, params) => { + try { + this.handleNotification(method, params) + } catch (error: unknown) { + this.fail(thrown(error)) + } + }) + } + + /** Start reading app-server frames. */ + start(): void { + if (this.started) return + this.started = true + this.input.on('error', this.onInputError) + this.input.on('end', this.onInputEnd) + this.transport.start() + } + + /** + * Perform the required app-server initialize/initialized handshake. + * @param signal - unpublished-start cancellation. + */ + async initialize(signal: AbortSignal): Promise { + const response = object(await this.guarded(this.transport.request('initialize', { + clientInfo: { + name: 'deepseek-harness', + title: 'DeepSeek Harness', + version: '0.0.1', + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, + }, signal), signal), 'initialize response') + string(response.userAgent, 'initialize userAgent') + this.transport.notify('initialized') + await this.guarded(this.transport.flush(), signal) + } + + /** + * Create the run's private ephemeral thread and retain its identity. + * @param cwd - parent Session workspace. + * @param signal - unpublished-start cancellation. + * @returns the app-server thread id. + */ + async startThread(cwd: string, signal: AbortSignal): Promise { + const response = object(await this.guarded(this.transport.request('thread/start', { + cwd, + ephemeral: true, + }, signal), signal), 'thread/start response') + const thread = object(response.thread, 'thread/start thread') + const id = string(thread.id, 'thread/start thread id') + if (thread.ephemeral !== true) { + throw new Error('subagent-codex: app-server did not create an ephemeral thread') + } + this.threadId = id + return id + } + + /** + * Submit the one text-only task and wait for this thread/turn's authoritative + * terminal notification. + * @param texts - already validated task text blocks. + * @param signal - local cancellation for the published run. + * @param cancelled - whether local cancellation has already won. + * @returns the shared three-state subagent result. + */ + async runTurn( + texts: readonly string[], + signal: AbortSignal, + cancelled: () => boolean, + ): Promise { + if (this.threadId === undefined) { + throw new Error('subagent-codex: cannot start a turn before thread/start') + } + if (this.turnCompleted !== undefined) { + throw new Error('subagent-codex: this one-shot wire already started its turn') + } + const completion = deferred() + this.turnCompleted = completion + const response = object(await this.guarded(this.transport.request('turn/start', { + threadId: this.threadId, + input: texts.map(text => ({ type: 'text', text, text_elements: [] })), + }, signal), signal), 'turn/start response') + const turn = object(response.turn, 'turn/start turn') + this.commitTurnId(string(turn.id, 'turn/start turn id')) + + const completed = await this.guarded(completion.promise, signal) + if (cancelled()) return { output: this.collectOutput(), stopReason: 'aborted' } + + const terminal = object(completed.turn, 'turn/completed turn') + const status = terminal.status + if (status !== 'completed') { + const detail = status === 'failed' + ? `: ${JSON.stringify(terminal.error)}` + : '' + throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) + } + const output = this.collectOutput() + if (output.length === 0) { + throw new Error('subagent-codex: Codex completed without a final answer') + } + return { output, stopReason: 'completed' } + } + + /** + * Best-effort remote cancellation. Local settlement and process teardown + * remain authoritative when the child no longer accepts protocol requests. + */ + interrupt(): void { + if (this.threadId === undefined || this.turnId === undefined || this.closed) return + void this.transport.request('turn/interrupt', { + threadId: this.threadId, + turnId: this.turnId, + }).catch(() => {}) + } + + /** + * The best non-commentary answer observed so far, preserving exact bytes. + * @returns the selected final or nullable-phase text block, if any. + */ + collectOutput(): ContentBlock[] { + const selected = this.finalAnswers.length > 0 + ? this.finalAnswers.at(-1) + : this.unphasedAnswers.at(-1) + return selected !== undefined && selected.trim().length > 0 + ? [{ type: 'text', text: selected }] + : [] + } + + /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ + close(): void { + if (this.closed) return + this.closed = true + this.input.off('error', this.onInputError) + this.input.off('end', this.onInputEnd) + this.transport.close() + } + + private async guarded(pending: Promise, signal: AbortSignal): Promise { + const withFatal = Promise.race([ + pending, + this.fatal.promise.then((error): Promise => Promise.reject(error)), + ]) + return raceAbort(withFatal, signal) + } + + private fail(error: Error): void { + this.fatal.resolve(error) + } + + private readonly onInputError = (error: Error): void => { + this.fail(error) + } + + private readonly onInputEnd = (): void => { + this.fail(new Error('subagent-codex: app-server protocol stream closed')) + } + + private observePendingTurnId(id: string): void { + if (this.turnCompleted === undefined) { + throw new Error('subagent-codex: app-server referenced a turn before turn/start') + } + if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { + throw new Error('subagent-codex: app-server referenced conflicting turns') + } + this.pendingTurnId = id + } + + private commitTurnId(id: string): void { + if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { + throw new Error('subagent-codex: turn/start response did not match the active turn') + } + this.turnId = id + const notifications = this.earlyTurnNotifications.splice(0) + for (const notification of notifications) { + this.handleNotification(notification.method, notification.params) + } + } + + private validateRunIds(params: JsonObject, nullableTurn = false): void { + if (params.threadId !== this.threadId) { + throw new Error('subagent-codex: app-server request referenced another thread') + } + if (nullableTurn && params.turnId === null) return + const id = string(params.turnId, 'server request turn id') + if (this.turnId === undefined) { + this.observePendingTurnId(id) + return + } + if (id !== this.turnId) { + throw new Error('subagent-codex: app-server request referenced another turn') + } + } + + private handleServerRequest(method: string, params: JsonObject): Promise { + try { + switch (method) { + case 'item/commandExecution/requestApproval': + case 'item/fileChange/requestApproval': + this.validateRunIds(params) + return Promise.resolve({ decision: 'decline' }) + case 'item/permissions/requestApproval': + this.validateRunIds(params) + return Promise.resolve({ permissions: {}, scope: 'turn' }) + case 'mcpServer/elicitation/request': + this.validateRunIds(params, true) + return Promise.resolve({ action: 'decline', content: null, _meta: null }) + default: + throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`) + } + } catch (error: unknown) { + const normalized = thrown(error) + this.fail(normalized) + return Promise.reject(normalized) + } + } + + private handleNotification(method: string, params: JsonObject): void { + if (method === 'turn/started') { + if (params.threadId !== this.threadId) return + const turn = object(params.turn, 'turn/started turn') + if (this.turnCompleted !== undefined && this.turnId === undefined) { + this.observePendingTurnId(string(turn.id, 'turn/started turn id')) + } + return + } + if (method === 'item/completed') { + if (params.threadId !== this.threadId) return + const id = string(params.turnId, 'item/completed turn id') + if (this.turnId === undefined) { + if (this.turnCompleted !== undefined) { + this.observePendingTurnId(id) + this.earlyTurnNotifications.push({ method, params }) + } + return + } + if (id !== this.turnId) return + const item = object(params.item, 'item/completed item') + if (item.type !== 'agentMessage') return + const text = typeof item.text === 'string' + ? item.text + : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })() + if (item.phase === 'final_answer') { + this.finalAnswers.push(text) + } else if (item.phase === null) { + this.unphasedAnswers.push(text) + } else if (item.phase !== 'commentary') { + throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`) + } + return + } + if (method !== 'turn/completed') return + if (params.threadId !== this.threadId) return + const turn = object(params.turn, 'turn/completed turn') + const id = string(turn.id, 'turn/completed turn id') + const turnCompleted = this.turnCompleted + if (turnCompleted === undefined) return + if (this.turnId === undefined) { + this.observePendingTurnId(id) + this.earlyTurnNotifications.push({ method, params }) + return + } + if (id !== this.turnId) return + if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { + throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) + } + turnCompleted.resolve(params) + } +} diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts new file mode 100644 index 0000000000..77c494f762 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -0,0 +1,230 @@ +import { execFile } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import { + startResponsesFixture, + type ResponsesBehavior, + type ResponsesFixture, +} from './responses-fixture.ts' + +const execFileAsync = promisify(execFile) +const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackage = JSON.parse(readFileSync( + join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + 'utf8', +)) as { version: string } + +const roots: string[] = [] +const fixtures: ResponsesFixture[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +interface RealHarness { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly parent: Agent + readonly env: Record + readonly workspace: string +} + +async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ + readonly harness: RealHarness + readonly fixture: ResponsesFixture +}> { + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-')) + roots.push(root) + const workspace = join(root, 'workspace') + const codexHome = join(root, 'codex-home') + const fixture = await startResponsesFixture(script) + fixtures.push(fixture) + mkdirSync(workspace) + mkdirSync(codexHome) + writeFileSync(join(codexHome, 'config.toml'), [ + 'model = "fixture-model"', + 'model_provider = "fixture"', + 'approval_policy = "on-request"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.fixture]', + 'name = "Fixture Responses"', + `base_url = "${fixture.baseUrl}"`, + 'env_key = "OPENAI_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + const env = { + OPENAI_API_KEY: 'dsh-fake-openai-key', + CODEX_HOME: codexHome, + HOME: root, + XDG_CONFIG_HOME: join(root, 'xdg'), + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + const parent = { + id: 'real-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + return { harness: { ctx, handles, parent, env, workspace }, fixture } +} + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + const outcome = await handle.done + expect(outcome).toHaveProperty('exitCode') + expect(outcome).toHaveProperty('signal') + } +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +describe('real @openai/codex 0.146.0 product', () => { + it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { + const sentinel = 'REAL_CODEX_SENTINEL_0_146_0' + const task = 'Return the fixture sentinel exactly.' + const { harness, fixture } = await realHarness([ + { kind: 'complete', text: sentinel }, + ]) + expect(codexPackage.version).toBe('0.146.0') + const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + env: { ...process.env, ...harness.env }, + }) + expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: task }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(fixture.requests).toHaveLength(1) + const recorded = fixture.requests[0]! + expect(recorded.method).toBe('POST') + expect(recorded.path).toBe('/v1/responses') + expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key') + expect(responseInputTexts(recorded.body)).toContain(task) + await expectQuiescent(harness.handles) + }, 20_000) + + it('declines a real app-server command approval without executing the command', async () => { + const sentinel = 'REAL_CODEX_APPROVAL_DECLINED' + const { harness, fixture } = await realHarness([ + { + kind: 'functionCall', + name: 'exec_command', + arguments: { + cmd: 'touch approval-side-effect', + sandbox_permissions: 'require_escalated', + justification: 'exercise the unattended approval boundary', + }, + }, + { kind: 'complete', text: sentinel }, + ]) + const sideEffect = join(harness.workspace, 'approval-side-effect') + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Attempt the fixture command.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(existsSync(sideEffect)).toBe(false) + expect(fixture.requests).toHaveLength(2) + const tools = fixture.requests[0]!.body.tools as Array> + expect(tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'function', name: 'exec_command' }), + ])) + const followup = JSON.stringify(fixture.requests[1]!.body) + expect(followup).toContain('call_fixture') + expect(followup).toContain('rejected by user') + expect(fixture.requests.every(requestEntry => + requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', + )).toBe(true) + await expectQuiescent(harness.handles) + }, 20_000) + + it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const controller = new AbortController() + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Wait for cancellation.' }], + parent: harness.parent, + signal: controller.signal, + }) + await fixture.requestStarted + controller.abort(new Error('real product cancellation')) + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) + await run.dispose() + await expectQuiescent(harness.handles) + }, 20_000) +}) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts new file mode 100644 index 0000000000..940b0d52a0 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -0,0 +1,283 @@ +import { createServer } from 'node:http' +import type { + IncomingHttpHeaders, + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' + +/** One request observed by the package-private Responses fixture. */ +interface RecordedResponsesRequest { + readonly method: string | undefined + readonly path: string | undefined + readonly headers: IncomingHttpHeaders + readonly body: Record +} + +/** Behavior consumed by one Responses request. */ +export type ResponsesBehavior = + | { readonly kind: 'complete'; readonly text: string } + | { + readonly kind: 'functionCall' + readonly name: string + readonly arguments: Record + } + | { readonly kind: 'hold' } + +/** Running package-private Responses fixture. */ +export interface ResponsesFixture { + readonly baseUrl: string + readonly requests: RecordedResponsesRequest[] + readonly requestStarted: Promise + close(): Promise +} + +function responseObject(text: string): Record { + const message = { + id: 'msg_fixture', + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ + type: 'output_text', + annotations: [], + logprobs: [], + text, + }], + } + return { + id: 'resp_fixture', + object: 'response', + created_at: 1, + status: 'completed', + background: false, + error: null, + incomplete_details: null, + instructions: null, + max_output_tokens: null, + max_tool_calls: null, + model: 'fixture-model', + output: [message], + parallel_tool_calls: true, + previous_response_id: null, + prompt_cache_key: null, + prompt_cache_retention: null, + reasoning: { effort: null, summary: null }, + safety_identifier: null, + service_tier: 'default', + store: false, + temperature: null, + text: { format: { type: 'text' }, verbosity: 'medium' }, + tool_choice: 'auto', + tools: [], + top_logprobs: 0, + top_p: null, + truncation: 'disabled', + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 1, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 11, + }, + user: null, + metadata: {}, + } +} + +function completeEvents(text: string): Record[] { + const completed = responseObject(text) + const message = (completed.output as Record[])[0]! + const part = (message.content as Record[])[0]! + return [ + { + type: 'response.created', + response: { ...completed, status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...message, status: 'in_progress', content: [] }, + }, + { + type: 'response.content_part.added', + item_id: message.id, + output_index: 0, + content_index: 0, + part: { ...part, text: '' }, + }, + { + type: 'response.output_text.delta', + item_id: message.id, + output_index: 0, + content_index: 0, + delta: text, + logprobs: [], + }, + { + type: 'response.output_text.done', + item_id: message.id, + output_index: 0, + content_index: 0, + text, + logprobs: [], + }, + { + type: 'response.content_part.done', + item_id: message.id, + output_index: 0, + content_index: 0, + part, + }, + { + type: 'response.output_item.done', + output_index: 0, + item: message, + }, + { type: 'response.completed', response: completed }, + ] +} + +function functionCallEvents( + name: string, + argumentsValue: Record, +): Record[] { + const argumentsText = JSON.stringify(argumentsValue) + const item = { + id: 'fc_fixture', + type: 'function_call', + status: 'completed', + name, + arguments: argumentsText, + call_id: 'call_fixture', + } + const completed = { + ...responseObject(''), + output: [item], + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 15, + }, + } + return [ + { + type: 'response.created', + response: { ...completed, status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...item, status: 'in_progress', arguments: '' }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: item.id, + output_index: 0, + delta: argumentsText, + }, + { + type: 'response.function_call_arguments.done', + item_id: item.id, + output_index: 0, + arguments: argumentsText, + }, + { + type: 'response.output_item.done', + output_index: 0, + item, + }, + { type: 'response.completed', response: completed }, + ] +} + +function readRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { resolve(body) }) + request.on('error', reject) + }) +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + server.closeAllConnections() + }) +} + +/** + * Start a loopback-only Responses SSE fixture. + * @param script - one behavior per expected Responses request. + * @returns the running fixture and its observed requests. + */ +export async function startResponsesFixture( + script: readonly ResponsesBehavior[], +): Promise { + const behaviors = [...script] + const requests: RecordedResponsesRequest[] = [] + const started = Promise.withResolvers() + const openResponses = new Set() + const server = createServer((request, response) => { + openResponses.add(response) + response.on('close', () => { openResponses.delete(response) }) + void readRequest(request).then((body) => { + requests.push({ + method: request.method, + path: request.url, + headers: request.headers, + body: JSON.parse(body) as Record, + }) + started.resolve(undefined) + const behavior = behaviors.shift() + if (behavior === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } })) + return + } + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'x-request-id': 'req_fixture', + }) + if (behavior.kind === 'hold') return + const events = behavior.kind === 'complete' + ? completeEvents(behavior.text) + : functionCallEvents(behavior.name, behavior.arguments) + for (const event of events) { + response.write(`data: ${JSON.stringify(event)}\n\n`) + } + response.end('data: [DONE]\n\n') + }).catch((error: unknown) => { + response.destroy(error instanceof Error ? error : new Error(String(error))) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('responses fixture did not acquire a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + requests, + requestStarted: started.promise, + async close(): Promise { + for (const response of openResponses) response.destroy() + await closeServer(server) + }, + } +} diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts new file mode 100644 index 0000000000..6e6f3dbeaf --- /dev/null +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -0,0 +1,1053 @@ +import { PassThrough } from 'node:stream' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { + SubprocessHandle, + SubprocessOutcome, +} from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import * as invariant from '../src/invariant.ts' +import { + DEFAULT_DISPOSE_GRACE_MS, + disposeCodexChild, + startCodexRun, + textTask, + type CodexRunSpec, +} from '../src/run.ts' +import { CodexAppServerWire } from '../src/wire.ts' + +type JsonObject = Record + +const fakeParent = { + id: 'parent', + session: { header: { cwd: process.cwd() } }, +} as unknown as Agent + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }], + signal = new AbortController().signal, +) { + return { prompt, parent: fakeParent, signal } +} + +async function nextTask(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} + +class ProtocolPeer { + private buffer = '' + private readonly frames: JsonObject[] = [] + private readonly wakeups = new Set<() => void>() + + constructor( + input: PassThrough, + private readonly output: PassThrough, + ) { + input.on('data', (chunk: Buffer | string) => { + this.buffer += chunk.toString() + for (;;) { + const newline = this.buffer.indexOf('\n') + if (newline < 0) break + const line = this.buffer.slice(0, newline) + this.buffer = this.buffer.slice(newline + 1) + if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject) + } + for (const wake of this.wakeups) wake() + this.wakeups.clear() + }) + } + + async next(predicate: (frame: JsonObject) => boolean): Promise { + for (;;) { + const index = this.frames.findIndex(predicate) + if (index >= 0) return this.frames.splice(index, 1)[0]! + await new Promise((resolve) => { this.wakeups.add(resolve) }) + } + } + + nextMethod(method: string): Promise { + return this.next(frame => frame.method === method) + } + + nextResponse(id: unknown): Promise { + return this.next(frame => frame.id === id && frame.method === undefined) + } + + send(...frames: readonly JsonObject[]): void { + this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`) + } + + respond(requestFrame: JsonObject, result: unknown): void { + this.send({ id: requestFrame.id, result }) + } +} + +interface FakeChildOptions { + readonly pid?: number + readonly stdin?: boolean + readonly stdout?: boolean + readonly exitOnTerminate?: boolean + readonly waitForExitResult?: boolean + readonly doneError?: Error +} + +interface FakeChild { + readonly handle: SubprocessHandle + readonly peer: ProtocolPeer + readonly fromChild: PassThrough + readonly toChild: PassThrough + readonly settle: (outcome?: SubprocessOutcome) => void + readonly fail: (error: Error) => void + readonly terminate: () => void + readonly waitForExit: (signal?: AbortSignal) => Promise +} + +function fakeChild(options: FakeChildOptions = {}): FakeChild { + const fromChild = new PassThrough() + const toChild = new PassThrough() + const peer = new ProtocolPeer(toChild, fromChild) + let exited = false + let resolveDone!: (outcome: SubprocessOutcome) => void + let rejectDone!: (error: Error) => void + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + const settle = ( + outcome: SubprocessOutcome = { exitCode: 0, signal: null }, + ): void => { + if (exited) return + exited = true + resolveDone(outcome) + } + const fail = (error: Error): void => { + if (exited) return + exited = true + rejectDone(error) + } + if (options.doneError !== undefined) fail(options.doneError) + const terminate = vi.fn(() => { + if (options.exitOnTerminate !== false) settle() + }) + const waitForExit = vi.fn(async (signal?: AbortSignal) => { + if (options.waitForExitResult !== undefined) { + return options.waitForExitResult + } + if (exited) return true + if (signal === undefined) { + await done.catch(() => {}) + return true + } + return await new Promise((resolve) => { + const onAbort = (): void => { resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + void done.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + ) + }) + }) + const handle: SubprocessHandle = { + pid: options.pid ?? 1234, + stdin: options.stdin === false ? undefined : toChild, + stdout: options.stdout === false ? undefined : fromChild, + stderr: undefined, + collected: {}, + done, + terminate, + waitForExit, + } + return { + handle, + peer, + fromChild, + toChild, + settle, + fail, + terminate, + waitForExit, + } +} + +function runSpec( + child: FakeChild, + overrides: Partial = {}, +): CodexRunSpec { + return { + cwd: process.cwd(), + env: {}, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: () => child.handle, + ...overrides, + } +} + +async function initializeWire(): Promise<{ + readonly child: FakeChild + readonly wire: CodexAppServerWire +}> { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await initializing + expect(await child.peer.nextMethod('initialized')).toEqual({ + jsonrpc: '2.0', + method: 'initialized', + }) + const starting = wire.startThread(process.cwd(), new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await expect(starting).resolves.toBe('thread-1') + return { child, wire } +} + +async function publishRun( + child = fakeChild(), + signal = new AbortController().signal, + specOverrides: Partial = {}, +) { + const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + const turnStart = await child.peer.nextMethod('turn/start') + return { child, run, turnStart } +} + +function agentMessage( + text: unknown, + phase: unknown, + turnId = 'turn-1', + threadId = 'thread-1', +): JsonObject { + return { + method: 'item/completed', + params: { + threadId, + turnId, + item: { type: 'agentMessage', text, phase }, + }, + } +} + +function turnCompleted( + status: unknown, + turnId = 'turn-1', + threadId = 'thread-1', + error: unknown = null, +): JsonObject { + return { + method: 'turn/completed', + params: { + threadId, + turn: { id: turnId, status, error }, + }, + } +} + +describe('task admission and package contracts', () => { + it('accepts one or more text blocks and rejects empty or non-text tasks', () => { + expect(textTask([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ])).toEqual(['one', 'two']) + expect(() => textTask([])).toThrow('only text blocks') + expect(() => textTask([{ type: 'reasoning', text: 'hidden' }])) + .toThrow('only text blocks') + expect(() => textTask([{ type: 'text', text: ' \n ' }])) + .toThrow('must not be empty') + }) + + it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const fiber = await ctx.plugin(codex, {}) + const provider = ctx.subagents.getProvider('codex')! + expect(provider).toMatchObject({ + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['codex']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + + for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(ctx.plugin(codex, { disposeGraceMs })) + .rejects.toThrow('disposeGraceMs must be a positive finite number') + } + await ctx.fiber.dispose() + }) + + it('keeps the namespace export shape and package-owned empty invariant', async () => { + expect('default' in codex).toBe(false) + expect(codex.name).toBe('subagent-codex') + expect(codex.inject).toEqual(['subagents', 'subprocess']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(codex)).toBe(codex) + + const dispose = vi.fn() + const register = vi.fn(( + _packageName: string, + _installer: InvariantInstaller, + ) => dispose) + const ctx = { invariants: { register } } as unknown as Context + await expect(invariant.apply(ctx)).resolves.toBe(dispose) + expect(register).toHaveBeenCalledWith( + '@deepseek-ai/dsh-subagent-codex', + expect.any(Function), + ) + const install = register.mock.calls[0]![1] + await install(new Context(), (message) => { throw new Error(message) }) + expect(invariant.name).toBe('subagent-codex-invariant') + expect(invariant.inject).toEqual(['invariants']) + }) +}) + +describe('CodexAppServerWire', () => { + it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + expect(wire.collectOutput()).toEqual([]) + wire.start() + wire.start() + + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + expect(initialize.params).toEqual({ + clientInfo: { + name: 'deepseek-harness', + title: 'DeepSeek Harness', + version: '0.0.1', + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, + }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await initializing + await child.peer.nextMethod('initialized') + + const starting = wire.startThread('/workspace', new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true }) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await starting + + const result = wire.runTurn( + ['first', 'second'], + new AbortController().signal, + () => false, + ) + const turnStart = await child.peer.nextMethod('turn/start') + expect(turnStart.params).toEqual({ + threadId: 'thread-1', + input: [ + { type: 'text', text: 'first', text_elements: [] }, + { type: 'text', text: 'second', text_elements: [] }, + ], + }) + child.peer.send( + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, + }, + agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'), + agentMessage('other turn', 'final_answer', 'turn-2'), + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'reasoning', text: 'not output' }, + }, + }, + agentMessage('commentary', 'commentary'), + agentMessage('unphased', null), + agentMessage('first final', 'final_answer'), + agentMessage('last final', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'last final' }], + stopReason: 'completed', + }) + expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }]) + wire.close() + wire.close() + }) + + it('uses the last nullable-phase answer when no explicit final exists', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('first', null), + agentMessage('fallback', null), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'fallback' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('rejects invalid handshake, thread, and turn response shapes', async () => { + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + const frame = await child.peer.nextMethod('initialize') + child.peer.respond(frame, null) + await expect(pending).rejects.toThrow('invalid initialize response') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.startThread('/workspace', new AbortController().signal) + const frame = await child.peer.nextMethod('thread/start') + child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } }) + await expect(pending).rejects.toThrow('did not create an ephemeral thread') + wire.close() + } + { + const { child, wire } = await initializeWire() + const pending = wire.runTurn(['task'], new AbortController().signal, () => false) + const frame = await child.peer.nextMethod('turn/start') + child.peer.respond(frame, { turn: { id: '' } }) + await expect(pending).rejects.toThrow('turn/start turn id') + wire.close() + } + }) + + it('rejects a turn before thread publication and a second one-shot turn', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(wire.runTurn(['task'], new AbortController().signal, () => false)) + .rejects.toThrow('before thread/start') + const initialized = await initializeWire() + const first = initialized.wire.runTurn( + ['task'], + new AbortController().signal, + () => false, + ) + await initialized.child.peer.nextMethod('turn/start') + await expect(initialized.wire.runTurn( + ['again'], + new AbortController().signal, + () => false, + )).rejects.toThrow('already started') + initialized.wire.close() + await expect(first).rejects.toThrow('transport closed') + }) + + it('fails closed for empty output, malformed messages, phases, and terminal status', async () => { + const scenarios: Array<{ + readonly frames: JsonObject[] + readonly message: string + }> = [ + { + frames: [turnCompleted('completed')], + message: 'without a final answer', + }, + { + frames: [ + agentMessage('fallback', null), + agentMessage(' \n ', 'final_answer'), + turnCompleted('completed'), + ], + message: 'without a final answer', + }, + { + frames: [agentMessage(42, 'final_answer')], + message: 'invalid agent message', + }, + { + frames: [agentMessage('answer', 'future_phase')], + message: 'unknown agent message phase', + }, + { + frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })], + message: 'status failed', + }, + { + frames: [turnCompleted('interrupted')], + message: 'status interrupted', + }, + { + frames: [turnCompleted('inProgress')], + message: 'invalid terminal turn status', + }, + ] + for (const scenario of scenarios) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(...scenario.frames) + await expect(result).rejects.toThrow(scenario.message) + wire.close() + } + }) + + it('gives local cancellation precedence over a remote completed turn', async () => { + const { child, wire } = await initializeWire() + let cancelled = false + const result = wire.runTurn( + ['task'], + new AbortController().signal, + () => cancelled, + ) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + cancelled = true + child.peer.send(agentMessage('late', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'late' }], + stopReason: 'aborted', + }) + wire.close() + }) + + it('answers all four unattended request classes without granting authority', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + + child.peer.send({ + id: 'command', + method: 'item/commandExecution/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + expect(await child.peer.nextResponse('command')).toMatchObject({ + result: { decision: 'decline' }, + }) + + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + const requests = [ + { + id: 'file', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + result: { decision: 'decline' }, + }, + { + id: 'permissions', + method: 'item/permissions/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + result: { permissions: {}, scope: 'turn' }, + }, + { + id: 'mcp', + method: 'mcpServer/elicitation/request', + params: { threadId: 'thread-1', turnId: null }, + result: { action: 'decline', content: null, _meta: null }, + }, + ] as const + for (const serverRequest of requests) { + child.peer.send(serverRequest) + expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({ + result: serverRequest.result, + }) + } + + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + wire.close() + }) + + it('fails the run on unknown requests or wrong request association', async () => { + for (const serverRequest of [ + { + id: 'unknown', + method: 'item/tool/requestUserInput', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }, + { + id: 'thread', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-2', turnId: 'turn-1' }, + }, + { + id: 'turn', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-2' }, + }, + ]) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send(serverRequest) + const response = await child.peer.nextResponse(serverRequest.id) + expect(response.error).toMatchObject({ code: -32603 }) + await expect(result).rejects.toThrow() + wire.close() + } + }) + + it('rejects conflicting early turn identities before accepting output', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-early' } }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + wire.close() + }) + + it('rejects conflicting early notifications and requests before turn/start', async () => { + { + const { child, wire } = await initializeWire() + child.peer.send({ + id: 'too-early', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + const response = await child.peer.nextResponse('too-early') + expect(response.error).toMatchObject({ code: -32603 }) + wire.close() + } + { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + await child.peer.nextMethod('turn/start') + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, + }, + agentMessage('wrong', 'final_answer', 'turn-2'), + ) + await expect(result).rejects.toThrow('conflicting turns') + wire.close() + } + }) + + it('interrupts only an active open turn and contains remote interrupt failure', async () => { + const { child, wire } = await initializeWire() + wire.interrupt() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + wire.interrupt() + const interrupt = await child.peer.nextMethod('turn/interrupt') + expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' }) + child.peer.send({ + id: interrupt.id, + error: { code: -32000, message: 'already done' }, + }) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + wire.close() + wire.interrupt() + }) + + it('ignores unrelated and out-of-window notifications', async () => { + const { child, wire } = await initializeWire() + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-2', turn: { id: 'turn-other' } }, + }, + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-before' } }, + }, + agentMessage('before', 'final_answer'), + { method: 'future/notification', params: {} }, + turnCompleted('completed'), + turnCompleted('completed', 'turn-other', 'thread-2'), + ) + await nextTask() + + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send( + agentMessage('wrong turn', 'final_answer', 'turn-2'), + turnCompleted('completed', 'turn-2'), + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('rejects pending work on abort, EOF, and stream error', async () => { + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const controller = new AbortController() + controller.abort('pre-aborted') + await expect(wire.initialize(controller.signal)) + .rejects.toThrow('app-server request aborted: pre-aborted') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const controller = new AbortController() + const pending = wire.initialize(controller.signal) + await child.peer.nextMethod('initialize') + controller.abort(new Error('cancel initialize')) + await expect(pending).rejects.toThrow('cancel initialize') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.fromChild.end() + await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/) + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.fromChild.emit('error', new Error('stdout broke')) + await expect(pending).rejects.toThrow('stdout broke') + wire.close() + } + }) +}) + +describe('run lifecycle and quiescence', () => { + it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => { + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + const starting = startCodexRun( + request([{ type: 'text', text: 'task' }]), + runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }), + ) + let published = false + void starting.then(() => { published = true }) + const initialize = await child.peer.nextMethod('initialize') + expect(published).toBe(false) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + expect(published).toBe(false) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + expect(spawn).toHaveBeenCalledWith({ + argv: ['codex', 'app-server', '--stdio'], + cwd: process.cwd(), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: DEFAULT_DISPOSE_GRACE_MS, + env: { OPENAI_API_KEY: 'fake' }, + }) + expect(run.localAgent).toBeUndefined() + + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send( + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + const disposal = run.dispose() + expect(run.dispose()).toBe(disposal) + await disposal + await nextTask() + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + }) + + it('settles local cancellation immediately and sends best-effort interrupt', async () => { + const controller = new AbortController() + const { child, run, turnStart } = await publishRun( + fakeChild(), + controller.signal, + ) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + controller.abort(new Error('stop')) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({ + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + await run.dispose() + }) + + it('flattens child exit and protocol failures after publication', async () => { + const errors: string[] = [] + { + const child = fakeChild({ exitOnTerminate: false }) + const { run } = await publishRun(child, undefined, { + onError: (error) => { errors.push(error.message) }, + }) + child.settle({ exitCode: 9, signal: null }) + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + expect(errors.at(-1)).toContain('code 9') + await run.dispose().catch(() => {}) + } + { + const child = fakeChild() + const { run, turnStart } = await publishRun(child, undefined, { + onError: () => { throw new Error('diagnostic sink') }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.fromChild.end() + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + await run.dispose() + } + }) + + it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { + const controller = new AbortController() + controller.abort() + const spawn = vi.fn() + await expect(startCodexRun( + request(undefined, controller.signal), + { + cwd: process.cwd(), + env: {}, + disposeGraceMs: 10, + spawn, + }, + )).rejects.toThrow('aborted before app-server startup') + expect(spawn).not.toHaveBeenCalled() + + const child = fakeChild() + const starting = startCodexRun(request(), runSpec(child)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: '' }) + await expect(starting).rejects.toThrow('initialize userAgent') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('rolls back an abort that wins immediately after thread creation', async () => { + const controller = new AbortController() + const child = fakeChild() + const starting = startCodexRun( + request(undefined, controller.signal), + runSpec(child), + ) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + controller.abort('startup race') + await expect(starting).rejects.toThrow('aborted before app-server startup') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('rolls back a subprocess done rejection during startup', async () => { + const child = fakeChild({ doneError: new Error('spawn observer failed') }) + const error: unknown = await startCodexRun(request(), runSpec(child)).then( + () => undefined, + (failure: unknown) => failure, + ) + expect(error).toBeInstanceOf(AggregateError) + if (!(error instanceof AggregateError)) { + throw new Error('expected startup and rollback failures') + } + expect(error.errors).toEqual([ + expect.objectContaining({ message: 'spawn observer failed' }), + expect.objectContaining({ message: 'spawn observer failed' }), + ]) + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('reports both startup and rollback failures', async () => { + const child = fakeChild({ waitForExitResult: false, exitOnTerminate: false }) + const starting = startCodexRun( + request(), + runSpec(child, { disposeGraceMs: 1 }), + ) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: '' }) + await expect(starting).rejects.toThrow( + 'startup failed and app-server cleanup also failed', + ) + }) + + it('rejects a missing protocol stream after reaping the unpublished child', async () => { + for (const options of [{ stdin: false }, { stdout: false }]) { + const child = fakeChild(options) + await expect(startCodexRun(request(), runSpec(child))) + .rejects.toThrow('dropped a piped protocol stream') + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + } + }) + + it('keeps overlapping runs isolated', async () => { + const first = fakeChild() + const second = fakeChild() + const runs = await Promise.all([ + publishRun(first), + publishRun(second), + ]) + for (const [index, entry] of runs.entries()) { + const id = `turn-${index + 1}` + entry.child.peer.send( + { id: entry.turnStart.id, result: { turn: { id } } }, + agentMessage(`answer-${index + 1}`, 'final_answer', id), + turnCompleted('completed', id), + ) + } + const results = await Promise.all(runs.map(entry => entry.run.result)) + expect(results.map(result => result.output)).toEqual([ + [{ type: 'text', text: 'answer-1' }], + [{ type: 'text', text: 'answer-2' }], + ]) + expect(runs[0].run.id).not.toBe(runs[1].run.id) + await Promise.all(runs.map(entry => entry.run.dispose())) + }) + + it('uses the registered provider config and logs flattened errors', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const child = fakeChild() + const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { + warnings.push(String(message)) + }) as typeof ctx.logger.warn + await ctx.plugin(codex, { + env: { OPENAI_API_KEY: 'fake' }, + disposeGraceMs: 25, + }) + const starting = ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'task' }], + parent: fakeParent, + signal: new AbortController().signal, + }) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + await child.peer.nextMethod('turn/start') + child.settle({ exitCode: 1, signal: null }) + await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + env: { OPENAI_API_KEY: 'fake' }, + graceMs: 25, + cwd: process.cwd(), + })) + expect(warnings).toEqual([ + expect.stringContaining('subagent-codex: child run failed (error):'), + ]) + await run.dispose().catch(() => {}) + await ctx.fiber.dispose() + }) +}) + +describe('disposeCodexChild', () => { + it('closes stdin, terminates, and waits for the managed tree', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const end = vi.spyOn(child.toChild, 'end') + await disposeCodexChild(wire, child.handle, 100) + expect(end).toHaveBeenCalled() + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + }) + + it('contains a concurrently closed stdin error', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + vi.spyOn(child.toChild, 'end').mockImplementation(() => { + throw new Error('already closed') + }) + await expect(disposeCodexChild(wire, child.handle, 100)) + .resolves.toBeUndefined() + }) + + it('handles a spawn-level failure with no process tree', async () => { + const child = fakeChild({ + pid: -1, + doneError: new Error('spawn failed'), + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 100)) + .resolves.toBeUndefined() + expect(child.terminate).not.toHaveBeenCalled() + expect(child.waitForExit).not.toHaveBeenCalled() + }) + + it('fails when the tree misses the release window or done rejects', async () => { + { + const child = fakeChild({ + exitOnTerminate: false, + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 1)) + .rejects.toThrow('did not exit within its dispose window') + } + { + const child = fakeChild({ + doneError: new Error('close observer failed'), + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 1)) + .rejects.toThrow('close observer failed') + } + { + const child = fakeChild() + const handle = { ...child.handle, stdin: undefined } + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, handle, 1)).resolves.toBeUndefined() + } + }) +}) diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json new file mode 100644 index 0000000000..6034bf5fbe --- /dev/null +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sdk/sdk-protocol" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index ceac4245a5..15873129cd 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent/README.md -README.md: e54f0b98ec3649cec428a47026e6657a9749608b -README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4 +README.md: 4040f9a48bd61cc230adec1bd9725cf30bdfd8f7 +README.zh.md: 5f6a041887e3227d92a88eac344524e55a598413 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e54f0b98ec..4040f9a48b 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -14,6 +14,7 @@ The family separates the stable interface from implementations and model-facing | `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child; supports continuable children. | | `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | +| `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | | `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. | diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 1624fa5985..5f6a041887 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -14,6 +14,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | +| `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | | `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ca290fd6..237b8296c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:* + version: link:../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -721,6 +724,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../packages/subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../packages/subprocess/subprocess-local @@ -4948,6 +4954,43 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/subagent-codex: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@openai/codex': + specifier: 0.146.0 + version: 0.146.0 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subagent/subagent-dsh-sdk: dependencies: schemastery: @@ -7796,6 +7839,47 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@openai/codex@0.146.0': + resolution: {integrity: sha512-yG3sPWNda/2YAIQIDq9MrrjoCTIQ7rxYM5IasrG3VBcuhCLTkgeg/JzqmJq1V98RE4MJ5jCxDXXQlOjrditFRw==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.146.0-darwin-arm64': + resolution: {integrity: sha512-nb61yX4r5L6Z0dlC4o3u0GAK1YCd4TUvjaB382bajDoh84V+uv2hTBIVZ++fgXWV9yoeuNrNnNcn7GoTGOe2Tg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.146.0-darwin-x64': + resolution: {integrity: sha512-hTQR5jy/ObfTf1MDnuJCZJAe+SljKE8DDwQWN6lDFgjsPhMQz852U2tILt8Ei+G5GkQSzemHYKl2AYPwW0Y5xw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.146.0-linux-arm64': + resolution: {integrity: sha512-qiYDxkkEFnXG7joadJW6Q+XcgyDXCpGdpa9nk/c+i0gEomur1j7bHvx12NfWWCF/y8Tqri6ay+FLuC2MjdehtA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.146.0-linux-x64': + resolution: {integrity: sha512-fswvyGprAPCMiOEue/7MKMk7pCjh9kZIJfJX5i9atmfnmGYbYCcUhZsEH9LEP0+0t5xyPqDbfNXY7NSxIVuXxA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.146.0-win32-arm64': + resolution: {integrity: sha512-EW6zdjDe+SLX2Iw+xymJ5+Pz2+DGexdstfFHXh4Ub+TfJsQPiMjGfZfNaoWgdJ2FsqSIzVKu2+G0KCMGYz2W8g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.146.0-win32-x64': + resolution: {integrity: sha512-b3lxMYeR0+IhstNo4JjX1P9cPc1xwVcCVkPd1lD1wpWPJ0SBhpIkPczwbu3ZRkJcdyl342+rgyf4DUrbZLdrGA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -13131,6 +13215,33 @@ snapshots: '@nodable/entities@2.2.0': {} + '@openai/codex@0.146.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.146.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.146.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.146.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.146.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.146.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.146.0-win32-x64' + + '@openai/codex@0.146.0-darwin-arm64': + optional: true + + '@openai/codex@0.146.0-darwin-x64': + optional: true + + '@openai/codex@0.146.0-linux-arm64': + optional: true + + '@openai/codex@0.146.0-linux-x64': + optional: true + + '@openai/codex@0.146.0-win32-arm64': + optional: true + + '@openai/codex@0.146.0-win32-x64': + optional: true + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.0 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 36677e120e..804dbbddf4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'], - note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex'], + note: 'The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', @@ -416,7 +416,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider and continuation service', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, diff --git a/tsconfig.host.json b/tsconfig.host.json index bfc4f898e8..06af7e5871 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -194,6 +194,7 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/subagent/subagent-codex" }, { "path": "./packages/subagent/subagent-dsh-sdk" }, { "path": "./packages/tasks/tasks" }, { "path": "./packages/tasks/tasks-local" }, diff --git a/vitest.config.ts b/vitest.config.ts index ddf7741716..eac84d8d20 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -54,6 +54,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subagent/subagent-codex/tests/real-product.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', From 270b8e0acc64581f604a86367dd57ce5d6104d62 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 16:25:38 +0800 Subject: [PATCH 02/27] fix(subagent): close Codex provider CI gaps --- .../fixtures/subagent/subagent-codex/cordis.yml | 2 +- .../codex}/evidence.expected.json | 0 .../codex}/session.expected.jsonl | 0 .../tests/subagent-product-providers.snapshot.ts | 16 ++++++++++------ packages/subagent/subagent-codex/src/run.ts | 12 +----------- 5 files changed, 12 insertions(+), 18 deletions(-) rename examples/acp-agent/tests/{snapshots/subagent-codex => product-provider-snapshots/codex}/evidence.expected.json (100%) rename examples/acp-agent/tests/{snapshots/subagent-codex => product-provider-snapshots/codex}/session.expected.jsonl (100%) diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index cc50ea2587..f219214dd0 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -14,7 +14,7 @@ config: env: OPENAI_API_KEY: !!js process.env.DSH_TEST_OPENAI_API_KEY - CODEX_HOME: !!js process.cwd() + '/codex-home' + CODEX_HOME: !!js process.env.DSH_TEST_CODEX_HOME HOME: !!js process.cwd() XDG_CONFIG_HOME: !!js process.cwd() + '/xdg' PATH: !!js process.env.PATH diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json b/examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json rename to examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl b/examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl rename to examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl diff --git a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts index 6df5b7f411..64d1d244ae 100644 --- a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts +++ b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts @@ -5,9 +5,10 @@ * Code scenario and reruns both from its final stacked candidate. */ +import { homedir } from 'node:os' import { dirname, delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' import { normalizeSessionLog, @@ -25,7 +26,7 @@ const testsDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const fixtureDir = join(testsDir, 'fixtures/subagent/subagent-codex') const configPath = join(fixtureDir, 'cordis.yml') -const snapshotDir = join(testsDir, 'snapshots/subagent-codex') +const snapshotDir = join(testsDir, 'product-provider-snapshots/codex') const sessionExpected = join(snapshotDir, 'session.expected.jsonl') const evidenceExpected = join(snapshotDir, 'evidence.expected.json') const cliBin = join(repoRoot, 'packages/examples/cli-demo/src/bin.ts') @@ -75,6 +76,7 @@ function responseInputTexts(body: Record): string[] { describe('real product subagent providers through the Loader', () => { it('pins the Codex tool, result, persisted Session, and process quiescence', async () => { + const codexHome = await mkdtemp(join(homedir(), '.dsh-subagent-codex-loader-')) const responses = await startResponsesFixture([ { kind: 'complete', text: CODEX_SENTINEL }, ]) @@ -96,12 +98,11 @@ describe('real product subagent providers through the Loader', () => { tsconfigPath: repoTsconfig, processTimeoutMs: 45_000, env: { + DSH_TEST_CODEX_HOME: codexHome, DSH_TEST_OPENAI_API_KEY: FAKE_KEY, PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, }, - async prepare(cwd): Promise { - const codexHome = join(cwd, 'codex-home') - await mkdir(codexHome) + async prepare(): Promise { await writeFile(join(codexHome, 'config.toml'), [ 'model = "fixture-model"', 'model_provider = "fixture"', @@ -161,7 +162,10 @@ describe('real product subagent providers through the Loader', () => { expect(normalizedSession).toBe(await readFile(sessionExpected, 'utf8')) expect(evidence).toBe(await readFile(evidenceExpected, 'utf8')) } finally { - await responses.close() + await Promise.all([ + responses.close(), + rm(codexHome, { recursive: true, force: true }), + ]) } }, LOADER_SMOKE_TEST_TIMEOUT_MS + 30_000) }) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index f58b0a6877..39bd5bcf27 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -65,16 +65,6 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { return texts } -async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { - const controller = new AbortController() - const timer = setTimeout(() => { controller.abort() }, ms) - try { - return await child.waitForExit(controller.signal) - } finally { - clearTimeout(timer) - } -} - /** * Close the private wire, terminate the managed process tree, and wait for the * subprocess owner to prove it is gone. @@ -98,7 +88,7 @@ export async function disposeCodexChild( // A concurrently closed stdin does not change tree ownership below. } child.terminate() - if (!(await treeExitsWithin(child, graceMs * 2))) { + if (!(await child.waitForExit(AbortSignal.timeout(graceMs * 2)))) { throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') } await child.done From 9d65894314ac57b505083ee9bf666846ead0e6da Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 16:45:04 +0800 Subject: [PATCH 03/27] docs(agent-notes): replace product provider proposal --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 3 +- .../2026-06-21-subagent-capability-seam.zh.md | 3 +- .../2026-06-22-acp-subagent-backend.i18n.yaml | 4 +- .../2026-06-22-acp-subagent-backend.md | 2 +- .../2026-06-22-acp-subagent-backend.zh.md | 2 +- ...claude-code-and-codex-subagent-backends.md | 74 --------------- ...ude-code-and-codex-subagent-backends.zh.md | 74 --------------- ...ode-and-codex-subagent-backends.i18n.yaml} | 6 +- ...claude-code-and-codex-subagent-backends.md | 90 +++++++++++++++++++ ...ude-code-and-codex-subagent-backends.zh.md | 90 +++++++++++++++++++ 11 files changed, 193 insertions(+), 159 deletions(-) delete mode 100644 .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md delete mode 100644 .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md rename .agents/notes/proposed/feature/{2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml => 2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml} (54%) create mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md create mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index feb73d00dd..61640aade4 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 043092884731c403a11b71ef8b5a410e9e5af7e0 -2026-06-21-subagent-capability-seam.zh.md: 6a4a5798199ca7d6d7c011668a319d65a0553208 +2026-06-21-subagent-capability-seam.md: fd22b883572e5304c1587c818026c36235ef504d +2026-06-21-subagent-capability-seam.zh.md: fcfdf3e9c1eb35d7c372aa4311ebf8da192b56a7 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 0430928847..fd22b88357 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -14,7 +14,8 @@ The distinctive requirement — the one that shapes the whole design — is that - **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); -- later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend. +- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); +- later: **A2A** and the **Claude Code Agent SDK** — the same out-of-process "start a child, prompt it, settle, cancel" shape; the Claude sibling remains in the product-provider proposal. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 6a4a579819..fcfdf3e9c1 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -14,7 +14,8 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智 - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); -- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。 +- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); +- 后续:**A2A** 与 **Claude Code Agent SDK**——两者采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」;Claude 兄弟提供方仍在产品提供方提案中。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 774b9cc52e..207a8e7f6d 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md -2026-06-22-acp-subagent-backend.md: 5f12aa1c08d4f4cfaa35f2f7f4b09ad341c3eae8 -2026-06-22-acp-subagent-backend.zh.md: 61359246b0c552f6126cd82ae843d489de809a38 +2026-06-22-acp-subagent-backend.md: ea0ec821b1f7a55d173f58c5bc4ba8829ef65c54 +2026-06-22-acp-subagent-backend.zh.md: 32e83a7a4e89eb7adb17220dc66952bca0a165aa diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index 5f12aa1c08..ea0ec821b1 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -59,4 +59,4 @@ Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The par ## Future providers -The same out-of-process spawn/prompt/stream/cancel shape generalizes to other transports named in the seam Agent Note — A2A, the Codex app-server, and the Claude Code Agent SDK — each a sibling provider registered by name. The ACP backend is the proof that the seam supports the boundary; those are mechanically similar. +The [Codex app-server provider](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols. diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 61359246b0..32e83a7a4e 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -59,4 +59,4 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ## 后续提供方 -同样的进程外启动/提示词/流式输出/取消形态可泛化到 seam Agent Note 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 +[Codex app-server 提供方](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md deleted file mode 100644 index 86a2e3489a..0000000000 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Note: Claude Code and Codex subagent providers - -Status: proposed - -English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md) - -## Problem - -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 one-shot providers register fixed deployment names and are exposed through two fixed `dsh-tool-subagent` instances: - -- `@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. - -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. - -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. - -## Codex provider - -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. - -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. - -`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`. - -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. - -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. - -## Claude Code provider - -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. - -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. - -## Evidence contract - -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. - -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 - -**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. - -**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. - -**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. - -**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. - -**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. - -**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 - -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 - -- 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. diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md deleted file mode 100644 index ef2098b3af..0000000000 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Note: Claude Code 与 Codex subagent 提供方 - -Status: proposed - -[English](2026-07-07-claude-code-and-codex-subagent-backends.md) | 中文 - -## 问题 - -命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子 agent 的运行方式即可委派工作,但 harness 需要接入真实 Codex 与 Claude Code 产品的第一方路径。一个实用的首版必须能把一个自包含任务交给任一产品,使用父会话的工作区,返回最终答案或明确失败,并且不留下任何受管产品进程。 - -产品集成不得让任务文本、工作目录、取消、结果结算或进程树出现第二个所有者。它还必须在强制无密钥测试中证明真实产品链路:假包装层或直接向模型发起的 HTTP 请求无法证明 Loader、提供方注册、官方产品协议、认证、最终答案和清理能够正确组合运行。 - -## 提案 - -两个同级的单次执行提供方注册固定部署名称,并通过两个固定的 `dsh-tool-subagent` 实例对外提供: - -- `@deepseek-ai/dsh-subagent-codex` 注册 `codex`,由 `codex app-server --stdio` 驱动,现已实现。 -- `@deepseek-ai/dsh-subagent-claude-code` 将注册 `claude-code`,由官方 Claude Agent SDK 及其捆绑的 CLI(命令行界面)驱动,目前仍待实现。 - -面向模型的工具为 `subagent_codex` 和 `subagent_claude_code`。每个工具在部署时绑定一个提供方,接受一个独立任务,并在初始组合中省略后台参数。产品选择不作为额外的模型参数。 - -两个提供方均报告 `inheritsParentContext: false`,不声明任何可选启动能力,并使用父会话的工作目录而不复制父会话对话。每次调用都会创建一个全新的产品进程和一次不可恢复的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台 dispose(资源释放);共享子进程服务负责环境清洗、进程树终止和整棵进程树的退出观测。 - -## Codex 提供方 - -Codex 提供方的固定名称为 `codex`,固定命令为 `codex app-server --stdio`。其公开配置只包含显式 `env` 条目和取正有限值的 `disposeGraceMs`;不公开命令、工作目录、模型、基础 URL、API 密钥、沙箱、审批、产品主目录或会话设置。生产环境从 `PATH` 解析 Codex,并使用宿主机原生的 Codex 配置和认证。`dsh-subprocess` 会清洗环境中形似凭证的变量,之后再合并显式 `env` 值。 - -在发布运行实例前,提供方会验证任务非空且仅含文本,启动受管 app-server,依次执行 `initialize` → `initialized`,并在父工作区中创建一个 `ephemeral: true` 线程。返回的运行实例只负责一次 `turn/start`;产品线程 ID 和轮次 ID 始终为私有信息,不会持久化到父会话中。 - -`turn/completed` 是判定远端终止状态的权威依据。最新一条内容非空且带有 `phase: "final_answer"` 的 `agentMessage` 优先;阶段字段可为空值的最新消息作为兼容回退。过程说明绝不取代答案。已完成但无答案的轮次、失败或中断的远端轮次、格式错误的载荷、协议关闭、进程提前退出或未知服务端请求,都会结算为共享的 `error`。本地取消会在竞态中胜出,结果仍为 `aborted`。 - -无人值守通信层会拒绝命令审批和文件审批,对于该轮次请求的权限一概不予授予,并拒绝 MCP elicitation。对于其他所有服务端请求,它都会以失败响应,而不会等待本提供方并未提供的 UI。 - -发布时,协议连接和进程句柄会移交给唯一持有者。幂等 dispose 会尽力中断已知轮次、关闭协议连接、结束 stdin、调用共享的逐级终止流程,并等待整棵进程树退出。若启动在发布前失败,`start()` 会先执行同样的清理,再以拒绝结束。 - -## Claude Code 提供方 - -Claude Code 同级提供方沿用相同边界:名称固定、任务自包含、仅执行一次、使用父级工作目录、结果由共享服务结算,且进程树受管。其产品专用实现将使用官方 Agent SDK 的 `query()` 与 spawn 钩子,将 SDK 协议所有权同 `dsh-subprocess` 的进程树所有权分开,不设置人机交互回调,并且仅在消息迭代器正常结束后提取严格的最终 SDK 结果。 - -Claude 包将公开相同的两个配置项:`env` 和 `disposeGraceMs`。产品安装、原生设置和登录仍由部署方负责,插件不管理这些内容。在该同级提供方及两种产品的组合证据实现之前,本文仍处于 proposed 状态。 - -## 证据契约 - -每个产品都有包(package)级分支完备测试、一项必需的真实产品规格测试,以及一份真实 Loader 快照。真实产品层必须使用受测的确切官方发行包、非空的假产品密钥、隔离的临时工作区与产品配置,以及固定答案的环回模型;如果缺少二进制文件、认证请求、任务、答案、取消或进程退出证明中的任一项,该层必须失败而非跳过。 - -Codex 证据固定使用 `@openai/codex@0.146.0` / `codex-cli 0.146.0`。其真实产品规格测试会观测确切的 Bearer 密钥、原始任务、字节完全一致的最终答案、无人值守下命令被拒绝且不产生文件副作用、本地取消,以及每个受管句柄对应的整棵进程树均达到完全停稳。其 Loader 快照固定记录不含后台参数的工具 schema、确切的工具调用与工具结果、完整持久化的父会话、产品请求,以及清理前的完全停稳状态。该 npm 包是用于提供可复现证据的开发依赖;生产环境仍使用 `PATH` 中的 `codex`。 - -## 曾考虑的替代方案 - -**直接向模型发起 HTTP 请求或 `codex exec`。** 这些路径会绕过产品官方的可扩展进程协议,无法证明产品配置、工具、审批、生命周期或清理。提供方改用 app-server 和官方 Agent SDK。 - -**共享产品进程辅助包。** 现有 subagent seam 和子进程 seam 已经负责所有共享任务、结果、环境和进程树关注点。在两个生产消费方证明通用契约确有缺口之前,新辅助包会造成所有权重复,因此产品专用适配器直接调用现有 seam。 - -**面向模型的产品选择器。** 产品可用性与认证属于部署事实。两个固定工具让各自的 schema 和提供方绑定保持显式,并避免向通用服务加入动态选择状态。 - -**将产品替身作为必需证据。** 替身适合完整覆盖私有协议分支,但无法证明包导出、官方二进制文件、认证或真实进程行为。必需证据使用环回模型 fixture(测试前置数据)驱动官方产品。 - -**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外另立一个管理权威,并把单次执行提供方变成账户管理功能。提供方只公开显式环境叠加和清理宽限期;无人值守交互一律以失败响应。 - -**续接、进度与共享父级上下文。** 首版面向用户的功能只需接收一个自包含任务,并返回一个最终答案。产品会话、恢复、后续请求、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集各自需要独立的用户契约,本提案不会预先构建这些内容。 - -## 验收标准 - -当两个固定工具都能通过 Loader 接入各自的真实产品,返回精确的最终答案或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制无密钥 CI 中证明受管进程树完全停稳时,本提案即告完成。两个包都具备覆盖配置、生命周期、失败、模型体验与限制的完整文档;生成的包记录、配置记录、能力记录、依赖记录和第三方记录均与已发布的 manifest(元数据清单)一致。 - -已实现的 Codex 部分已经针对其固定工具和 0.146.0 产品基线满足此契约。本文仍处于 proposed 状态,因为 Claude Code 同级提供方和两种产品的最终组合证据尚未实现。 - -## 风险 - -- Codex app-server 协议随产品版本演进,可能发生变化;生产环境不执行运行时版本探测,因此每次变更受支持的基线时,都必须重新开展 schema 调查并更新真实产品兼容性证据。 -- 产品原生配置使行为取决于部署环境中安装的产品及其账户状态。强制测试会隔离这些输入,而生产环境则刻意让这些输入继续由产品自身掌控。 -- 每次委派都要承担启动全新进程和使用独立模型上下文的成本,而且只有最终文本会传回父 agent。 -- 运行失败或被取消时,产品工具或文件副作用不会回滚。 -- 无人值守模式下拒绝审批可防止初始提供方因交互而挂起,但无法满足需要新权限的任务。 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml similarity index 54% rename from .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml rename to .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 27fb29dffd..49028bf8b2 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -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 .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 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +2026-08-04-claude-code-and-codex-subagent-backends.md: f8f9e10231874b6f97eba98594c952a86efd6901 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 60437a1bc6d8d3c3c9677b4a466ce6a7122fa4fc diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md new file mode 100644 index 0000000000..f8f9e10231 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -0,0 +1,90 @@ +# Agent Note: Claude Code and Codex subagent backends + +Status: proposed + +English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) + +## Problem + +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, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. + +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. They must also prove the real assembled path in required keyless tests. A direct model HTTP request, product double, or hand-mounted plugin cannot show that the Loader, fixed tool, provider registration, official product protocol, native authentication shape, final answer, and teardown work together. + +## Proposal + +The harness provides two sibling one-shot providers behind two fixed model-facing tools. `subagent_codex` selects the `codex` provider, and `subagent_claude_code` selects the `claude-code` provider. Each tool accepts only a standalone text task and binds its provider at deployment time; product selection and background execution are not model arguments. + +The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains part of this proposal and will use Claude Agent SDK 0.3.220 with its bundled Claude Code 2.1.220 CLI. This Note remains proposed until both siblings and their combined evidence are present. + +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. + +```text +fixed tool → shared subagent service → product provider → official product process + ← final answer / explicit error / cancellation ← terminal product fact + → foreground disposal → shared process-tree termination → whole-tree exit +``` + +### Ownership and lifecycle + +| Phase | Shared owner | Product-specific responsibility | Observable result | +| --- | --- | --- | --- | +| Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published | +| Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects | +| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive one shared `completed`, `error`, or `aborted` result | The parent receives only a final answer or an explicit failure | +| Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits | + +## Codex provider + +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. + +Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. + +`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A completed turn without an answer, a failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`. Local cancellation wins its race and remains `aborted`. + +The unattended wire declines command and file approvals, grants no requested permissions for the turn, and declines MCP elicitation. Any other server request fails the run instead of waiting for a user interface the provider does not supply. + +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. + +## Claude Code provider + +The Claude Code sibling follows the same fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries. It will call the official Agent SDK's `query()` and use its `spawnClaudeCodeProcess` hook to pass the SDK-provided command, arguments, cwd, environment, and forwarded signal into `dsh-subprocess` without rewriting them. + +The SDK will continue to own the Claude protocol and graceful `Query.close()` intent, while `dsh-subprocess` owns the actual CLI process tree and exit proof. Publication waits until both the SDK query and real CLI handle are controllable. A strict successful `SDKResultMessage` becomes `completed` only after asynchronous iteration ends normally; local cancellation becomes `aborted`, and every other result, iterator failure, protocol failure, or process failure becomes `error`. + +The package will expose the same two configuration concerns, `env` and `disposeGraceMs`. It will keep native settings and login under Claude Code's authority, disable unattended `AskUserQuestion`, omit interactive callbacks, and create no plugin-owned product session or account state. + +## Evidence contract + +Each product owns branch-complete package tests, a required real-product spec, and a real Loader snapshot. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. + +The Codex evidence pins `@openai/codex@0.146.0` and `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 whole-tree exit. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, complete persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still supplies `codex` on `PATH`. + +The combined contract is complete only when the Claude sibling has equivalent real SDK and bundled-CLI evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract. + +## Alternatives considered + +**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible process protocols and cannot prove native configuration, tools, approvals, result semantics, or teardown. The providers use app-server and the official Agent SDK instead. + +**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 the two products demonstrate a missing common contract, so each private adapter calls the existing seams directly. + +**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. + +**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 each official product against a loopback model fixture. + +**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 an explicit environment overlay and teardown grace; unattended interaction fails closed. + +**Continuation, progress, background collection, 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 + +Both fixed tools reach their corresponding real products through the Loader, return exact final answers or explicit failure or cancellation, persist the complete model-visible parent transcript, and prove managed process-tree quiescence in required keyless CI. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. + +The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. + +## Risks + +- The product protocols are versioned and may change. Production performs no runtime version probe, so every supported baseline change requires refreshed 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 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 interaction denial prevents hidden approval hangs but cannot satisfy tasks that require new permission or human input. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md new file mode 100644 index 0000000000..60437a1bc6 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -0,0 +1,90 @@ +# Agent Note: Claude Code 与 Codex subagent 后端 + +Status: proposed + +[English](2026-08-04-claude-code-and-codex-subagent-backends.md) | 中文 + +## 问题 + +命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 + +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。它们还必须在强制性的无密钥测试中证明真实组装路径。直接发起模型 HTTP 请求、使用产品替身或手工挂载插件,都无法证明 Loader、固定工具、提供方注册、官方产品协议、原生身份验证形态、最终回答和资源清理能够协同工作。 + +## 提案 + +harness 在两个固定的面向模型工具背后提供两个一次性兄弟提供方。`subagent_codex` 选择 `codex` 提供方,`subagent_claude_code` 选择 `claude-code` 提供方。每个工具只接受独立文本任务,并在部署时绑定其提供方;产品选择与后台执行都不作为模型参数。 + +Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍属于本提案的一部分,将使用 Claude Agent SDK 0.3.220 及其捆绑的 Claude Code 2.1.220 CLI(命令行界面)。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 + +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 + +```text +fixed tool → shared subagent service → product provider → official product process + ← final answer / explicit error / cancellation ← terminal product fact + → foreground disposal → shared process-tree termination → whole-tree exit +``` + +### 归属与生命周期 + +| 阶段 | 共享责任方 | 产品特定职责 | 可观察结果 | +| --- | --- | --- | --- | +| 解析 | `dsh-tool-subagent` 与 `ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 | +| 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 | +| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导一个共享的 `completed`、`error` 或 `aborted` 结果 | 父级只会收到最终回答或明确失败 | +| dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 | + +## Codex 提供方 + +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 + +发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 + +`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。轮次完成却没有答案、远端轮次失败或中断、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`。本地取消在竞态中胜出并保持为 `aborted`。 + +无人值守的协议连接会拒绝命令与文件审批,不授予该轮次请求的任何权限,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方没有提供的用户界面。 + +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 + +## Claude Code 提供方 + +Claude Code 兄弟提供方遵循同样的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界。它将调用官方 Agent SDK 的 `query()`,并使用其 `spawnClaudeCodeProcess` 钩子,将 SDK 提供的命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`。 + +SDK 将继续负责 Claude 协议,并通过 `Query.close()` 表达优雅关闭意图;`dsh-subprocess` 则负责实际的 CLI 进程树与退出证明。只有在 SDK query 和真实 CLI 句柄均可控后才会发布运行。只有严格表示成功的 `SDKResultMessage` 才会在异步迭代正常结束后成为 `completed`;本地取消成为 `aborted`,其他任何结果、迭代器失败、协议失败或进程失败都成为 `error`。 + +该包会公开相同的两个配置项:`env` 与 `disposeGraceMs`。它会继续让 Claude Code 负责原生设置与登录,禁用无人值守的 `AskUserQuestion`,不提供交互式回调,也不会创建由插件负责的产品会话或账户状态。 + +## 证据契约 + +每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一个真实 Loader 快照。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。 + +Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader 快照锁定不支持后台执行的工具 schema、确切的工具调用与结果、完整的已持久化父会话、产品请求,以及清理前的完全停稳状态。该 NPM 包是用于复现证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 + +只有在 Claude 兄弟提供方具备同等的真实 SDK 与捆绑 CLI 证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。 + +## 曾考虑的替代方案 + +**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展进程协议,无法证明原生配置、工具、审批、结果语义或资源清理。提供方改为使用 app-server 与官方 Agent SDK。 + +**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。在两个产品尚未证明通用契约存在缺口时,新辅助包只会造成责任重复,因此各自的私有适配器会直接调用现有 seam。 + +**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 + +**以产品替身作为强制证据。** 替身有助于穷尽覆盖私有协议分支,但无法证明包导出、官方二进制程序、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 + +**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 + +**续接、进度、后台收集和共享父级上下文。** 首个用户结果只需要一项自包含任务和一个最终回答。产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集都需要独立的用户契约,本提案不会预先构建这些功能。 + +## 验收标准 + +两个固定工具都通过 Loader 到达相应的真实产品,返回完全一致的最终回答或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制性的无密钥 CI 中证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 + +已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 + +## 风险 + +- 产品协议受版本约束,且可能发生变化。生产环境不会执行运行时版本探测,因此每次更改受支持的基线都必须刷新兼容性证据。 +- 产品原生配置使行为取决于部署环境中安装的产品与账户状态。强制测试会隔离这些输入,而生产环境会有意让产品继续负责它们。 +- 每次委派都要承担新建进程和独立模型上下文的开销,且只有最终文本会到达父级。 +- 运行失败或被取消时,产品工具或文件产生的副作用不会回滚。 +- 拒绝无人值守交互可以防止审批流程暗中挂起,但无法完成需要新权限或人工输入的任务。 From 36b562e4e95da11ce6996595826d37c870c368fc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 17:13:38 +0800 Subject: [PATCH 04/27] fix(subagent): close Codex provider review findings --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 12 ++--- ...ude-code-and-codex-subagent-backends.zh.md | 12 ++--- packages/subagent/subagent-codex/src/run.ts | 27 +++------- packages/subagent/subagent-codex/src/wire.ts | 37 +++++++------- .../tests/subagent-codex.spec.ts | 49 +++++-------------- 6 files changed, 48 insertions(+), 93 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 49028bf8b2..8431b6bbce 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: f8f9e10231874b6f97eba98594c952a86efd6901 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 60437a1bc6d8d3c3c9677b4a466ce6a7122fa4fc +2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index f8f9e10231..37f45f844c 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -14,7 +14,7 @@ The product integrations must not become second owners for task text, cwd, cance The harness provides two sibling one-shot providers behind two fixed model-facing tools. `subagent_codex` selects the `codex` provider, and `subagent_claude_code` selects the `claude-code` provider. Each tool accepts only a standalone text task and binds its provider at deployment time; product selection and background execution are not model arguments. -The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains part of this proposal and will use Claude Agent SDK 0.3.220 with its bundled Claude Code 2.1.220 CLI. This Note remains proposed until both siblings and their combined evidence are present. +The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -47,11 +47,7 @@ An unpublished startup failure closes the wire, terminates the acquired process ## Claude Code provider -The Claude Code sibling follows the same fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries. It will call the official Agent SDK's `query()` and use its `spawnClaudeCodeProcess` hook to pass the SDK-provided command, arguments, cwd, environment, and forwarded signal into `dsh-subprocess` without rewriting them. - -The SDK will continue to own the Claude protocol and graceful `Query.close()` intent, while `dsh-subprocess` owns the actual CLI process tree and exit proof. Publication waits until both the SDK query and real CLI handle are controllable. A strict successful `SDKResultMessage` becomes `completed` only after asynchronous iteration ends normally; local cancellation becomes `aborted`, and every other result, iterator failure, protocol failure, or process failure becomes `error`. - -The package will expose the same two configuration concerns, `env` and `disposeGraceMs`. It will keep native settings and login under Claude Code's authority, disable unattended `AskUserQuestion`, omit interactive callbacks, and create no plugin-owned product session or account state. +The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented. ## Evidence contract @@ -59,11 +55,11 @@ Each product owns branch-complete package tests, a required real-product spec, a The Codex evidence pins `@openai/codex@0.146.0` and `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 whole-tree exit. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, complete persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still supplies `codex` on `PATH`. -The combined contract is complete only when the Claude sibling has equivalent real SDK and bundled-CLI evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract. +The combined contract is complete only when the Claude sibling has equivalent real-product evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract. ## Alternatives considered -**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible process protocols and cannot prove native configuration, tools, approvals, result semantics, or teardown. The providers use app-server and the official Agent SDK instead. +**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. **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 the two products demonstrate a missing common contract, so each private adapter calls the existing seams directly. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 60437a1bc6..dcd7b90231 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -14,7 +14,7 @@ Status: proposed harness 在两个固定的面向模型工具背后提供两个一次性兄弟提供方。`subagent_codex` 选择 `codex` 提供方,`subagent_claude_code` 选择 `claude-code` 提供方。每个工具只接受独立文本任务,并在部署时绑定其提供方;产品选择与后台执行都不作为模型参数。 -Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍属于本提案的一部分,将使用 Claude Agent SDK 0.3.220 及其捆绑的 Claude Code 2.1.220 CLI(命令行界面)。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 +Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -47,11 +47,7 @@ fixed tool → shared subagent service → product provider → official product ## Claude Code 提供方 -Claude Code 兄弟提供方遵循同样的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界。它将调用官方 Agent SDK 的 `query()`,并使用其 `spawnClaudeCodeProcess` 钩子,将 SDK 提供的命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`。 - -SDK 将继续负责 Claude 协议,并通过 `Query.close()` 表达优雅关闭意图;`dsh-subprocess` 则负责实际的 CLI 进程树与退出证明。只有在 SDK query 和真实 CLI 句柄均可控后才会发布运行。只有严格表示成功的 `SDKResultMessage` 才会在异步迭代正常结束后成为 `completed`;本地取消成为 `aborted`,其他任何结果、迭代器失败、协议失败或进程失败都成为 `error`。 - -该包会公开相同的两个配置项:`env` 与 `disposeGraceMs`。它会继续让 Claude Code 负责原生设置与登录,禁用无人值守的 `AskUserQuestion`,不提供交互式回调,也不会创建由插件负责的产品会话或账户状态。 +Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界,本 Agent Note 才能进入 implemented 状态。 ## 证据契约 @@ -59,11 +55,11 @@ SDK 将继续负责 Claude 协议,并通过 `Query.close()` 表达优雅关闭 Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader 快照锁定不支持后台执行的工具 schema、确切的工具调用与结果、完整的已持久化父会话、产品请求,以及清理前的完全停稳状态。该 NPM 包是用于复现证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 -只有在 Claude 兄弟提供方具备同等的真实 SDK 与捆绑 CLI 证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。 +只有在 Claude 兄弟提供方具备同等的真实产品证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。 ## 曾考虑的替代方案 -**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展进程协议,无法证明原生配置、工具、审批、结果语义或资源清理。提供方改为使用 app-server 与官方 Agent SDK。 +**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展接入面,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都使用对应产品的官方接入方式。 **共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。在两个产品尚未证明通用契约存在缺口时,新辅助包只会造成责任重复,因此各自的私有适配器会直接调用现有 seam。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 39bd5bcf27..21f22d8a1b 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -116,13 +116,11 @@ export async function startCodexRun( graceMs: spec.disposeGraceMs, env: spec.env, }) - if (child.stdin === undefined || child.stdout === undefined) { - child.terminate() - await child.waitForExit() - throw new Error('subagent-codex: subprocess implementation dropped a piped protocol stream') - } - const wire = new CodexAppServerWire(child.stdout, child.stdin) + const wire = new CodexAppServerWire( + child.stdout as NonNullable, + child.stdin as NonNullable, + ) const disposeProcess = (): Promise => disposeCodexChild(wire, child, spec.disposeGraceMs) @@ -137,15 +135,10 @@ export async function startCodexRun( // late rejection observed after the result race has already settled. processFailure.catch(() => {}) - const flags = { cancelled: false } const runAbort = new AbortController() - let settleCancellation!: () => void - const cancellation = new Promise((resolve) => { settleCancellation = resolve }) const requestCancel = (): void => { - if (flags.cancelled) return - flags.cancelled = true + if (runAbort.signal.aborted) return runAbort.abort(new Error('subagent-codex: run cancelled locally')) - settleCancellation() wire.interrupt() } const onAbort = (): void => { requestCancel() } @@ -165,7 +158,7 @@ export async function startCodexRun( 'subagent-codex: startup failed and app-server cleanup also failed', ) } - if (flags.cancelled) { + if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before app-server startup') } throw thrown(error) @@ -174,15 +167,11 @@ export async function startCodexRun( const collectOutput = (): ContentBlock[] => wire.collectOutput() const result: Promise = settleRunResult({ attempt: () => Promise.race([ - wire.runTurn(texts, runAbort.signal, () => flags.cancelled), + wire.runTurn(texts, runAbort.signal, () => runAbort.signal.aborted), processFailure, - cancellation.then((): SubagentResult => ({ - output: collectOutput(), - stopReason: 'aborted', - })), ]), collectOutput, - cancelled: () => flags.cancelled, + cancelled: () => runAbort.signal.aborted, onError: spec.onError, signal: request.signal, onAbort, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index e8f743d1fa..9d15f7c6d1 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -83,9 +83,8 @@ export class CodexAppServerWire { readonly method: string readonly params: JsonObject }> = [] - private readonly finalAnswers: string[] = [] - private readonly unphasedAnswers: string[] = [] - private started = false + private lastFinalAnswer: string | undefined + private lastUnphasedAnswer: string | undefined private closed = false constructor( @@ -101,14 +100,16 @@ export class CodexAppServerWire { this.fail(thrown(error)) } }) + this.input.on('error', this.onInputError) + this.input.on('end', this.onInputEnd) + // Pipe errors can race protocol closure and process teardown. Retain both + // error listeners for the lifetime of their per-run streams so no late + // EPIPE or read failure becomes an unhandled EventEmitter error. + output.on('error', this.onOutputError) } /** Start reading app-server frames. */ start(): void { - if (this.started) return - this.started = true - this.input.on('error', this.onInputError) - this.input.on('end', this.onInputEnd) this.transport.start() } @@ -166,16 +167,11 @@ export class CodexAppServerWire { signal: AbortSignal, cancelled: () => boolean, ): Promise { - if (this.threadId === undefined) { - throw new Error('subagent-codex: cannot start a turn before thread/start') - } - if (this.turnCompleted !== undefined) { - throw new Error('subagent-codex: this one-shot wire already started its turn') - } const completion = deferred() this.turnCompleted = completion + const threadId = this.threadId as string const response = object(await this.guarded(this.transport.request('turn/start', { - threadId: this.threadId, + threadId, input: texts.map(text => ({ type: 'text', text, text_elements: [] })), }, signal), signal), 'turn/start response') const turn = object(response.turn, 'turn/start turn') @@ -216,9 +212,7 @@ export class CodexAppServerWire { * @returns the selected final or nullable-phase text block, if any. */ collectOutput(): ContentBlock[] { - const selected = this.finalAnswers.length > 0 - ? this.finalAnswers.at(-1) - : this.unphasedAnswers.at(-1) + const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer return selected !== undefined && selected.trim().length > 0 ? [{ type: 'text', text: selected }] : [] @@ -228,7 +222,6 @@ export class CodexAppServerWire { close(): void { if (this.closed) return this.closed = true - this.input.off('error', this.onInputError) this.input.off('end', this.onInputEnd) this.transport.close() } @@ -249,6 +242,10 @@ export class CodexAppServerWire { this.fail(error) } + private readonly onOutputError = (error: Error): void => { + this.fail(error) + } + private readonly onInputEnd = (): void => { this.fail(new Error('subagent-codex: app-server protocol stream closed')) } @@ -338,9 +335,9 @@ export class CodexAppServerWire { ? item.text : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })() if (item.phase === 'final_answer') { - this.finalAnswers.push(text) + this.lastFinalAnswer = text } else if (item.phase === null) { - this.unphasedAnswers.push(text) + this.lastUnphasedAnswer = text } else if (item.phase !== 'commentary') { throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 6e6f3dbeaf..66d5ef5d5c 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -90,8 +90,6 @@ class ProtocolPeer { interface FakeChildOptions { readonly pid?: number - readonly stdin?: boolean - readonly stdout?: boolean readonly exitOnTerminate?: boolean readonly waitForExitResult?: boolean readonly doneError?: Error @@ -161,8 +159,8 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { }) const handle: SubprocessHandle = { pid: options.pid ?? 1234, - stdin: options.stdin === false ? undefined : toChild, - stdout: options.stdout === false ? undefined : fromChild, + stdin: toChild, + stdout: fromChild, stderr: undefined, collected: {}, done, @@ -334,7 +332,6 @@ describe('CodexAppServerWire', () => { const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) expect(wire.collectOutput()).toEqual([]) wire.start() - wire.start() const initializing = wire.initialize(new AbortController().signal) const initialize = await child.peer.nextMethod('initialize') @@ -451,27 +448,6 @@ describe('CodexAppServerWire', () => { } }) - it('rejects a turn before thread publication and a second one-shot turn', async () => { - const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(wire.runTurn(['task'], new AbortController().signal, () => false)) - .rejects.toThrow('before thread/start') - const initialized = await initializeWire() - const first = initialized.wire.runTurn( - ['task'], - new AbortController().signal, - () => false, - ) - await initialized.child.peer.nextMethod('turn/start') - await expect(initialized.wire.runTurn( - ['again'], - new AbortController().signal, - () => false, - )).rejects.toThrow('already started') - initialized.wire.close() - await expect(first).rejects.toThrow('transport closed') - }) - it('fails closed for empty output, malformed messages, phases, and terminal status', async () => { const scenarios: Array<{ readonly frames: JsonObject[] @@ -757,6 +733,17 @@ describe('CodexAppServerWire', () => { await expect(pending).rejects.toThrow('stdout broke') wire.close() } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.toChild.emit('error', new Error('stdin broke')) + await expect(pending).rejects.toThrow('stdin broke') + wire.close() + child.toChild.emit('error', new Error('late stdin close')) + } }) }) @@ -918,16 +905,6 @@ describe('run lifecycle and quiescence', () => { ) }) - it('rejects a missing protocol stream after reaping the unpublished child', async () => { - for (const options of [{ stdin: false }, { stdout: false }]) { - const child = fakeChild(options) - await expect(startCodexRun(request(), runSpec(child))) - .rejects.toThrow('dropped a piped protocol stream') - expect(child.terminate).toHaveBeenCalledTimes(1) - expect(child.waitForExit).toHaveBeenCalledTimes(1) - } - }) - it('keeps overlapping runs isolated', async () => { const first = fakeChild() const second = fakeChild() From d7ee0e798a8b9d90d7b7addb1233885739727146 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 17:34:19 +0800 Subject: [PATCH 05/27] feat(subagent): mount Codex provider in shipped CLI --- ...-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...6-08-04-claude-code-and-codex-subagent-backends.md | 4 +++- ...8-04-claude-code-and-codex-subagent-backends.zh.md | 4 +++- apps/cli/composition.md | 6 ++++++ apps/cli/config/base.cordis.yml | 11 +++++++++++ apps/cli/package.json | 1 + apps/cli/tests/built-bin.e2e.ts | 6 ++++++ pnpm-lock.yaml | 3 +++ 8 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 8431b6bbce..91abd43031 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28 +2026-08-04-claude-code-and-codex-subagent-backends.md: 52181edba816f651877eac2c616cf67191f7e550 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: bde24eb8939b10c10916dab93effd3b94cbc7e1b diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 37f45f844c..52181edba8 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -16,7 +16,7 @@ The harness provides two sibling one-shot providers behind two fixed model-facin The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their fixed tools use `maxDepth: 'provider-managed'` because each out-of-process product owns any delegation budget inside its own harness; the parent sends no recursion cap that the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text fixed tool → shared subagent service → product provider → official product process @@ -37,6 +37,8 @@ fixed tool → shared subagent service → product provider → official product `@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. +The shipped `apps/cli/config/base.cordis.yml` loads this provider and a fixed `subagent_codex` tool by default, while `apps/cli/package.json` carries the provider package in the CLI dependency closure. Loading the base does not probe the Codex binary or authentication; missing native availability fails only when the tool is called. + Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. `turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A completed turn without an answer, a failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`. Local cancellation wins its race and remains `aborted`. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index dcd7b90231..bde24eb893 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -16,7 +16,7 @@ harness 在两个固定的面向模型工具背后提供两个一次性兄弟提 Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。固定工具使用 `maxDepth: 'provider-managed'`,因为每个进程外产品都负责其自身 harness 内部的委派预算;父级不会传入提供方无法执行的递归上限。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text fixed tool → shared subagent service → product provider → official product process @@ -37,6 +37,8 @@ fixed tool → shared subagent service → product provider → official product `@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 +正式发布的 `apps/cli/config/base.cordis.yml` 默认加载这个提供方和固定的 `subagent_codex` 工具,而 `apps/cli/package.json` 将提供方包纳入 CLI 依赖闭包。加载基础配置时不会探测 Codex 二进制程序或身份验证;缺少原生可用条件只会在工具实际调用时失败。 + 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 `turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。轮次完成却没有答案、远端轮次失败或中断、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`。本地取消在竞态中胜出并保持为 `aborted`。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0bede25716..a24fbd20f6 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,6 +94,8 @@ flowchart LR cfg --> plugin_dsh_base_subagent_spawn plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_dsh_base_subagent_fork + plugin_dsh_base_subagent_codex["subagent-codex
@deepseek-ai/dsh-subagent-codex"] + cfg --> plugin_dsh_base_subagent_codex plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] @@ -102,6 +104,8 @@ flowchart LR cfg --> plugin_dsh_base_tool_subagent plugin_dsh_base_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_dsh_base_tool_subagent_fork + plugin_dsh_base_tool_subagent_codex["tool-subagent-codex
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_dsh_base_tool_subagent_codex plugin_dsh_base_tool_subagent_report["tool-subagent-report
@deepseek-ai/dsh-tool-subagent-report"] cfg --> plugin_dsh_base_tool_subagent_report plugin_dsh_base_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] @@ -191,10 +195,12 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-codex` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 623b4d1153..5bc053c8c8 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -254,6 +254,9 @@ config: providerName: fork +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control @@ -276,6 +279,14 @@ toolName: subagent_fork backgroundMode: continuable +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: 'provider-managed' + # Optional direct-child return channel; absent from roots and one-shot agents. - id: tool-subagent-report name: '@deepseek-ai/dsh-tool-subagent-report' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0ccf4b2197..b69fb30a73 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -98,6 +98,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-codex": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index fcfe8b3829..5e3d6442d2 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -85,6 +85,7 @@ function startRawLifecycle(fixture: RawLifecycleFixture) { env: { DSH_HOME: fixture.home, DSH_TELEMETRY_DISABLED: '1', + PATH: fixture.home, RAW_READY_FILE: fixture.ready, RAW_SETTLED_FILE: fixture.settled, RAW_DISPOSED_FILE: fixture.disposed, @@ -163,6 +164,11 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") expect(stdout).toContain('agents: []') expect(stdout).toContain('# == base.cordis.yml') + expect(stdout).toContain("name: '@deepseek-ai/dsh-subagent-codex'") + expect(stdout).toContain('provider: codex') + expect(stdout).toContain('toolName: subagent_codex') + expect(stdout).toContain('enableRunInBackground: false') + expect(stdout).toContain('maxDepth: provider-managed') }, 30_000) it('composes the required raw overlay directly over the base', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 237b8296c4..d85ed96bf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,6 +377,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork From 8e4a1b3396c1023e5b28f22c09c2191027883108 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 18:00:09 +0800 Subject: [PATCH 06/27] fix(subagent): keep Codex provider opt-in --- ...-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...6-08-04-claude-code-and-codex-subagent-backends.md | 4 +--- ...8-04-claude-code-and-codex-subagent-backends.zh.md | 4 +--- apps/cli/composition.md | 6 ------ apps/cli/config/base.cordis.yml | 11 ----------- apps/cli/package.json | 1 - apps/cli/tests/built-bin.e2e.ts | 6 ------ pnpm-lock.yaml | 3 --- 8 files changed, 4 insertions(+), 35 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 91abd43031..8431b6bbce 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 52181edba816f651877eac2c616cf67191f7e550 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: bde24eb8939b10c10916dab93effd3b94cbc7e1b +2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 52181edba8..37f45f844c 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -16,7 +16,7 @@ The harness provides two sibling one-shot providers behind two fixed model-facin The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their fixed tools use `maxDepth: 'provider-managed'` because each out-of-process product owns any delegation budget inside its own harness; the parent sends no recursion cap that the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text fixed tool → shared subagent service → product provider → official product process @@ -37,8 +37,6 @@ fixed tool → shared subagent service → product provider → official product `@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. -The shipped `apps/cli/config/base.cordis.yml` loads this provider and a fixed `subagent_codex` tool by default, while `apps/cli/package.json` carries the provider package in the CLI dependency closure. Loading the base does not probe the Codex binary or authentication; missing native availability fails only when the tool is called. - Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. `turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A completed turn without an answer, a failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`. Local cancellation wins its race and remains `aborted`. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index bde24eb893..dcd7b90231 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -16,7 +16,7 @@ harness 在两个固定的面向模型工具背后提供两个一次性兄弟提 Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。固定工具使用 `maxDepth: 'provider-managed'`,因为每个进程外产品都负责其自身 harness 内部的委派预算;父级不会传入提供方无法执行的递归上限。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text fixed tool → shared subagent service → product provider → official product process @@ -37,8 +37,6 @@ fixed tool → shared subagent service → product provider → official product `@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 -正式发布的 `apps/cli/config/base.cordis.yml` 默认加载这个提供方和固定的 `subagent_codex` 工具,而 `apps/cli/package.json` 将提供方包纳入 CLI 依赖闭包。加载基础配置时不会探测 Codex 二进制程序或身份验证;缺少原生可用条件只会在工具实际调用时失败。 - 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 `turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。轮次完成却没有答案、远端轮次失败或中断、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`。本地取消在竞态中胜出并保持为 `aborted`。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index a24fbd20f6..0bede25716 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,8 +94,6 @@ flowchart LR cfg --> plugin_dsh_base_subagent_spawn plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_dsh_base_subagent_fork - plugin_dsh_base_subagent_codex["subagent-codex
@deepseek-ai/dsh-subagent-codex"] - cfg --> plugin_dsh_base_subagent_codex plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] @@ -104,8 +102,6 @@ flowchart LR cfg --> plugin_dsh_base_tool_subagent plugin_dsh_base_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_dsh_base_tool_subagent_fork - plugin_dsh_base_tool_subagent_codex["tool-subagent-codex
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_dsh_base_tool_subagent_codex plugin_dsh_base_tool_subagent_report["tool-subagent-report
@deepseek-ai/dsh-tool-subagent-report"] cfg --> plugin_dsh_base_tool_subagent_report plugin_dsh_base_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] @@ -195,12 +191,10 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `tool-subagent-codex` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 5bc053c8c8..623b4d1153 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -254,9 +254,6 @@ config: providerName: fork -- id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' - # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control @@ -279,14 +276,6 @@ toolName: subagent_fork backgroundMode: continuable -- id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: codex - toolName: subagent_codex - enableRunInBackground: false - maxDepth: 'provider-managed' - # Optional direct-child return channel; absent from roots and one-shot agents. - id: tool-subagent-report name: '@deepseek-ai/dsh-tool-subagent-report' diff --git a/apps/cli/package.json b/apps/cli/package.json index b69fb30a73..0ccf4b2197 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -98,7 +98,6 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-codex": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 5e3d6442d2..fcfe8b3829 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -85,7 +85,6 @@ function startRawLifecycle(fixture: RawLifecycleFixture) { env: { DSH_HOME: fixture.home, DSH_TELEMETRY_DISABLED: '1', - PATH: fixture.home, RAW_READY_FILE: fixture.ready, RAW_SETTLED_FILE: fixture.settled, RAW_DISPOSED_FILE: fixture.disposed, @@ -164,11 +163,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") expect(stdout).toContain('agents: []') expect(stdout).toContain('# == base.cordis.yml') - expect(stdout).toContain("name: '@deepseek-ai/dsh-subagent-codex'") - expect(stdout).toContain('provider: codex') - expect(stdout).toContain('toolName: subagent_codex') - expect(stdout).toContain('enableRunInBackground: false') - expect(stdout).toContain('maxDepth: provider-managed') }, 30_000) it('composes the required raw overlay directly over the base', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d85ed96bf7..237b8296c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,9 +377,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent - '@deepseek-ai/dsh-subagent-codex': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork From c09b20f96b08f5119712c0017858dde214e61236 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 18:38:05 +0800 Subject: [PATCH 07/27] fix(subagent): finalize Codex provider composition --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 20 +- ...ude-code-and-codex-subagent-backends.zh.md | 20 +- docs/capability-seams.md | 7 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../subagent/subagent-codex/cordis.yml | 19 +- .../subagent/subagent-codex/driver.ts | 51 ++++++ .../subagent/subagent-codex/fixture.ts | 100 +--------- .../codex/evidence.expected.json | 38 ---- .../codex/session.expected.jsonl | 25 --- .../subagent-product-providers.snapshot.ts | 171 ------------------ examples/package.json | 1 - knip.json | 4 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 8 +- packages/subagent/subagent-codex/README.zh.md | 8 +- packages/subagent/subagent-codex/package.json | 1 + packages/subagent/subagent-codex/src/wire.ts | 29 ++- .../tests/loader-composition.e2e.ts | 53 ++++++ .../subagent-codex/tests/real-product.spec.ts | 13 +- .../tests/subagent-codex.spec.ts | 65 ++++++- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 1 + packages/subagent/subagent/README.zh.md | 1 + pnpm-lock.yaml | 6 +- scripts/gen-doc-graphs.ts | 6 +- 31 files changed, 270 insertions(+), 405 deletions(-) create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts delete mode 100644 examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json delete mode 100644 examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl delete mode 100644 examples/acp-agent/tests/subagent-product-providers.snapshot.ts create mode 100644 packages/subagent/subagent-codex/tests/loader-composition.e2e.ts diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 8431b6bbce..bde3f3cf11 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28 +2026-08-04-claude-code-and-codex-subagent-backends.md: 3b9fd51632439da5b3c3fd9187de552d6c9ca5e2 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 36be903640ad1c839d45ed1bf5e605f4e4d6e000 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 37f45f844c..3b9fd51632 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -8,15 +8,15 @@ English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) 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, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. They must also prove the real assembled path in required keyless tests. A direct model HTTP request, product double, or hand-mounted plugin cannot show that the Loader, fixed tool, provider registration, official product protocol, native authentication shape, final answer, and teardown work together. +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official protocol, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot replace the former; a hand-mounted plugin cannot replace the latter. ## Proposal -The harness provides two sibling one-shot providers behind two fixed model-facing tools. `subagent_codex` selects the `codex` provider, and `subagent_claude_code` selects the `claude-code` provider. Each tool accepts only a standalone text task and binds its provider at deployment time; product selection and background execution are not model arguments. +The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text fixed tool → shared subagent service → product provider → official product process @@ -30,7 +30,7 @@ fixed tool → shared subagent service → product provider → official product | --- | --- | --- | --- | | Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published | | Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects | -| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive one shared `completed`, `error`, or `aborted` result | The parent receives only a final answer or an explicit failure | +| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive an existing shared stop reason; Codex uses `max-tokens` only for explicit context exhaustion | The parent receives only a final answer or an explicit failure | | Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits | ## Codex provider @@ -39,9 +39,9 @@ fixed tool → shared subagent service → product provider → official product Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A completed turn without an answer, a failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`. Local cancellation wins its race and remains `aborted`. +`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. -The unattended wire declines command and file approvals, grants no requested permissions for the turn, and declines MCP elicitation. Any other server request fails the run instead of waiting for a user interface the provider does not supply. +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. @@ -51,11 +51,11 @@ The Claude Code sibling is not yet implemented. Its product version, official in ## Evidence contract -Each product owns branch-complete package tests, a required real-product spec, and a real Loader snapshot. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. +Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. -The Codex evidence pins `@openai/codex@0.146.0` and `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 whole-tree exit. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, complete persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.146.0` and `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 whole-tree exit. Its Loader e2e resolves `@deepseek-ai/dsh-subagent-codex` by package name, verifies the `codex` registration and `subagent_codex` schema with background omitted, accepts `maxDepth: 'provider-managed'`, and records zero child starts while no `codex` command is available. The npm package is a development dependency for reproducible real-product evidence; production still supplies `codex` on `PATH`. -The combined contract is complete only when the Claude sibling has equivalent real-product evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract. +The combined contract is complete only when the Claude sibling has equivalent real-product evidence and both public Loader configurations prove the fixed tools use the unchanged common subagent contract. ## Alternatives considered @@ -73,7 +73,7 @@ The combined contract is complete only when the Claude sibling has equivalent re ## Acceptance criteria -Both fixed tools reach their corresponding real products through the Loader, return exact final answers or explicit failure or cancellation, persist the complete model-visible parent transcript, and prove managed process-tree quiescence in required keyless CI. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. +Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required real-product specs return exact final answers or explicit failure or cancellation and prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index dcd7b90231..36be903640 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -8,15 +8,15 @@ Status: proposed 命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 -产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。它们还必须在强制性的无密钥测试中证明真实组装路径。直接发起模型 HTTP 请求、使用产品替身或手工挂载插件,都无法证明 Loader、固定工具、提供方注册、官方产品协议、原生身份验证形态、最终回答和资源清理能够协同工作。 +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据会分别证明两个事实:真实产品测试证明官方协议、原生身份验证形态、最终回答和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品。直接发起模型 HTTP 请求或使用产品替身无法取代前者,手工挂载插件则无法取代后者。 ## 提案 -harness 在两个固定的面向模型工具背后提供两个一次性兄弟提供方。`subagent_codex` 选择 `codex` 提供方,`subagent_claude_code` 选择 `claude-code` 提供方。每个工具只接受独立文本任务,并在部署时绑定其提供方;产品选择与后台执行都不作为模型参数。 +harness 将两个一次性兄弟提供方发布为可独立安装的可选包。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,而 `subagent_claude_code` 绑定 `claude-code`。正式 CLI 的依赖闭包以及基础、Web 和 headless 配置都不加载这两个提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。文档中的工具会关闭后台执行并使用 `maxDepth: 'provider-managed'`,让进程外产品自行负责递归策略,而不会向提供方发送其无法执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text fixed tool → shared subagent service → product provider → official product process @@ -30,7 +30,7 @@ fixed tool → shared subagent service → product provider → official product | --- | --- | --- | --- | | 解析 | `dsh-tool-subagent` 与 `ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 | | 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 | -| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导一个共享的 `completed`、`error` 或 `aborted` 结果 | 父级只会收到最终回答或明确失败 | +| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导出一种现有的共享停止原因;Codex 仅在明确发生上下文耗尽时使用 `max-tokens` | 父级只会收到最终回答或明确失败 | | dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 | ## Codex 提供方 @@ -39,9 +39,9 @@ fixed tool → shared subagent service → product provider → official product 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。轮次完成却没有答案、远端轮次失败或中断、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`。本地取消在竞态中胜出并保持为 `aborted`。 +`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 -无人值守的协议连接会拒绝命令与文件审批,不授予该轮次请求的任何权限,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方没有提供的用户界面。 +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 @@ -51,11 +51,11 @@ Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本 ## 证据契约 -每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一个真实 Loader 快照。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。 +每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一项 Loader 装配 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。 -Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader 快照锁定不支持后台执行的工具 schema、确切的工具调用与结果、完整的已持久化父会话、产品请求,以及清理前的完全停稳状态。该 NPM 包是用于复现证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader e2e 会按包名解析 `@deepseek-ai/dsh-subagent-codex`,验证 `codex` 注册与省略后台参数的 `subagent_codex` schema,接受 `maxDepth: 'provider-managed'`,并在环境中没有可用 `codex` 命令时记录零次子级启动。该 NPM 包是用于复现真实产品证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 -只有在 Claude 兄弟提供方具备同等的真实产品证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。 +只有在 Claude 兄弟提供方具备同等的真实产品证据,并且两个公开 Loader 配置都证明固定工具使用未变的通用 subagent 契约时,组合契约才算完整。 ## 曾考虑的替代方案 @@ -73,7 +73,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 ## 验收标准 -两个固定工具都通过 Loader 到达相应的真实产品,返回完全一致的最终回答或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制性的无密钥 CI 中证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 +两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,并证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 44af0b5e76..c64e678b54 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -104,6 +104,7 @@ flowchart LR pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] pkg_subagent_codex["subagent-codex"] + pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] @@ -225,6 +226,7 @@ flowchart LR pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_codex --> svc_subagents + pkg_subagent_dsh_sdk --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents pkg_subprocess --> svc_subprocess @@ -314,6 +316,7 @@ flowchart LR svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp svc_subprocess --> pkg_subagent_codex + svc_subprocess --> pkg_subagent_dsh_sdk svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -373,7 +376,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | - | The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | @@ -384,7 +387,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index f2438bea7a..0582fb9dac 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/cookbook/extension-cookbook.md -extension-cookbook.md: 379bd2644a4a7de005c8ea56d4857b6a6f9143b8 -extension-cookbook.zh.md: 09a5163c3b47e52e0e0e88f000a8f04302fa93e4 +extension-cookbook.md: 820d7fce8560028f592ec101f4f222013105f035 +extension-cookbook.zh.md: bb48f584729ad3a67fd43ca8c4c9f6f4902de656 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 379bd2644a..820d7fce85 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -118,7 +118,7 @@ Every product feature maps to a listener on a documented extension seam — the | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | -| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`) + `dsh-tool-subagent` exposing one configured provider to the model | +| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-dsh-sdk`) + `dsh-tool-subagent` exposing one configured provider to the model | | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 09a5163c3b..bb48f58472 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -118,7 +118,7 @@ export function apply(ctx: Context) { | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | -| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | +| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-dsh-sdk`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d5682a47bc..6bd775a84c 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/core-data-structures/subagent.md -subagent.md: 917913470da389dccac83e455cf23486a94c23b1 -subagent.zh.md: efe12a5a5fe40d664f3071036157acd704b10c57 +subagent.md: c810ae40f57a0f84f1a6b53e092d6bec88af206f +subagent.zh.md: efeec0a0f20b7ac85020df012878cf41f264073d diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 917913470d..c810ae40f5 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index efe12a5a5f..efeec0a0f2 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为四个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为五个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index f219214dd0..be1af7c135 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -1,5 +1,5 @@ -# Test-only composition: one real Codex app-server delegation through the -# Loader, fixed provider tool, common foreground settlement, and JSONL store. +# Test-only composition of the public opt-in provider and foreground tool. +# The owning e2e boots this tree but never invokes the model or Codex. - id: fixture name: './fixture.ts' @@ -11,17 +11,6 @@ - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' - config: - env: - OPENAI_API_KEY: !!js process.env.DSH_TEST_OPENAI_API_KEY - CODEX_HOME: !!js process.env.DSH_TEST_CODEX_HOME - HOME: !!js process.cwd() - XDG_CONFIG_HOME: !!js process.cwd() + '/xdg' - PATH: !!js process.env.PATH - HTTP_PROXY: '' - HTTPS_PROXY: '' - ALL_PROXY: '' - NO_PROXY: '127.0.0.1,localhost' - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' @@ -36,7 +25,5 @@ config: provider: mock model: mock-delegate - persona: 'Delegate the task through the fixed Codex tool.' - persistenceRoot: './.sessions' - persistenceCompression: 'none' + persona: 'This composition test must not start a model turn.' workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts new file mode 100644 index 0000000000..873f5e36de --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** Inspect the public Codex provider composition without invoking the product. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-tools' + +const configPath = process.argv[2] +if (configPath === undefined) { + throw new Error('subagent-codex Loader composition driver requires a config path') +} + +let starts = 0 +const ctx = await boot( + 'subagent-codex-loader-composition', + resolveConfigPath(configPath, undefined), + undefined, + (hostCtx) => { + hostCtx.on('subagent/start', () => { + starts += 1 + }) + }, +) + +try { + const provider = ctx.subagents.getProvider('codex') + if (provider === undefined) throw new Error('Codex provider was not registered') + const tool = ctx.tools.schemas().find(schema => schema.name === 'subagent_codex') + if (tool === undefined) throw new Error('subagent_codex tool was not registered') + const properties = tool.parameters.properties + if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) { + throw new Error('subagent_codex tool has invalid parameter properties') + } + + process.stdout.write(`${JSON.stringify({ + providers: ctx.subagents.list(), + provider: { + name: provider.name, + capabilities: provider.capabilities, + inheritsParentContext: provider.inheritsParentContext, + }, + tool: { + name: tool.name, + parameterNames: Object.keys(properties).sort(), + required: tool.parameters.required, + }, + starts, + })}\n`) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts index 9618c83654..e2dc946dfe 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts @@ -1,102 +1,22 @@ -/** Deterministic parent model and process-quiescence observer for the Codex Loader snapshot. */ +/** Parent adapter that fails if the composition-only Loader test starts a turn. */ -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' import type { Context } from 'cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { - SubprocessHandle, - SubprocessSpawnSpec, -} from '@deepseek-ai/dsh-subprocess' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' -const CODEX_TASK = 'Return the Loader snapshot sentinel exactly.' -const QUIESCENCE_FILE = '.codex-quiescence.json' - -function toolResultText(options: GenerateOptions): string { - return options.messages.at(-1)?.content - .filter(block => block.type === 'tool-result') - .flatMap(block => block.content) - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') ?? '' -} - -class CodexDelegatingAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { - const result = toolResultText(options) - if (result.length === 0) { - const args = JSON.stringify({ - description: 'Codex Loader snapshot', - prompt: CODEX_TASK, - }) - yield { type: 'block-start', index: 0, blockType: 'tool-call' } - yield { - type: 'tool-call-delta', - index: 0, - id: CallId('call-codex-loader'), - name: 'subagent_codex', - argumentsDelta: args, - } - yield { - type: 'block-end', - index: 0, - block: { - type: 'tool-call', - id: CallId('call-codex-loader'), - name: 'subagent_codex', - arguments: args, - }, - } - yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } - return - } - - const reply = `Codex child returned: ${result}` - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text: reply } - yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } - yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } - yield { type: 'finish', reason: { kind: 'stop' } } +class CompositionOnlyAdapter extends LlmAdapter { + async * stream(_options: GenerateOptions): AsyncIterable { + throw new Error('subagent-codex Loader composition must not invoke a model') } } -interface ObservedProcess { - readonly spec: SubprocessSpawnSpec - readonly handle: SubprocessHandle -} - -export const name = 'codex-loader-snapshot-fixture' -export const inject = ['llm', 'subprocess'] +export const name = 'codex-loader-composition-fixture' +export const inject = ['llm'] /** - * Register the deterministic parent adapter and record whether every spawned - * product tree was already quiet when the assembled application disposed. - * @param ctx - Loader context supplying the LLM and subprocess seams. + * Register a parent adapter solely so the host composition is complete. + * @param ctx - Loader context supplying the LLM seam. */ export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['mock'], new CodexDelegatingAdapter()) - ctx.effect(() => { - const observed: ObservedProcess[] = [] - const originalSpawn = ctx.subprocess.spawn.bind(ctx.subprocess) - ctx.subprocess.spawn = (spec: SubprocessSpawnSpec): SubprocessHandle => { - const handle = originalSpawn(spec) - observed.push({ spec, handle }) - return handle - } - return async () => { - ctx.subprocess.spawn = originalSpawn - const alreadyExited = AbortSignal.abort() - const processes = await Promise.all(observed.map(async ({ spec, handle }) => ({ - argv: [...spec.argv], - quiescent: await handle.waitForExit(alreadyExited), - outcome: await handle.done, - }))) - await writeFile( - join(process.cwd(), QUIESCENCE_FILE), - `${JSON.stringify({ processes })}\n`, - ) - } - }, 'codex Loader snapshot process observer') + ctx.llm.registerAdapter(['mock'], new CompositionOnlyAdapter()) } diff --git a/examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json b/examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json deleted file mode 100644 index f6f9b995ae..0000000000 --- a/examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "stdout": { - "type": "result", - "success": true, - "sessionId": "{{sessionId}}", - "turn": 1, - "result": "Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0", - "reason": { - "kind": "completed" - }, - "usage": { - "inputTokens": 20, - "outputTokens": 61 - } - }, - "request": { - "method": "POST", - "path": "/v1/responses", - "authorization": "Bearer dsh-fake-openai-loader-key", - "taskObserved": true - }, - "quiescence": { - "processes": [ - { - "argv": [ - "codex", - "app-server", - "--stdio" - ], - "quiescent": true, - "outcome": { - "exitCode": 0, - "signal": null - } - } - ] - } -} diff --git a/examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl b/examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl deleted file mode 100644 index e15b81ddf0..0000000000 --- a/examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl +++ /dev/null @@ -1,25 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Delegate through Codex once."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Delegate through Codex once.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"mock","model":"mock-delegate"},"system":"{{system}}","tools":[{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent_codex","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}}]},"reason":"initial"}} -{"type":"request/context","seq":5,"time":0,"data":{"provider":"mock","model":"mock-delegate"}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-codex-loader","name":"subagent_codex","argumentsDelta":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}} -{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-codex-loader"},"content":[{"type":"tool-result","toolCallId":"call-codex-loader","content":[{"type":"text","text":"REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":56}}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":56}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts deleted file mode 100644 index 64d1d244ae..0000000000 --- a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Real-product Loader snapshots for fixed subagent providers. - * - * PR1 owns the Codex scenario. PR2 extends this file with the sibling Claude - * Code scenario and reruns both from its final stacked candidate. - */ - -import { homedir } from 'node:os' -import { dirname, delimiter, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { describe, expect, it } from 'vitest' -import { - normalizeSessionLog, - normalizeStdout, - scrubSystemPrompts, - type NormalizeContext, -} from '@deepseek-ai/dsh-acp-snapshot' -import { - LOADER_SMOKE_TEST_TIMEOUT_MS, - runLoaderSmoke, -} from '@deepseek-ai/dsh-loader-smoke' -import { startResponsesFixture } from '../../../packages/subagent/subagent-codex/tests/responses-fixture.ts' - -const testsDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) -const fixtureDir = join(testsDir, 'fixtures/subagent/subagent-codex') -const configPath = join(fixtureDir, 'cordis.yml') -const snapshotDir = join(testsDir, 'product-provider-snapshots/codex') -const sessionExpected = join(snapshotDir, 'session.expected.jsonl') -const evidenceExpected = join(snapshotDir, 'evidence.expected.json') -const cliBin = join(repoRoot, 'packages/examples/cli-demo/src/bin.ts') -const repoTsconfig = join(repoRoot, 'tsconfig.json') -const codexBinDir = join( - repoRoot, - 'packages/subagent/subagent-codex/node_modules/.bin', -) -const refreshing = process.env.DSH_SNAPSHOT === 'refresh' -const CODEX_SENTINEL = 'REAL_CODEX_LOADER_SENTINEL_0_146_0' -const FAKE_KEY = 'dsh-fake-openai-loader-key' - -interface PersistedSession { - readonly content: string - readonly header: { - readonly id: string - readonly cwd: string - } -} - -async function onlySession(root: string): Promise { - const paths = (await readdir(root, { recursive: true })) - .filter(path => path.endsWith('.jsonl')) - expect(paths).toHaveLength(1) - const path = paths[0] - if (path === undefined) throw new Error('Codex Loader snapshot persisted no session') - const content = await readFile(join(root, path), 'utf8') - const header = JSON.parse(content.slice(0, content.indexOf('\n'))) as PersistedSession['header'] - return { content, header } -} - -function responseInputTexts(body: Record): string[] { - if (!Array.isArray(body.input)) return [] - return body.input.flatMap((item): string[] => { - if (item === null || typeof item !== 'object') return [] - const content = (item as Record).content - if (!Array.isArray(content)) return [] - return content.flatMap((part): string[] => ( - part !== null - && typeof part === 'object' - && typeof (part as Record).text === 'string' - ? [(part as Record).text as string] - : [] - )) - }) -} - -describe('real product subagent providers through the Loader', () => { - it('pins the Codex tool, result, persisted Session, and process quiescence', async () => { - const codexHome = await mkdtemp(join(homedir(), '.dsh-subagent-codex-loader-')) - const responses = await startResponsesFixture([ - { kind: 'complete', text: CODEX_SENTINEL }, - ]) - let session: PersistedSession | undefined - let quiescence: unknown - try { - const result = await runLoaderSmoke({ - label: 'Codex subagent Loader snapshot', - tempDirPrefix: 'dsh-subagent-codex-loader-', - binScript: cliBin, - configPath, - binArgs: [ - '--config', - configPath, - '--output-format', - 'json', - 'Delegate through Codex once.', - ], - tsconfigPath: repoTsconfig, - processTimeoutMs: 45_000, - env: { - DSH_TEST_CODEX_HOME: codexHome, - DSH_TEST_OPENAI_API_KEY: FAKE_KEY, - PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, - }, - async prepare(): Promise { - await writeFile(join(codexHome, 'config.toml'), [ - 'model = "fixture-model"', - 'model_provider = "fixture"', - 'approval_policy = "on-request"', - 'sandbox_mode = "read-only"', - 'disable_response_storage = true', - 'check_for_update_on_startup = false', - '', - '[model_providers.fixture]', - 'name = "Fixture Responses"', - `base_url = "${responses.baseUrl}"`, - 'env_key = "OPENAI_API_KEY"', - 'wire_api = "responses"', - 'requires_openai_auth = false', - '', - '[analytics]', - 'enabled = false', - '', - ].join('\n')) - }, - async inspect(cwd): Promise { - session = await onlySession(join(cwd, '.sessions')) - quiescence = JSON.parse(await readFile(join(cwd, '.codex-quiescence.json'), 'utf8')) - }, - }) - - expect(result.stderr).toBe('') - expect(session).toBeDefined() - if (session === undefined) throw new Error('Codex Loader snapshot session was not inspected') - const context: NormalizeContext = { - sessionIds: [session.header.id], - cwd: session.header.cwd, - } - const normalizedSession = scrubSystemPrompts(normalizeSessionLog(session.content, context)) - const request = responses.requests[0] - expect(request).toBeDefined() - if (request === undefined) throw new Error('Codex Loader snapshot made no Responses request') - const evidence = `${JSON.stringify({ - stdout: JSON.parse(normalizeStdout(result.stdout, context)) as unknown, - request: { - method: request.method, - path: request.path, - authorization: request.headers.authorization, - taskObserved: responseInputTexts(request.body) - .includes('Return the Loader snapshot sentinel exactly.'), - }, - quiescence, - }, null, 2)}\n` - - if (refreshing) { - await mkdir(snapshotDir, { recursive: true }) - await Promise.all([ - writeFile(sessionExpected, normalizedSession), - writeFile(evidenceExpected, evidence), - ]) - } - expect(normalizedSession).toBe(await readFile(sessionExpected, 'utf8')) - expect(evidence).toBe(await readFile(evidenceExpected, 'utf8')) - } finally { - await Promise.all([ - responses.close(), - rm(codexHome, { recursive: true, force: true }), - ]) - } - }, LOADER_SMOKE_TEST_TIMEOUT_MS + 30_000) -}) diff --git a/examples/package.json b/examples/package.json index 83e20a5b2b..bc697ab11c 100644 --- a/examples/package.json +++ b/examples/package.json @@ -67,7 +67,6 @@ "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", - "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", diff --git a/knip.json b/knip.json index 75909cc8d3..1d99ae4cd4 100644 --- a/knip.json +++ b/knip.json @@ -46,6 +46,7 @@ "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", + "acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", @@ -543,7 +544,8 @@ }, "packages/subagent/subagent-codex": { "entry": [ - "tests/**/*.spec.ts" + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index bee793e09a..3e8e805c88 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-codex/README.md -README.md: ca92f935539812351dd578dca700c9a0113dcd46 -README.zh.md: 6f6690ea51970dd39c738ad0ec4f55c2a5ab2467 +README.md: ce1c66427b562c08af06320f012f28b9e125ac45 +README.zh.md: bef47586db77c70bec741629d8579ba0e2efba1e diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index ca92f93553..ce1c66427b 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -10,9 +10,9 @@ This package registers the fixed `codex` subagent provider. Each accepted run st The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. -The unattended provider answers command and file approvals with `decline`, answers permission requests with an empty turn-scoped permission set, and declines MCP elicitation. Any other server request fails the run instead of waiting for interaction that this provider cannot supply. +For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. -Local cancellation wins the result race and maps to `aborted`; a remote interrupted or failed turn maps to `error`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` when the current ids are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and this version produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. ## Capabilities and context @@ -27,6 +27,8 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. +Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default. + ```yaml - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' @@ -45,7 +47,7 @@ Production resolves `codex` from `PATH` and uses the host's native Codex configu ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: package tests drive the real binary against a loopback Responses service with a non-empty fake key, and the Loader snapshot fixes the model-visible tool schema, exact tool result, persisted parent Session, original child task, authentication header, and pre-teardown process-tree quiescence. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. ## Model Experience diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 6f6690ea51..bef47586db 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -10,9 +10,9 @@ 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 -无人值守的提供方对命令与文件审批答复 `decline`,对权限请求返回作用域限于当前轮次的空权限集,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方无法提供的交互。 +对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 -本地取消会在结果竞态中胜出并映射为 `aborted`;远端轮次若中断或失败,则映射为 `error`。`dispose()` 具有幂等性:如果当前标识符已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且本版本不会产生 `refusal`。`dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 ## 能力与上下文 @@ -27,6 +27,8 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 +请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`。 + ```yaml - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' @@ -45,7 +47,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:包测试使用非空的伪密钥,驱动真实二进制程序连接回环 Responses 服务;Loader 快照则锁定模型可见的工具 schema、确切的工具结果、已持久化的父会话、原始子任务、身份验证请求头,以及清理前进程树的完全停稳状态。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 ## 模型体验 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index ea4a2a2e45..10bf7ee5f4 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 9d15f7c6d1..4e920113e3 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -39,6 +39,25 @@ function string(value: unknown, label: string): string { return value } +function unattendedDecision(params: JsonObject): 'cancel' | 'decline' { + const available = params.availableDecisions + if (available === undefined || available === null) return 'decline' + if (Array.isArray(available)) { + if (available.includes('cancel')) return 'cancel' + if (available.includes('decline')) return 'decline' + } + throw new Error('subagent-codex: app-server offered no unattended approval decision') +} + +function isContextWindowExceeded(turn: JsonObject): boolean { + if (turn.status !== 'failed') return false + const error = turn.error + return error !== null + && typeof error === 'object' + && !Array.isArray(error) + && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded' +} + function thrown(value: unknown): Error { /* v8 ignore next -- typed protocol and stream failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -160,7 +179,7 @@ export class CodexAppServerWire { * @param texts - already validated task text blocks. * @param signal - local cancellation for the published run. * @param cancelled - whether local cancellation has already won. - * @returns the shared three-state subagent result. + * @returns the shared subagent result. */ async runTurn( texts: readonly string[], @@ -182,6 +201,9 @@ export class CodexAppServerWire { const terminal = object(completed.turn, 'turn/completed turn') const status = terminal.status + if (isContextWindowExceeded(terminal)) { + return { output: this.collectOutput(), stopReason: 'max-tokens' } + } if (status !== 'completed') { const detail = status === 'failed' ? `: ${JSON.stringify(terminal.error)}` @@ -292,10 +314,13 @@ export class CodexAppServerWire { case 'item/commandExecution/requestApproval': case 'item/fileChange/requestApproval': this.validateRunIds(params) - return Promise.resolve({ decision: 'decline' }) + return Promise.resolve({ decision: unattendedDecision(params) }) case 'item/permissions/requestApproval': this.validateRunIds(params) return Promise.resolve({ permissions: {}, scope: 'turn' }) + case 'item/tool/requestUserInput': + this.validateRunIds(params) + return Promise.resolve({ answers: {} }) case 'mcpServer/elicitation/request': this.validateRunIds(params, true) return Promise.resolve({ action: 'decline', content: null, _meta: null }) diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..6c4019f8c8 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -0,0 +1,53 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-codex/', + import.meta.url, +)) +const driver = join(fixtureDir, 'driver.ts') +const configPath = join(fixtureDir, 'cordis.yml') +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +describe('Codex provider public Loader composition', () => { + it('loads the opt-in package and foreground tool without starting Codex', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'subagent-codex Loader composition', + tempDirPrefix: 'dsh-subagent-codex-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + // Loading the optional package must not probe or start a Codex binary. + PATH: '', + }, + }) + + expect(stderr).toBe('') + expect(JSON.parse(stdout)).toEqual({ + providers: ['codex'], + provider: { + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + tool: { + name: 'subagent_codex', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + starts: 0, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 77c494f762..dd7c0c458f 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -172,8 +172,7 @@ describe('real @openai/codex 0.146.0 product', () => { await expectQuiescent(harness.handles) }, 20_000) - it('declines a real app-server command approval without executing the command', async () => { - const sentinel = 'REAL_CODEX_APPROVAL_DECLINED' + it('cancels a real app-server command approval without executing the command', async () => { const { harness, fixture } = await realHarness([ { kind: 'functionCall', @@ -184,7 +183,6 @@ describe('real @openai/codex 0.146.0 product', () => { justification: 'exercise the unattended approval boundary', }, }, - { kind: 'complete', text: sentinel }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') const run = await harness.ctx.subagents.start('codex', { @@ -193,20 +191,17 @@ describe('real @openai/codex 0.146.0 product', () => { signal: new AbortController().signal, }) await expect(run.result).resolves.toEqual({ - output: [{ type: 'text', text: sentinel }], - stopReason: 'completed', + output: [], + stopReason: 'error', }) await run.dispose() expect(existsSync(sideEffect)).toBe(false) - expect(fixture.requests).toHaveLength(2) + expect(fixture.requests).toHaveLength(1) const tools = fixture.requests[0]!.body.tools as Array> expect(tools).toEqual(expect.arrayContaining([ expect.objectContaining({ type: 'function', name: 'exec_command' }), ])) - const followup = JSON.stringify(fixture.requests[1]!.body) - expect(followup).toContain('call_fixture') - expect(followup).toContain('rejected by user') expect(fixture.requests.every(requestEntry => requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', )).toBe(true) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 66d5ef5d5c..6cc4461e06 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -417,6 +417,25 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('maps only an explicit context-window failure to max-tokens', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'too much context', + codexErrorInfo: 'contextWindowExceeded', + }), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + stopReason: 'max-tokens', + }) + wire.close() + }) + it('rejects invalid handshake, thread, and turn response shapes', async () => { { const child = fakeChild() @@ -516,7 +535,7 @@ describe('CodexAppServerWire', () => { wire.close() }) - it('answers all four unattended request classes without granting authority', async () => { + it('answers all five unattended request classes without granting authority', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal, () => false) const turnStart = await child.peer.nextMethod('turn/start') @@ -524,10 +543,14 @@ describe('CodexAppServerWire', () => { child.peer.send({ id: 'command', method: 'item/commandExecution/requestApproval', - params: { threadId: 'thread-1', turnId: 'turn-1' }, + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline', 'cancel'], + }, }) expect(await child.peer.nextResponse('command')).toMatchObject({ - result: { decision: 'decline' }, + result: { decision: 'cancel' }, }) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -536,6 +559,16 @@ describe('CodexAppServerWire', () => { { id: 'file', method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + result: { decision: 'decline' }, + }, + { + id: 'file-default', + method: 'item/fileChange/requestApproval', params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { decision: 'decline' }, }, @@ -545,6 +578,12 @@ describe('CodexAppServerWire', () => { params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { permissions: {}, scope: 'turn' }, }, + { + id: 'user-input', + method: 'item/tool/requestUserInput', + params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] }, + result: { answers: {} }, + }, { id: 'mcp', method: 'mcpServer/elicitation/request', @@ -568,9 +607,27 @@ describe('CodexAppServerWire', () => { for (const serverRequest of [ { id: 'unknown', - method: 'item/tool/requestUserInput', + method: 'future/request', params: { threadId: 'thread-1', turnId: 'turn-1' }, }, + { + id: 'approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['accept'], + }, + }, + { + id: 'malformed-approval', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: 'decline', + }, + }, { id: 'thread', method: 'item/fileChange/requestApproval', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 15873129cd..98763ddb6e 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent/README.md -README.md: 4040f9a48bd61cc230adec1bd9725cf30bdfd8f7 -README.zh.md: 5f6a041887e3227d92a88eac344524e55a598413 +README.md: 4682b06ae105a0ae70ea7e78a80776ac18d817e7 +README.zh.md: c39afb26d8c6baf4774ae3b4a8f8151529a29e15 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4040f9a48b..4682b06ae1 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -15,6 +15,7 @@ The family separates the stable interface from implementations and model-facing | `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | | `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). | +| `@deepseek-ai/dsh-subagent-dsh-sdk` | Fresh out-of-process harness child driven through the TypeScript SDK client (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | | `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. | diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 5f6a041887..c39afb26d8 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -15,6 +15,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | | `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 | +| `@deepseek-ai/dsh-subagent-dsh-sdk` | 通过 TypeScript SDK 客户端驱动的全新进程外 harness 子 agent(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | | `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 237b8296c4..352a8a2abf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,9 +724,6 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn - '@deepseek-ai/dsh-subprocess': - specifier: workspace:* - version: link:../packages/subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../packages/subprocess/subprocess-local @@ -4969,6 +4966,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ version: link:../../sdk/sdk-protocol diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 804dbbddf4..7c3c8261fe 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex'], - note: 'The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], + note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', @@ -416,7 +416,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider and continuation service', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, From f96acad43829e6e7461418fa738265cdd8398396 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 19:55:39 +0800 Subject: [PATCH 08/27] fix(subagent): preserve Codex fatal and grace semantics --- packages/subagent/subagent-codex/src/run.ts | 50 ++++++++++++++- packages/subagent/subagent-codex/src/wire.ts | 26 ++++---- .../tests/subagent-codex.spec.ts | 48 +++++++++++++- .../subprocess/subprocess-local/src/spawn.ts | 63 +++++++++++++++++-- .../subprocess-local/tests/spawn.spec.ts | 43 ++++++++++++- 5 files changed, 210 insertions(+), 20 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 21f22d8a1b..811f7c8f98 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -24,6 +24,47 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +/** + * Bound final exit observation at twice a positive finite grace without + * narrowing the public config to Node's single-timer integer range. + */ +function doubledGraceWindow(graceMs: number): { + readonly signal: AbortSignal + readonly cancel: () => void +} { + const whole = Math.floor(graceMs) + let remaining = BigInt(whole) * 2n + + BigInt(Math.ceil((graceMs - whole) * 2)) + const controller = new AbortController() + let timer: ReturnType | undefined + const arm = (): void => { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + timer = setTimeout(() => { + timer = undefined + if (remaining === 0n) { + controller.abort() + } else { + arm() + } + }, Number(chunk)) + } + arm() + return { + signal: controller.signal, + cancel: () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + }, + } +} + /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ @@ -88,8 +129,13 @@ export async function disposeCodexChild( // A concurrently closed stdin does not change tree ownership below. } child.terminate() - if (!(await child.waitForExit(AbortSignal.timeout(graceMs * 2)))) { - throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') + const exitWindow = doubledGraceWindow(graceMs) + try { + if (!(await child.waitForExit(exitWindow.signal))) { + throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') + } + } finally { + exitWindow.cancel() } await child.done } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 4e920113e3..304c5eadb4 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -17,12 +17,17 @@ type JsonObject = Record interface Deferred { readonly promise: Promise readonly resolve: (value: T) => void + readonly reject: (reason?: unknown) => void } function deferred(): Deferred { let resolve!: (value: T) => void - const promise = new Promise((settle) => { resolve = settle }) - return { promise, resolve } + let reject!: (reason?: unknown) => void + const promise = new Promise((settle, fail) => { + resolve = settle + reject = fail + }) + return { promise, resolve, reject } } function object(value: unknown, label: string): JsonObject { @@ -93,7 +98,7 @@ async function raceAbort(pending: Promise, signal: AbortSignal): Promise() + private readonly fatal = deferred() private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined @@ -111,6 +116,10 @@ export class CodexAppServerWire { output: Writable, ) { this.transport = new JsonRpcLineTransport(input, output) + // Fatal protocol state can arrive after the current guarded operation has + // already settled. Keep the shared rejection observed without inserting + // another promise-adoption hop into active races. + void this.fatal.promise.catch(() => {}) this.transport.onRequest((method, params) => this.handleServerRequest(method, params)) this.transport.onNotification((method, params) => { try { @@ -157,9 +166,8 @@ export class CodexAppServerWire { * Create the run's private ephemeral thread and retain its identity. * @param cwd - parent Session workspace. * @param signal - unpublished-start cancellation. - * @returns the app-server thread id. */ - async startThread(cwd: string, signal: AbortSignal): Promise { + async startThread(cwd: string, signal: AbortSignal): Promise { const response = object(await this.guarded(this.transport.request('thread/start', { cwd, ephemeral: true, @@ -170,7 +178,6 @@ export class CodexAppServerWire { throw new Error('subagent-codex: app-server did not create an ephemeral thread') } this.threadId = id - return id } /** @@ -249,15 +256,12 @@ export class CodexAppServerWire { } private async guarded(pending: Promise, signal: AbortSignal): Promise { - const withFatal = Promise.race([ - pending, - this.fatal.promise.then((error): Promise => Promise.reject(error)), - ]) + const withFatal = Promise.race([pending, this.fatal.promise]) return raceAbort(withFatal, signal) } private fail(error: Error): void { - this.fatal.resolve(error) + this.fatal.reject(error) } private readonly onInputError = (error: Error): void => { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 6cc4461e06..18e28cc7cc 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -210,7 +210,7 @@ async function initializeWire(): Promise<{ const starting = wire.startThread(process.cwd(), new AbortController().signal) const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) - await expect(starting).resolves.toBe('thread-1') + await starting return { child, wire } } @@ -516,6 +516,21 @@ describe('CodexAppServerWire', () => { } }) + it('keeps an earlier fatal frame authoritative over later completion in the same chunk', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send( + agentMessage('invalid', 'future_phase'), + agentMessage('late answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).rejects.toThrow('unknown agent message phase') + wire.close() + }) + it('gives local cancellation precedence over a remote completed turn', async () => { const { child, wire } = await initializeWire() let cancelled = false @@ -1038,6 +1053,37 @@ describe('disposeCodexChild', () => { expect(child.waitForExit).toHaveBeenCalledTimes(1) }) + it('accepts fractional and larger-than-Node grace windows', async () => { + for (const graceMs of [0.25, Number.MAX_VALUE]) { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, graceMs)) + .resolves.toBeUndefined() + const signal = vi.mocked(child.waitForExit).mock.calls[0]?.[0] + expect(signal?.aborted).toBe(false) + } + }) + + it('chains a doubled grace window beyond one Node timer segment', async () => { + vi.useFakeTimers() + try { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const disposal = disposeCodexChild( + wire, + child.handle, + 1_073_741_823.75, + ) + const rejected = expect(disposal) + .rejects.toThrow('did not exit within its dispose window') + await vi.advanceTimersByTimeAsync(2_147_483_647) + await vi.advanceTimersByTimeAsync(1) + await rejected + } finally { + vi.useRealTimers() + } + }) + it('contains a concurrently closed stdin error', async () => { const child = fakeChild() const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 90d460c2c5..d3cbb0cf55 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -55,6 +55,47 @@ function sleepTick(): Promise { return sleepMs(15) } +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +/** + * Schedule a positive finite millisecond delay across as many Node-safe timer + * segments as necessary. Fractional milliseconds round up so a grace never + * expires earlier than configured. + * @param delayMs - positive finite delay in milliseconds. + * @param callback - work to run after the complete delay. + * @returns a handle that cancels the active segment and all future segments. + */ +export function scheduleFiniteTimeout( + delayMs: number, + callback: () => void, +): { cancel(): void } { + let remaining = BigInt(Math.ceil(delayMs)) + let timer: ReturnType | undefined + const arm = (): void => { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + timer = setTimeout(() => { + timer = undefined + if (remaining === 0n) { + callback() + } else { + arm() + } + }, Number(chunk)) + } + arm() + return { + cancel(): void { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + }, + } +} + let spillCounter = 0 let defaultSpillDir: string | undefined @@ -341,7 +382,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') const stderrCollector = collectStream(errMode, child.stderr, 'stderr') - let graceTimer: NodeJS.Timeout | undefined + let graceTimer: ReturnType | undefined let settled = false // Failed spawns use pid -1 so signalling remains a no-op. @@ -377,6 +418,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // child and must stay signalable, while a fully-dead tree (possible pid // reuse) must not be re-signalled by a later tier. const kill = (sig: NodeJS.Signals): void => { + /* v8 ignore next -- the exit monitor cancels the ordinary dead-tree timer; + this remains the timer/death race guard and cannot be staged deterministically. */ if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } @@ -390,7 +433,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // kill() re-probes tree liveness before force-killing. It stays ref'd: // the pending SIGKILL is a commitment, and a parent exiting before it // fires would orphan a trapped survivor. Self-bounds at graceMs. - graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) + const timer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) + graceTimer = timer + // A very large configured grace must not pin the parent after TERM already + // removed the whole tree. Keep the escalation armed only while its target + // remains alive; direct-child settlement alone is not sufficient. + void waitForExit().then(() => { + timer.cancel() + graceTimer = undefined + }) } // The caller owns timeout classification; this layer only reacts to abort. @@ -405,7 +456,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const done = new Promise((resolve, reject) => { - let pipeDrainTimer: NodeJS.Timeout | undefined + let pipeDrainTimer: ReturnType | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return settled = true @@ -428,13 +479,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // A surviving descendant that inherited a pipe must not hold the // outcome open indefinitely: after exit, the same bounded grace that // governs kills also bounds the close wait. - pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) + pipeDrainTimer = scheduleFiniteTimeout(spec.graceMs, () => { + settle(exitCode, signal) + }) }) child.on('close', settle) function cleanup(): void { // graceTimer deliberately NOT cleared: the SIGKILL escalation must be // able to reach tree survivors after the direct child settles. - if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) + pipeDrainTimer?.cancel() spec.signal?.removeEventListener('abort', onAbort) } }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 491756f01f..87c81116ff 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -2,7 +2,13 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts' +import { + killGroup, + OutputCollector, + scheduleFiniteTimeout, + spawnSubprocess, + taskkillProcessTree, +} from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ @@ -101,6 +107,30 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } +describe('scheduleFiniteTimeout', () => { + it('rounds fractions up, chains Node-safe segments, and cancels idempotently', async () => { + vi.useFakeTimers() + try { + const fired = vi.fn() + const chained = scheduleFiniteTimeout(2_147_483_647.25, fired) + await vi.advanceTimersByTimeAsync(2_147_483_647) + expect(fired).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(fired).toHaveBeenCalledOnce() + chained.cancel() + + const cancelled = vi.fn() + const timer = scheduleFiniteTimeout(0.25, cancelled) + timer.cancel() + timer.cancel() + await vi.advanceTimersByTimeAsync(1) + expect(cancelled).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) + describe('spawnSubprocess', () => { it('captures stdout on success', async () => { const result = await finish(spawnSubprocess(spec('echo hello'))) @@ -164,6 +194,17 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) + it('cancels a larger-than-Node escalation timer once SIGTERM removes the tree', async () => { + const running = spawnSubprocess(spec('echo ready; sleep 60', { + graceMs: Number.MAX_VALUE, + })) + await waitForStdout(running, 'ready\n') + running.terminate() + const result = await running.done + expect(result.signal).toBe('SIGTERM') + await expect(running.waitForExit()).resolves.toBe(true) + }) + it('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. From 451c2929ed3b94804da804817823aafc6f8aafcd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 20:26:45 +0800 Subject: [PATCH 09/27] fix(subagent): close terminal teardown races --- packages/subagent/subagent-acp/src/run.ts | 44 +++++++++--- .../subagent-acp/tests/subagent-acp.spec.ts | 68 ++++++++++++++++++- packages/subagent/subagent-codex/src/wire.ts | 2 +- .../tests/subagent-codex.spec.ts | 14 ++-- .../subprocess/subprocess-local/src/spawn.ts | 21 +++--- .../subprocess-local/tests/spawn.spec.ts | 16 ++++- 6 files changed, 135 insertions(+), 30 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index fba0403739..f264261b0e 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -90,15 +90,41 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ -async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { - const controller = new AbortController() - const timer = setTimeout(() => { controller.abort() }, ms) - try { - return await child.waitForExit(controller.signal) - } finally { - clearTimeout(timer) +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +function scaledFiniteMilliseconds(ms: number, scale: number): bigint { + const whole = Math.floor(ms) + return BigInt(whole) * BigInt(scale) + + BigInt(Math.ceil((ms - whole) * scale)) +} + +/** + * Bounded whole-tree exit wait across Node-safe timer segments. + * @param child - process tree whose liveness is authoritative. + * @param ms - positive finite base window in milliseconds. + * @param scale - integer multiplier applied without Number overflow. + */ +async function treeExitsWithin( + child: SubprocessHandle, + ms: number, + scale = 1, +): Promise { + let remaining = scaledFiniteMilliseconds(ms, scale) + while (remaining > 0n) { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, Number(chunk)) + try { + if (await child.waitForExit(controller.signal)) return true + } finally { + clearTimeout(timer) + } } + return false } /** @@ -125,7 +151,7 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe // (this plugin passes disposeGraceMs there), so the bound covers both the // escalation window and an equal confirmation window after the SIGKILL. child.terminate() - if (!(await treeExitsWithin(child, graceMs * 2))) { + if (!(await treeExitsWithin(child, graceMs, 2))) { throw new Error('ACP child process tree did not exit within its dispose windows') } } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index f2cbeda27b..1a8bc577c1 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' @@ -190,6 +190,72 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) }) + it('keeps an oversized finite escalation window instead of collapsing it to one millisecond', async () => { + vi.useFakeTimers() + try { + let waitCount = 0 + let reportExited!: (exited: boolean) => void + const terminate = vi.fn() + const waitForExit = vi.fn((signal?: AbortSignal) => { + waitCount += 1 + return new Promise((resolve) => { + signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) + if (waitCount === 2) reportExited = resolve + }) + }) + const child: Parameters[0] = { + pid: 1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + terminate, + waitForExit, + } + const disposal = disposeAcpChild(child, 0.25, Number.MAX_VALUE) + await vi.advanceTimersByTimeAsync(1) + expect(terminate).toHaveBeenCalledOnce() + expect(waitForExit).toHaveBeenCalledTimes(2) + const escalationSignal = waitForExit.mock.calls[1]?.[0] + await vi.advanceTimersByTimeAsync(1) + expect(escalationSignal?.aborted).toBe(false) + reportExited(true) + await expect(disposal).resolves.toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('chains a doubled grace beyond one Node timer segment', async () => { + vi.useFakeTimers() + try { + const waitForExit = vi.fn((signal?: AbortSignal) => new Promise((resolve) => { + signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) + })) + const child: Parameters[0] = { + pid: 1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + terminate: vi.fn(), + waitForExit, + } + const disposal = disposeAcpChild(child, 0.25, 1_073_741_823.75) + const rejected = expect(disposal).rejects.toThrow(/did not exit within its dispose windows/) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(2_147_483_647) + expect(waitForExit).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1) + await rejected + } finally { + vi.useRealTimers() + } + }) + it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ argv: ['bash', '-c', 'true'], diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 304c5eadb4..f933c1a04b 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -256,7 +256,7 @@ export class CodexAppServerWire { } private async guarded(pending: Promise, signal: AbortSignal): Promise { - const withFatal = Promise.race([pending, this.fatal.promise]) + const withFatal = Promise.race([this.fatal.promise, pending]) return raceAbort(withFatal, signal) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 18e28cc7cc..8e6c7ebd51 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -369,8 +369,9 @@ describe('CodexAppServerWire', () => { { type: 'text', text: 'second', text_elements: [] }, ], }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() child.peer.send( - { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, { method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, @@ -516,18 +517,17 @@ describe('CodexAppServerWire', () => { } }) - it('keeps an earlier fatal frame authoritative over later completion in the same chunk', async () => { + it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal, () => false) const turnStart = await child.peer.nextMethod('turn/start') - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - await nextTask() child.peer.send( - agentMessage('invalid', 'future_phase'), - agentMessage('late answer', 'final_answer'), + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + { id: 'future-request', method: 'future/request', params: {} }, + agentMessage('early answer', 'final_answer'), turnCompleted('completed'), ) - await expect(result).rejects.toThrow('unknown agent message phase') + await expect(result).rejects.toThrow('unsupported app-server request') wire.close() }) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index d3cbb0cf55..37e36bd213 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -383,6 +383,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: ReturnType | undefined + let terminationStarted = false let settled = false // Failed spawns use pid -1 so signalling remains a no-op. @@ -418,14 +419,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // child and must stay signalable, while a fully-dead tree (possible pid // reuse) must not be re-signalled by a later tier. const kill = (sig: NodeJS.Signals): void => { - /* v8 ignore next -- the exit monitor cancels the ordinary dead-tree timer; + /* v8 ignore next -- a successful consumer wait cancels the ordinary dead-tree timer; this remains the timer/death race guard and cannot be staged deterministically. */ if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } const terminate = (): void => { - if (graceTimer !== undefined) return // escalation already in flight + if (terminationStarted) return + terminationStarted = true if (!treeAlive()) return kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying @@ -433,15 +435,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // kill() re-probes tree liveness before force-killing. It stays ref'd: // the pending SIGKILL is a commitment, and a parent exiting before it // fires would orphan a trapped survivor. Self-bounds at graceMs. - const timer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) - graceTimer = timer - // A very large configured grace must not pin the parent after TERM already - // removed the whole tree. Keep the escalation armed only while its target - // remains alive; direct-child settlement alone is not sufficient. - void waitForExit().then(() => { - timer.cancel() - graceTimer = undefined - }) + graceTimer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) } // The caller owns timeout classification; this layer only reacts to abort. @@ -497,6 +491,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter if (signal?.aborted) return false await sleepTick() } + // Successful observation is the permanent no-more-signals boundary. It + // also cancels an escalation whose TERM tier already removed the tree. + terminationStarted = true + graceTimer?.cancel() + graceTimer = undefined return true } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 87c81116ff..f08e18c2ed 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -668,7 +668,6 @@ describe('coverage seams', () => { it('terminate() after the tree died delivers no termination signal', async () => { const running = spawnSubprocess(spec('true')) await running.done - await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { running.terminate() @@ -677,6 +676,21 @@ describe('coverage seams', () => { } finally { spy.mockRestore() } + await running.waitForExit() + }) + + it('repeated terminate after exit never probes or signals a reused process group', async () => { + const running = spawnSubprocess(spec('sleep 60')) + running.terminate() + await running.done + await running.waitForExit() + const spy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + running.terminate() + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } }) it('waitForExit on a failed spawn reports exited immediately', async () => { From 8067701b92982877005fa0c6f567f7aa6caf9300 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 20:45:12 +0800 Subject: [PATCH 10/27] fix(subprocess): stop escalation after tree exit --- .../subprocess/subprocess-local/src/spawn.ts | 53 +++++++++++++++---- .../subprocess-local/tests/spawn.spec.ts | 48 +++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 37e36bd213..a5d7f52d0c 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -384,6 +384,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter let graceTimer: ReturnType | undefined let terminationStarted = false + let treeExitObserved = false + let treeExitObservation: Promise | undefined let settled = false // Failed spawns use pid -1 so signalling remains a no-op. @@ -391,6 +393,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter /** Whether the detached tree's root (or POSIX group) is still alive. */ const treeAlive = (): boolean => { + /* v8 ignore next -- only a timer callback already queued when the observer settles can enter here; + the guard is the final defense against probing an id after its tree was confirmed absent. */ + if (treeExitObserved) return false if (pid <= 0) return false if (platform === 'win32') { // Windows has no group-liveness probe; the direct child's exit is the @@ -413,13 +418,29 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } } + /** + * Start or reuse the handle's single whole-tree exit observer. The first + * confirmed absence is a permanent no-more-signals boundary: it cancels a + * pending escalation before this process-group id can be reused. + */ + const observeTreeExit = (): Promise => { + treeExitObservation ??= (async () => { + while (treeAlive()) await sleepTick() + treeExitObserved = true + terminationStarted = true + graceTimer?.cancel() + graceTimer = undefined + })() + return treeExitObservation + } + // The escalation's tier primitive (not on the handle — terminate() is the // only consumer-facing termination verb). Guards on TREE liveness, not // outcome settlement: a TERM-trapping helper can outlive the settled direct // child and must stay signalable, while a fully-dead tree (possible pid // reuse) must not be re-signalled by a later tier. const kill = (sig: NodeJS.Signals): void => { - /* v8 ignore next -- a successful consumer wait cancels the ordinary dead-tree timer; + /* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer; this remains the timer/death race guard and cannot be staged deterministically. */ if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) @@ -428,7 +449,10 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const terminate = (): void => { if (terminationStarted) return terminationStarted = true - if (!treeAlive()) return + // Observe from the first termination tier onward, even when inherited + // pipes delay `done` and no consumer has begun its own teardown wait. + void observeTreeExit() + if (treeExitObserved) return kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying // does not mean the tree died — so settle does not clear this timer, and @@ -487,16 +511,23 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }) const waitForExit = async (signal?: AbortSignal): Promise => { - while (treeAlive()) { - if (signal?.aborted) return false - await sleepTick() + const observed = observeTreeExit() + if (treeExitObserved) return true + if (signal?.aborted) return false + if (signal === undefined) { + await observed + return true + } + const aborted = Promise.withResolvers() + const onAbort = (): void => { aborted.resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + /* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */ + if (signal.aborted) onAbort() + try { + return await Promise.race([observed.then(() => true), aborted.promise]) + } finally { + signal.removeEventListener('abort', onAbort) } - // Successful observation is the permanent no-more-signals boundary. It - // also cancels an escalation whose TERM tier already removed the tree. - terminationStarted = true - graceTimer?.cancel() - graceTimer = undefined - return true } return { diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index f08e18c2ed..ad2fc0f30a 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -205,6 +205,54 @@ describe('spawnSubprocess', () => { await expect(running.waitForExit()).resolves.toBe(true) }) + it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => { + const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`) + const graceMs = 160 + const childScript = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: ['ignore', 1, 2], + }) + writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid)) + helper.unref() + setInterval(() => {}, 1000) + ` + const running = spawnSubprocess({ + ...spec('unused', { graceMs }), + argv: [process.execPath, '-e', childScript], + }) + const helper = await waitForPidFile(pidFile) + const realKill: typeof process.kill = process.kill.bind(process) + let termAt = 0 + let forceSignals = 0 + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (target !== -running.pid) return realKill(target, signal) + if (signal === 'SIGTERM') { + termAt = Date.now() + return realKill(target, signal) + } + if (signal === 'SIGKILL') { + forceSignals += 1 + return true + } + if (signal === 0 && termAt !== 0 && Date.now() - termAt < graceMs / 2) { + throw Object.assign(new Error('simulated vanished process group'), { code: 'ESRCH' }) + } + return true // Before TERM the original group is live; later its pgid is reused. + }) + try { + running.terminate() + await running.done + expect(forceSignals).toBe(0) + } finally { + killSpy.mockRestore() + process.kill(helper, 'SIGKILL') + await waitGone(helper) + } + }) + it('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. From 74f2ab90e1235b739938f5f36a16d5788c03a9d0 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 20:55:29 +0800 Subject: [PATCH 11/27] refactor(subprocess): derive termination state --- packages/subprocess/subprocess-local/src/spawn.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index a5d7f52d0c..932daa2c59 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -383,7 +383,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: ReturnType | undefined - let terminationStarted = false let treeExitObserved = false let treeExitObservation: Promise | undefined let settled = false @@ -427,7 +426,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter treeExitObservation ??= (async () => { while (treeAlive()) await sleepTick() treeExitObserved = true - terminationStarted = true graceTimer?.cancel() graceTimer = undefined })() @@ -447,11 +445,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const terminate = (): void => { - if (terminationStarted) return - terminationStarted = true + if (treeExitObserved || graceTimer !== undefined) return // Observe from the first termination tier onward, even when inherited // pipes delay `done` and no consumer has begun its own teardown wait. void observeTreeExit() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await. if (treeExitObserved) return kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying From be56f64b7a7aef49ff83d7fea34283b096896658 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 20:57:55 +0800 Subject: [PATCH 12/27] feat(subagent): add Claude Code provider --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 2 +- .../2026-06-22-acp-subagent-backend.i18n.yaml | 4 +- .../2026-06-22-acp-subagent-backend.md | 2 +- .../2026-06-22-acp-subagent-backend.zh.md | 2 +- ...code-and-codex-subagent-backends.i18n.yaml | 6 + ...claude-code-and-codex-subagent-backends.md | 86 ++ ...ude-code-and-codex-subagent-backends.zh.md | 86 ++ ...30-generated-third-party-notices.i18n.yaml | 4 +- ...026-07-30-generated-third-party-notices.md | 10 +- ...-07-30-generated-third-party-notices.zh.md | 10 +- ...code-and-codex-subagent-backends.i18n.yaml | 6 - ...claude-code-and-codex-subagent-backends.md | 86 -- ...ude-code-and-codex-subagent-backends.zh.md | 86 -- THIRD_PARTY_NOTICES.md | 22 +- docs/capability-seams.md | 7 +- docs/config-catalog.md | 19 + docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../subagent/subagent-claude-code/cordis.yml | 40 + .../subagent/subagent-claude-code/driver.ts | 65 ++ .../subagent/subagent-claude-code/fixture.ts | 7 + examples/package.json | 1 + knip.json | 12 + packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 3 +- packages/subagent/README.zh.md | 3 +- .../subagent-claude-code/README.i18n.yaml | 6 + .../subagent/subagent-claude-code/README.md | 96 ++ .../subagent-claude-code/README.zh.md | 96 ++ .../subagent-claude-code/package.json | 53 ++ .../subagent-claude-code/src/index.ts | 95 ++ .../subagent-claude-code/src/invariant.ts | 31 + .../subagent-claude-code/src/process.ts | 159 ++++ .../subagent/subagent-claude-code/src/run.ts | 357 ++++++++ .../tests/loader-composition.e2e.ts | 72 ++ .../tests/messages-fixture.ts | 163 ++++ .../tests/real-product.spec.ts | 226 +++++ .../tests/subagent-claude-code.spec.ts | 845 ++++++++++++++++++ .../subagent-claude-code/tsconfig.json | 28 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 3 +- packages/subagent/subagent/README.zh.md | 3 +- pnpm-lock.yaml | 149 +++ scripts/gen-doc-graphs.ts | 6 +- scripts/gen-third-party-notices.spec.ts | 84 +- scripts/gen-third-party-notices.ts | 161 +++- tsconfig.host.json | 1 + vitest.config.ts | 1 + 54 files changed, 3014 insertions(+), 220 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml delete mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md delete mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts create mode 100644 packages/subagent/subagent-claude-code/README.i18n.yaml create mode 100644 packages/subagent/subagent-claude-code/README.md create mode 100644 packages/subagent/subagent-claude-code/README.zh.md create mode 100644 packages/subagent/subagent-claude-code/package.json create mode 100644 packages/subagent/subagent-claude-code/src/index.ts create mode 100644 packages/subagent/subagent-claude-code/src/invariant.ts create mode 100644 packages/subagent/subagent-claude-code/src/process.ts create mode 100644 packages/subagent/subagent-claude-code/src/run.ts create mode 100644 packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts create mode 100644 packages/subagent/subagent-claude-code/tests/messages-fixture.ts create mode 100644 packages/subagent/subagent-claude-code/tests/real-product.spec.ts create mode 100644 packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts create mode 100644 packages/subagent/subagent-claude-code/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 61640aade4..f8b82ebfbf 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: fd22b883572e5304c1587c818026c36235ef504d -2026-06-21-subagent-capability-seam.zh.md: fcfdf3e9c1eb35d7c372aa4311ebf8da192b56a7 +2026-06-21-subagent-capability-seam.md: 5b9b018df151f0d734b54cfdd4dacfd09058f7d7 +2026-06-21-subagent-capability-seam.zh.md: 49571288e35abb1369c16abd5c77a81dd3212a12 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index fd22b88357..5b9b018df1 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -14,7 +14,7 @@ The distinctive requirement — the one that shapes the whole design — is that - **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); -- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); +- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); - later: **A2A** and the **Claude Code Agent SDK** — the same out-of-process "start a child, prompt it, settle, cancel" shape; the Claude sibling remains in the product-provider proposal. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index fcfdf3e9c1..49571288e3 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -14,7 +14,7 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智 - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); -- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); +- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); - 后续:**A2A** 与 **Claude Code Agent SDK**——两者采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」;Claude 兄弟提供方仍在产品提供方提案中。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 207a8e7f6d..54ba7268df 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md -2026-06-22-acp-subagent-backend.md: ea0ec821b1f7a55d173f58c5bc4ba8829ef65c54 -2026-06-22-acp-subagent-backend.zh.md: 32e83a7a4e89eb7adb17220dc66952bca0a165aa +2026-06-22-acp-subagent-backend.md: d839ab6f75d8a518c9bc850894d1c3c5ffdbed92 +2026-06-22-acp-subagent-backend.zh.md: e9027e282bf351890643e0545b01fe00287375a6 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index ea0ec821b1..d839ab6f75 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -59,4 +59,4 @@ Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The par ## Future providers -The [Codex app-server provider](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols. +The [Codex app-server provider](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols. diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 32e83a7a4e..e9027e282b 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -59,4 +59,4 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ## 后续提供方 -[Codex app-server 提供方](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。 +[Codex app-server 提供方](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml new file mode 100644 index 0000000000..f90230b6a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +2026-08-04-claude-code-and-codex-subagent-backends.md: 923ac10573ee34746ff26d97b650edf4cddcaa9d +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c1368296f2f9c3887db74f16516fad6d0de8d620 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md new file mode 100644 index 0000000000..923ac10573 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -0,0 +1,86 @@ +# Agent Note: Claude Code and Codex subagent backends + +Status: implemented + +English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) + +## Problem + +The named [`ctx.subagents`](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. Each route must hand the product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. + +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official integration, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot prove the former; a hand-mounted plugin cannot prove the latter. + +## Decision + +The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. + +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. + +```text +fixed tool → shared subagent service → product provider → official product process + ← final answer / explicit error / cancellation ← terminal product fact + → foreground disposal → shared process-tree termination → whole-tree exit +``` + +### Ownership and lifecycle + +| Phase | Shared owner | Product-specific responsibility | Observable result | +| --- | --- | --- | --- | +| Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published | +| Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects | +| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive an existing shared stop reason; Codex uses `max-tokens` only for explicit context exhaustion | The parent receives only a final answer or an explicit failure | +| Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits | + +## Codex provider + +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. + +Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. + +`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. + +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. + +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. + +## Claude Code provider + +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. + +The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. + +The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted`. + +Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable. + +## Distribution and evidence + +Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped user configuration, verifies both fixed foreground-only tools in one context, and starts neither product process. + +The Codex evidence pins `@openai/codex@0.146.0` and `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 whole-tree exit. Production still supplies `codex` on `PATH`. + +The Claude Code evidence pins Agent SDK 0.3.220 and its platform-distributed Claude Code 2.1.220 CLI. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader e2e resolves both product packages by name while neither product command is available and records zero child starts. + +The project owner's distribution authorization is scoped to the official `@anthropic-ai/claude-agent-sdk` identity and the official Claude Code CLI/platform payloads each SDK version declares through `optionalDependencies`. [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) derives and discloses the current payload set without reclassifying its declared terms as permissive. Version, license-field, and payload-set changes still undergo ordinary dependency, lockfile, compatibility, terms, and notices review; unrelated non-permissive runtime packages continue to fail closed. + +## Alternatives considered + +**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. + +**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 without deleting either private product adapter, so each adapter calls the existing seams directly. + +**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. + +**Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. + +**Plugin-managed login, product home, models, settings, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. + +**Continuation, progress, background collection, and shared parent context.** The delivered user result is 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. + +## Consequences + +Users can install either or both product providers, bind stable foreground tools in their own Cordis configuration, and delegate one self-contained task through the existing subagent contract. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. + +Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. + +Compatibility is pinned by package-level unit coverage, real-product loopback tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product baseline change must refresh those facts; production performs no separate runtime version probe. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md new file mode 100644 index 0000000000..c1368296f2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -0,0 +1,86 @@ +# Agent Note: Claude Code 与 Codex subagent 后端 + +Status: implemented + +[English](2026-08-04-claude-code-and-codex-subagent-backends.md) | 中文 + +## 问题 + +命名的 [`ctx.subagents`](2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。每条路径都必须向产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 + +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据要区分两个事实:真实产品测试证明官方集成、原生身份验证形态、最终回答与资源清理;Loader 组合测试证明公开包(package)和文档所示的工具配置无需启动产品即可加载。直接发起模型 HTTP 请求或使用产品替身无法证明前者;手工挂载插件无法证明后者。 + +## 决策 + +harness 将两个一次性兄弟提供方作为可独立安装、选择启用的包交付。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,`subagent_claude_code` 绑定 `claude-code`。随产品交付的 CLI(命令行界面)依赖闭包,以及基础、Web 与 headless 配置都不会加载任一提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 + +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 + +```text +fixed tool → shared subagent service → product provider → official product process + ← final answer / explicit error / cancellation ← terminal product fact + → foreground disposal → shared process-tree termination → whole-tree exit +``` + +### 归属与生命周期 + +| 阶段 | 共享责任方 | 产品特定职责 | 可观察结果 | +| --- | --- | --- | --- | +| 解析 | `dsh-tool-subagent` 与 `ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 | +| 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 | +| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导出一种现有的共享停止原因;Codex 仅在明确发生上下文耗尽时使用 `max-tokens` | 父级只会收到最终回答或明确失败 | +| dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 | + +## Codex 提供方 + +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 + +发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 + +`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 + +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 + +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 + +## Claude Code 提供方 + +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 + +公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 + +只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`。 + +启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。 + +## 分发与证据 + +每个产品都负责覆盖所有分支的包测试、一项必跑的真实产品测试和一项 Loader 组合 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示形态的用户配置,在同一个上下文中验证两个固定且只支持前台执行的工具,并且不会启动任何产品进程。 + +Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 + +Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。 + +项目所有者的分发授权范围限定为官方 `@anthropic-ai/claude-agent-sdk` 身份,以及每个 SDK 版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 会推导并披露当前载荷集合,但不会将其声明条款重新归类为宽松条款。版本、许可证字段和载荷集合发生变化时,仍须经过常规的依赖、锁文件、兼容性、条款和声明评审;无关的非宽松运行时包继续以默认拒绝方式失败。 + +## 曾考虑的替代方案 + +**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展集成接口,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都改用相应的官方产品集成。 + +**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 + +**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 + +**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 + +**由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 + +**续接、进度、后台收集和共享父级上下文。** 已交付的用户结果是一项自包含任务和一个最终回答。产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集都需要独立的用户契约,当前实现不会预先构建这些功能。 + +## 后果 + +用户可以安装任一或两个产品提供方,在自己的 Cordis 配置中绑定稳定的前台工具,并通过现有 subagent 契约委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 + +每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 + +兼容性由包级单元测试覆盖率、真实产品回环测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index d65dae2802..da2fdfd101 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043 -2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561 +2026-07-30-generated-third-party-notices.md: cbabd1142ad292f78a3184723182a834dfb234fc +2026-07-30-generated-third-party-notices.zh.md: b135c3626b661f1a5b0317a90b700a26d679bf1e diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index e480954d29..cbabd1142a 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -18,7 +18,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions was tried and does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. -The file discloses **direct** dependencies only. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. +The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail. **Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). @@ -26,10 +26,14 @@ The runtime tier deliberately covers **every mountable plugin**, not just what t The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. +The project owner separately authorizes distribution of every official `@anthropic-ai/claude-agent-sdk` version and the official Claude Code CLI/platform payloads that version declares through `optionalDependencies`. The generator represents this as one exact direct-package identity exception, not as a permissive-license override: `SEE LICENSE IN README.md` and `SEE LICENSE IN LICENSE.md` remain non-permissive classifications, and every unrelated non-permissive runtime still fails closed. When the SDK is present, the generator reads its installed manifest, rejects optional identities outside the official SDK payload prefix, derives the current SDK, CLI, and payload versions, verifies the installed host payload's identity, version, and declared-license field, and renders the complete SDK-declared payload set in a separate notices section. Version, declared-license, and payload-set changes do not require new identity authorization, but they still require ordinary dependency, lockfile, compatibility, terms, and notices review. + ## Testing The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts. It also pins the parsers against the shapes that would otherwise drop a package without a word: a `vendor/README.md` table that stops covering a vendored directory, a requirement array holding extras (`"httpx[http2]"`), a requirement with no version at all, an author-named `[dependency-groups]` table, and a workspace member area absent from any hardcoded list. Each of those is a silent-omission path, which is the failure mode a disclosure file cannot afford. +The Claude distribution tests prove that only the exact direct SDK identity bypasses the ordinary non-permissive-runtime rejection, that the bypass does not change license classification, and that the payload set comes from the SDK manifest rather than a version or platform allowlist. Wrong SDK identities, missing payloads, and unrelated optional package identities all fail. + ## Alternatives considered **Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases. @@ -42,6 +46,8 @@ The same spec that asserts freshness pins the tiering rule against fixture manif **Tier by reachability from the shipped assemblies only** (`apps/*` plus `python/sdk-runtime`). This produces a tighter runtime tier, but classifies the MCP client and the OpenTelemetry exporter as development-only even though a user running the installed repository can mount them. It understates the disclosure, which is the wrong direction to err for a legal notice. +**Treat the Claude SDK terms as permissive or add a reusable non-permissive allowlist.** Either shape would misstate the upstream declaration and let an unrelated runtime inherit authorization it was never granted. The narrow exception keys only the official direct SDK identity, while its optional payload identities are accepted solely as data declared by that SDK and remain visibly non-permissive. + **Emit the notices as a bilingual pair.** Every other root document is paired, but the file is a table of upstream package names, SPDX identifiers, and URLs; the translatable surface is a handful of section blurbs. `scripts/translation-pairing.ts` scopes discovery to `README*`, `.agents/notes/**`, `docs/**`, and `python/**`, so a root non-README file is outside the bilingual corpus by construction, and the README pair carries the bilingual entry points into it. ## Consequences @@ -51,3 +57,5 @@ A dependency edit now carries a regenerated notices file into the same commit. C The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. The tiering rule is a policy encoded in one constant. Adding a workspace area that never ships — a second test-infrastructure tier, another site — requires extending `DEV_ONLY_AREAS`, or its dependencies will be disclosed as runtime. + +The Claude identity exception is deliberately narrower than the payload disclosure it activates. Upgrading the SDK needs no new owner authorization, but regeneration fails unless the installed SDK exposes its version, CLI version, and at least one official platform payload, and unless the current host payload matches the SDK declaration. Maintainers still review changed terms and compatibility; the generator prevents the authorization from silently widening to another package. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 78ba7250e7..b135c3626b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -18,7 +18,7 @@ Status: implemented 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除**清单文件不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法试过,不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 -文件只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。 +文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。 **分层依据是声明方所在区域,而非清单字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一清单——即根清单、`packages/support/`、`packages/client/test-runtime/`、`website/`、`examples/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而 `bin/dsh` 启动器 exec 经过的 `tsx`,根本没有任何清单把它声明为运行时依赖,只能由生成器显式标记。 @@ -26,10 +26,14 @@ Status: implemented 清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 +项目所有者另行授权分发每个官方 `@anthropic-ai/claude-agent-sdk` 版本,以及该版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。生成器将其表示为一项精确匹配直接包身份的例外,而非宽松许可证覆盖项:`SEE LICENSE IN README.md` 与 `SEE LICENSE IN LICENSE.md` 仍归类为非宽松,所有无关的非宽松运行时依赖仍以默认拒绝方式失败。存在该 SDK 时,生成器会读取其已安装清单,拒绝不符合官方 SDK 载荷前缀的可选包身份,推导当前 SDK、CLI 与载荷版本,核验已安装宿主载荷的身份、版本和声明许可证字段,并在单独的声明章节中渲染 SDK 声明的完整载荷集合。版本、声明许可证和载荷集合发生变化时无需新的身份授权,但仍须经过常规的依赖、锁文件、兼容性、条款和声明评审。 + ## Testing 断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它还把各解析器钉在那些原本会让某个包无声消失的形态上:不再覆盖全部收编目录的 `vendor/README.md` 表、含 extras 的依赖数组(`"httpx[http2]"`)、完全不带版本的依赖、作者自取名字的 `[dependency-groups]` 表,以及任何硬编码列表都不含的工作区成员区域。这些都是静默漏报路径——正是披露文件最担不起的失败方式。 +Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通常的非宽松运行时拒绝;该绕过不会改变许可证分类;载荷集合来自 SDK 清单,而非版本或平台允许列表。SDK 身份错误、载荷缺失或存在无关的可选包身份时,测试都会失败。 + ## Alternatives considered **保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。 @@ -42,6 +46,8 @@ Status: implemented **只按已交付装配的可达性分层**(`apps/*` 加 `python/sdk-runtime`)。这样得到的运行时层更紧凑,但会把 MCP 客户端与 OpenTelemetry 导出器判为仅开发用途——而运行已安装仓库的用户完全可以挂载它们。这会低估披露,对法务通告来说错在了更危险的一侧。 +**将 Claude SDK 条款视为宽松条款,或新增可复用的非宽松允许列表。** 两种方案都会误述上游声明,并让无关运行时依赖继承从未授予它的授权。这项窄例外只匹配官方直接 SDK 身份;其可选载荷身份仅作为该 SDK 声明的数据被接受,并继续明确归类为非宽松。 + **把披露文件做成双语对。** 其他根文档都是成对的,但这份文件是上游包名、SPDX 标识与网址构成的表格,可翻译的只有寥寥几段章节导语。`scripts/translation-pairing.ts` 的发现范围限定在 `README*`、`.agents/notes/**`、`docs/**` 与 `python/**`,根目录下的非 README 文件在构造上就不属于双语语料;双语入口由 README 对承担。 ## Consequences @@ -51,3 +57,5 @@ Status: implemented 生成器需要已安装的工作树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 分层规则是编码在一个常量里的政策。若新增了不参与交付的工作区区域——第二层测试基础设施、另一个站点——就要同步扩展 `DEV_ONLY_AREAS`,否则其依赖会被当作运行时依赖披露出去。 + +Claude 身份例外刻意比其启用的载荷披露范围更窄。升级 SDK 无需新的所有者授权,但如果已安装的 SDK 未公开自身版本、CLI 版本和至少一个官方平台载荷,或当前宿主载荷与 SDK 声明不符,重新生成就会失败。维护者仍须评审发生变化的条款与兼容性;生成器会阻止授权悄然扩大到其他包。 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml deleted file mode 100644 index bde3f3cf11..0000000000 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 3b9fd51632439da5b3c3fd9187de552d6c9ca5e2 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 36be903640ad1c839d45ed1bf5e605f4e4d6e000 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md deleted file mode 100644 index 3b9fd51632..0000000000 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ /dev/null @@ -1,86 +0,0 @@ -# Agent Note: Claude Code and Codex subagent backends - -Status: proposed - -English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) - -## Problem - -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, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. - -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official protocol, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot replace the former; a hand-mounted plugin cannot replace the latter. - -## Proposal - -The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. - -The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. - -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. - -```text -fixed tool → shared subagent service → product provider → official product process - ← final answer / explicit error / cancellation ← terminal product fact - → foreground disposal → shared process-tree termination → whole-tree exit -``` - -### Ownership and lifecycle - -| Phase | Shared owner | Product-specific responsibility | Observable result | -| --- | --- | --- | --- | -| Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published | -| Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects | -| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive an existing shared stop reason; Codex uses `max-tokens` only for explicit context exhaustion | The parent receives only a final answer or an explicit failure | -| Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits | - -## Codex provider - -`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. - -Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. - -`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. - -For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. - -An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. - -## Claude Code provider - -The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented. - -## Evidence contract - -Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. - -The Codex evidence pins `@openai/codex@0.146.0` and `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 whole-tree exit. Its Loader e2e resolves `@deepseek-ai/dsh-subagent-codex` by package name, verifies the `codex` registration and `subagent_codex` schema with background omitted, accepts `maxDepth: 'provider-managed'`, and records zero child starts while no `codex` command is available. The npm package is a development dependency for reproducible real-product evidence; production still supplies `codex` on `PATH`. - -The combined contract is complete only when the Claude sibling has equivalent real-product evidence and both public Loader configurations prove the fixed tools use the unchanged common subagent contract. - -## Alternatives considered - -**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. - -**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 the two products demonstrate a missing common contract, so each private adapter calls the existing seams directly. - -**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. - -**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 each official product against a loopback model fixture. - -**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 an explicit environment overlay and teardown grace; unattended interaction fails closed. - -**Continuation, progress, background collection, 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 - -Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required real-product specs return exact final answers or explicit failure or cancellation and prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. - -The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. - -## Risks - -- The product protocols are versioned and may change. Production performs no runtime version probe, so every supported baseline change requires refreshed 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 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 interaction denial prevents hidden approval hangs but cannot satisfy tasks that require new permission or human input. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md deleted file mode 100644 index 36be903640..0000000000 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ /dev/null @@ -1,86 +0,0 @@ -# Agent Note: Claude Code 与 Codex subagent 后端 - -Status: proposed - -[English](2026-08-04-claude-code-and-codex-subagent-backends.md) | 中文 - -## 问题 - -命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 - -产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据会分别证明两个事实:真实产品测试证明官方协议、原生身份验证形态、最终回答和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品。直接发起模型 HTTP 请求或使用产品替身无法取代前者,手工挂载插件则无法取代后者。 - -## 提案 - -harness 将两个一次性兄弟提供方发布为可独立安装的可选包。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,而 `subagent_claude_code` 绑定 `claude-code`。正式 CLI 的依赖闭包以及基础、Web 和 headless 配置都不加载这两个提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 - -Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 - -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。文档中的工具会关闭后台执行并使用 `maxDepth: 'provider-managed'`,让进程外产品自行负责递归策略,而不会向提供方发送其无法执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 - -```text -fixed tool → shared subagent service → product provider → official product process - ← final answer / explicit error / cancellation ← terminal product fact - → foreground disposal → shared process-tree termination → whole-tree exit -``` - -### 归属与生命周期 - -| 阶段 | 共享责任方 | 产品特定职责 | 可观察结果 | -| --- | --- | --- | --- | -| 解析 | `dsh-tool-subagent` 与 `ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 | -| 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 | -| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导出一种现有的共享停止原因;Codex 仅在明确发生上下文耗尽时使用 `max-tokens` | 父级只会收到最终回答或明确失败 | -| dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 | - -## Codex 提供方 - -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 - -发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 - -`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 - -对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 - -若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 - -## Claude Code 提供方 - -Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界,本 Agent Note 才能进入 implemented 状态。 - -## 证据契约 - -每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一项 Loader 装配 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。 - -Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader e2e 会按包名解析 `@deepseek-ai/dsh-subagent-codex`,验证 `codex` 注册与省略后台参数的 `subagent_codex` schema,接受 `maxDepth: 'provider-managed'`,并在环境中没有可用 `codex` 命令时记录零次子级启动。该 NPM 包是用于复现真实产品证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 - -只有在 Claude 兄弟提供方具备同等的真实产品证据,并且两个公开 Loader 配置都证明固定工具使用未变的通用 subagent 契约时,组合契约才算完整。 - -## 曾考虑的替代方案 - -**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展接入面,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都使用对应产品的官方接入方式。 - -**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。在两个产品尚未证明通用契约存在缺口时,新辅助包只会造成责任重复,因此各自的私有适配器会直接调用现有 seam。 - -**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 - -**以产品替身作为强制证据。** 替身有助于穷尽覆盖私有协议分支,但无法证明包导出、官方二进制程序、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 - -**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 - -**续接、进度、后台收集和共享父级上下文。** 首个用户结果只需要一项自包含任务和一个最终回答。产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集都需要独立的用户契约,本提案不会预先构建这些功能。 - -## 验收标准 - -两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,并证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 - -已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 - -## 风险 - -- 产品协议受版本约束,且可能发生变化。生产环境不会执行运行时版本探测,因此每次更改受支持的基线都必须刷新兼容性证据。 -- 产品原生配置使行为取决于部署环境中安装的产品与账户状态。强制测试会隔离这些输入,而生产环境会有意让产品继续负责它们。 -- 每次委派都要承担新建进程和独立模型上下文的开销,且只有最终文本会到达父级。 -- 运行失败或被取消时,产品工具或文件产生的副作用不会回滚。 -- 拒绝无人值守交互可以防止审批流程暗中挂起,但无法完成需要新权限或人工输入的任务。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5c1eb10acf..fc0600a4d4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml). @@ -32,6 +32,8 @@ External packages that a workspace package resolves at runtime. `scripts/install | Package | License | | --- | --- | | [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 | +| [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md | +| [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | | [`@babel/code-frame`](https://github.com/babel/babel) | MIT | | [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT | | [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | @@ -87,6 +89,24 @@ pnpm applies local patches to the following packages at install time, so shipped - `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch) +## Official Claude Code platform payloads + +The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. + +The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. + +| Optional platform package | Version | Declared license | +| --- | --- | --- | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | + + ## Development-only npm dependencies External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index c64e678b54..0dad00c0e5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -104,6 +104,7 @@ flowchart LR pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] pkg_subagent_codex["subagent-codex"] + pkg_subagent_claude_code["subagent-claude-code"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] @@ -225,6 +226,7 @@ flowchart LR pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents + pkg_subagent_claude_code --> svc_subagents pkg_subagent_codex --> svc_subagents pkg_subagent_dsh_sdk --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -315,6 +317,7 @@ flowchart LR svc_subprocess --> pkg_bash_sandbox svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp + svc_subprocess --> pkg_subagent_claude_code svc_subprocess --> pkg_subagent_codex svc_subprocess --> pkg_subagent_dsh_sdk svc_systemPrompt --> pkg_agent_loop @@ -376,7 +379,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, Claude Code, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | @@ -387,7 +390,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d063d793e6..08423c351d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1520,6 +1520,25 @@ export type PermissionPolicy = 'allow' | 'reject' Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) +## `@deepseek-ai/dsh-subagent-claude-code` + +Requires: `subagents` · `subprocess` + +```ts config-catalog +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for Claude Code process-tree termination. */ + disposeGraceMs?: number +} +``` + +Source: [`packages/subagent/subagent-claude-code/src/index.ts:31`](../packages/subagent/subagent-claude-code/src/index.ts) + ## `@deepseek-ai/dsh-subagent-codex` Requires: `subagents` · `subprocess` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 0582fb9dac..a875637d01 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/cookbook/extension-cookbook.md -extension-cookbook.md: 820d7fce8560028f592ec101f4f222013105f035 -extension-cookbook.zh.md: bb48f584729ad3a67fd43ca8c4c9f6f4902de656 +extension-cookbook.md: f9328220a3d2776cc6576f9478215be4f2fe51e1 +extension-cookbook.zh.md: 52b67de97b5adbf2b85a60a613f49776d60673cb diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 820d7fce85..f9328220a3 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -118,7 +118,7 @@ Every product feature maps to a listener on a documented extension seam — the | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | -| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-dsh-sdk`) + `dsh-tool-subagent` exposing one configured provider to the model | +| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-claude-code`/`-dsh-sdk`) + `dsh-tool-subagent` exposing one configured provider to the model | | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index bb48f58472..52b67de97b 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -118,7 +118,7 @@ export function apply(ctx: Context) { | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | -| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-dsh-sdk`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | +| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-claude-code`/`-dsh-sdk`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 6bd775a84c..2348c83909 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/core-data-structures/subagent.md -subagent.md: c810ae40f57a0f84f1a6b53e092d6bec88af206f -subagent.zh.md: efeec0a0f20b7ac85020df012878cf41f264073d +subagent.md: 9070c64da6cf2fa5a95074acb65f475bc9c05035 +subagent.zh.md: 9819e6f707770caf208697f977d683103d2783e1 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c810ae40f5..9070c64da6 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index efeec0a0f2..9819e6f707 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为五个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为六个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml new file mode 100644 index 0000000000..2bfcd2af3f --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -0,0 +1,40 @@ +# Test-only composition of both public opt-in providers and foreground tools. +# The owning e2e boots this tree but never invokes a model or product process. +- id: fixture + name: './fixture.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: 'provider-managed' + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'This composition test must not start a model turn.' + workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts new file mode 100644 index 0000000000..d7540e1a2d --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** Inspect both public product-provider compositions without invoking them. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-tools' + +const configPath = process.argv[2] +if (configPath === undefined) { + throw new Error('product-provider Loader composition driver requires a config path') +} + +let starts = 0 +const ctx = await boot( + 'product-provider-loader-composition', + resolveConfigPath(configPath, undefined), + undefined, + (hostCtx) => { + hostCtx.on('subagent/start', () => { + starts += 1 + }) + }, +) + +try { + const providerNames = ['codex', 'claude-code'] as const + const toolNames = ['subagent_codex', 'subagent_claude_code'] as const + const providers = providerNames.map((providerName) => { + const provider = ctx.subagents.getProvider(providerName) + if (provider === undefined) { + throw new Error(`${providerName} provider was not registered`) + } + return { + name: provider.name, + capabilities: provider.capabilities, + inheritsParentContext: provider.inheritsParentContext, + } + }) + const tools = toolNames.map((toolName) => { + const tool = ctx.tools.schemas().find(schema => schema.name === toolName) + if (tool === undefined) throw new Error(`${toolName} tool was not registered`) + const properties = tool.parameters.properties + if ( + typeof properties !== 'object' + || properties === null + || Array.isArray(properties) + ) { + throw new Error(`${toolName} has invalid parameter properties`) + } + return { + name: tool.name, + parameterNames: Object.keys(properties).sort(), + required: tool.parameters.required, + } + }) + + process.stdout.write(`${JSON.stringify({ + registeredProviders: ctx.subagents.list(), + providers, + tools, + starts, + })}\n`) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts new file mode 100644 index 0000000000..a9f9cd5997 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts @@ -0,0 +1,7 @@ +/** Reuse the composition-only parent adapter shared by the product providers. */ + +export { + apply, + inject, + name, +} from '../subagent-codex/fixture.ts' diff --git a/examples/package.json b/examples/package.json index bc697ab11c..513d0e4a2f 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:*", "@deepseek-ai/dsh-subagent-codex": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", diff --git a/knip.json b/knip.json index 1d99ae4cd4..5c0978d01c 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,8 @@ "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", + "acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts", + "acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts", "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", "acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", @@ -555,6 +557,16 @@ "@openai/codex" ] }, + "packages/subagent/subagent-claude-code": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 875a9c93a7..f3389b821f 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/README.md -README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5 -README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd +README.md: 75d90616e25f74761ac4f78c429a2e0c1aabfb38 +README.zh.md: f22a5ecb4fc5d1324a5abe5afb16f34044d5312e diff --git a/packages/subagent/README.md b/packages/subagent/README.md index abe1432d3c..75d90616e2 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -12,11 +12,12 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | | `subagent-codex/` | Out-of-process backend: a real Codex app-server process with one ephemeral thread and turn | (registers on `ctx.subagents`) | +| `subagent-claude-code/` | Out-of-process backend: the official Claude Agent SDK with one real Claude Code CLI query | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) | -The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-claude-code` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 3df2b6c62d..f22a5ecb4f 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -12,11 +12,12 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | | `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | | `subagent-codex/` | 进程外后端:一个真实的 Codex app-server 进程,包含一个临时 thread 和一个轮次 | (注册到 `ctx.subagents`) | +| `subagent-claude-code/` | 进程外后端:使用官方 Claude Agent SDK 与一次真实 Claude Code CLI query | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) | | `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) | -接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-claude-code` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml new file mode 100644 index 0000000000..ac60e83f76 --- /dev/null +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -0,0 +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 packages/subagent/subagent-claude-code/README.md +README.md: facaae300eeb8907076182a129aa216863dec8ec +README.zh.md: 75627cb54032edde07ec1ba5e21a058768515e29 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md new file mode 100644 index 0000000000..facaae300e --- /dev/null +++ b/packages/subagent/subagent-claude-code/README.md @@ -0,0 +1,96 @@ +# @deepseek-ai/dsh-subagent-claude-code + +English | [中文](README.zh.md) + +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. + +## Start and ownership + +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`. + +The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; this version produces neither `max-tokens` nor `refusal`. + +Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate. + +## Native settings and interaction + +The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. + +Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own. + +## Capabilities and context + +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | +| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | + +Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. + +Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. + +```yaml +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +## Product compatibility and evidence + +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. The real-product spec drives that official SDK and CLI against a loopback Anthropic Messages SSE service with a non-empty fake key. It proves the exact task, authentication, byte-exact answer, inherited host-setting marker, process failure, cancellation, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration alongside the Codex provider with neither product command available, verifies both fixed foreground-only tools, and records zero child starts. + +The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. + +## Model Experience + +### Child request + +#### What the model sees + +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation. + +#### Token effect + +The child pays for an independent Claude Code context and query. Child tokens do not enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Reuse depends only on Claude Code's own model, instructions, tools, native settings, and fresh query. + +### Parent tool result, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, the parent sees only the strict final Claude Code answer or the consumer's exact error for a non-completed result. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. + +#### Token effect + +Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself. + +#### KV Cache effect + +Append-only: the new tool result follows the reusable parent request prefix. + +## Known Limitations and Deferred Work + +- **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. +- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. +- **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. +- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md new file mode 100644 index 0000000000..75627cb540 --- /dev/null +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -0,0 +1,96 @@ +# @deepseek-ai/dsh-subagent-claude-code + +[English](README.md) | 中文 + +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。 + +## 启动与所有权 + +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 + +SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`;本版本不会产生 `max-tokens` 或 `refusal`。 + +本地取消会在结果竞态中胜出并映射为 `aborted`。`dispose()` 具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。 + +## 原生设置与交互 + +提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。 + +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 + +## 能力与上下文 + +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | +| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | + +生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。 + +请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 + +```yaml +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +## 产品兼容性与证据 + +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。真实产品测试使用非空伪密钥,驱动该官方 SDK 与 CLI 连接回环 Anthropic Messages SSE 服务,并证明原始任务、身份验证、逐字节一致的答案、继承的宿主设置标记、进程失败、取消与进程树退出。独立的 Loader 装配 e2e 会在两个产品命令均不可用时,与 Codex 提供方一起启动和 README 所示形态相同的用户配置,验证两个固定且只支持前台执行的工具,并记录零次子级启动。 + +项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 + +## 模型体验 + +### 子任务请求 + +#### 模型看到的内容 + +Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。 + +#### 对 token 的影响 + +子任务需为独立的 Claude Code 上下文和 query 承担 token 开销。子任务 token 不会进入父级上下文。 + +#### 对 KV Cache 的影响 + +这与父请求缓存相互独立。能否复用只取决于 Claude Code 自身的模型、指令、工具、原生设置和全新 query。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父级模型只会看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 + +#### 对 token 的影响 + +父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。 + +#### 对 KV Cache 的影响 + +仅追加:新的工具结果接在可复用的父请求前缀之后。 + +## 已知限制与后续工作 + +- **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 +- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 +- **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json new file mode 100644 index 0000000000..f3dad8d5bd --- /dev/null +++ b/packages/subagent/subagent-claude-code/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-subagent-claude-code", + "description": "One-shot Claude Code subagent provider over the official Agent SDK", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "@anthropic-ai/sdk": "0.93.0", + "@anthropic-ai/claude-agent-sdk": "0.3.220", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts new file mode 100644 index 0000000000..fe6d0a89a0 --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -0,0 +1,95 @@ +/** + * Fixed Claude Code one-shot subagent provider. Every accepted run invokes + * the official Agent SDK in the delegating Session's workspace and places + * the SDK-spawned real CLI under the shared subprocess owner. + * + * @module @deepseek-ai/dsh-subagent-claude-code + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { + assertPositiveFinite, + NO_START_CAPABILITIES, + resolveChildCwd, + type ResolvedSubagentStartRequest, + type SubagentCapabilities, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { + DEFAULT_DISPOSE_GRACE_MS, + startClaudeCodeRun, + type ClaudeCodeRunSpec, +} from './run.ts' + +export const name = 'subagent-claude-code' +export const inject = ['subagents', 'subprocess'] + +/* jscpd:ignore-start -- sibling product providers intentionally expose the + * same two deployment-owned fields without adding a shared config owner. */ +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for Claude Code process-tree termination. */ + disposeGraceMs?: number +} + +export const Config: z = z.object({ + env: z.dict(z.string()).default({}), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), +}) + +type ResolvedConfig = Required +/* jscpd:ignore-end */ + +/* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror + * the Codex sibling; each product's lifecycle remains package-private. */ +class ClaudeCodeProvider implements SubagentProvider { + readonly name = 'claude-code' + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + + constructor( + private readonly ctx: Context, + private readonly config: ResolvedConfig, + ) {} + + start(request: ResolvedSubagentStartRequest) { + const spec: ClaudeCodeRunSpec = { + cwd: resolveChildCwd( + 'subagent-claude-code', + undefined, + request.parent.session.header.cwd, + ), + env: this.config.env, + disposeGraceMs: this.config.disposeGraceMs, + spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), + onError: (error, stopReason) => { + this.ctx.logger.warn( + `subagent-claude-code: child run failed (${stopReason}): ${error.message}`, + ) + }, + } + return startClaudeCodeRun(request, spec) + } +} + +/** + * Register the fixed `claude-code` provider. + * @param ctx - context carrying shared subagent and subprocess services. + * @param config - explicit child environment and disposal grace. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite( + 'subagent-claude-code', + 'disposeGraceMs', + resolved.disposeGraceMs, + ) + ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved)) +} +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts new file mode 100644 index 0000000000..462692590f --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for + * `@deepseek-ai/dsh-subagent-claude-code`. + * @module @deepseek-ai/dsh-subagent-claude-code/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-claude-code' + +/** Cordis companion plugin name. */ +export const name = 'subagent-claude-code-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: lifecycle pairing belongs to the shared subagent + * service and process-tree ownership belongs to the subprocess service. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - plugin context carrying the invariant registry. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts new file mode 100644 index 0000000000..b8b09216fa --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -0,0 +1,159 @@ +/** + * Projection from the shared managed-process handle to the official Claude + * Agent SDK's custom-spawn process interface. + * + * @module @deepseek-ai/dsh-subagent-claude-code/process + */ + +import { EventEmitter } from 'node:events' +import type { + SpawnedProcess, + SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import type { + SubprocessHandle, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' + +function thrown(value: unknown): Error { + /* v8 ignore next -- the subprocess seam rejects with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Convert the SDK environment to the shared subprocess seam's defined-value + * overlay without changing the effective child environment. + * @param env - SDK-composed child environment. + * @returns entries whose values survive Node's subprocess environment. + */ +export function definedEnvironment( + env: SpawnOptions['env'], +): Record { + const defined: Record = {} + for (const [name, value] of Object.entries(env)) { + if (value !== undefined) defined[name] = value + } + return defined +} + +/** + * Translate one official SDK spawn request to the shared process owner. + * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. + * @param graceMs - process-tree termination grace. + * @returns the fully explicit shared subprocess request. + */ +export function claudeSpawnSpec( + options: SpawnOptions, + graceMs: number, +): SubprocessSpawnSpec { + if (options.cwd === undefined || options.cwd.length === 0) { + throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') + } + return { + argv: [options.command, ...options.args], + cwd: options.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs, + signal: options.signal, + env: definedEnvironment(options.env), + } +} + +/** + * SDK-facing view of one shared managed process. Protocol transport remains + * in the official SDK; this adapter only projects streams and exit events. + */ +export class ManagedClaudeCodeProcess implements SpawnedProcess { + readonly stdin + readonly stdout + private readonly events = new EventEmitter() + private exitCodeValue: number | null = null + private signalCodeValue: NodeJS.Signals | null = null + private killRequested = false + + /** + * Project a managed process with piped stdin and stdout. + * @param child - shared handle that remains the process-tree authority. + */ + constructor(private readonly child: SubprocessHandle) { + if (child.stdin === undefined || child.stdout === undefined) { + throw new Error('subagent-claude-code: SDK child requires piped stdin and stdout') + } + this.stdin = child.stdin + this.stdout = child.stdout + // EventEmitter gives `error` special throw semantics without a listener. + // The SDK attaches its listener synchronously after custom spawn returns, + // while this no-op also contains an already-rejected spawn handle. + this.events.on('error', () => {}) + void child.done.then( + (outcome) => { + this.exitCodeValue = outcome.exitCode + this.signalCodeValue = outcome.signal + this.events.emit('exit', outcome.exitCode, outcome.signal) + }, + (error: unknown) => { + this.events.emit('error', thrown(error)) + }, + ) + } + + /** Whether the SDK has requested managed tree termination. */ + get killed(): boolean { + return this.killRequested + } + + /** Direct-child exit code, or null while running or after signal exit. */ + get exitCode(): number | null { + return this.exitCodeValue + } + + /** Direct-child terminating signal, if any. */ + get signalCode(): NodeJS.Signals | null { + return this.signalCodeValue + } + + /** + * Route the SDK's termination request to the tree-scoped process owner. + * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. + * @returns false only after exit or a previous termination request. + */ + kill(_signal: NodeJS.Signals): boolean { + if ( + this.killRequested + || this.exitCodeValue !== null + || this.signalCodeValue !== null + ) { + return false + } + this.killRequested = true + this.child.terminate() + return true + } + + /** Register a persistent process lifecycle listener. */ + on( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.on(event, listener) + } + + /** Register a one-shot process lifecycle listener. */ + once( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.once(event, listener) + } + + /** Remove a process lifecycle listener. */ + off( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.off(event, listener) + } +} diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts new file mode 100644 index 0000000000..a65f6f5497 --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -0,0 +1,357 @@ +/** + * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its + * real CLI process under the shared subprocess owner, map only strict SDK + * success to completion, and dispose to whole-tree quiescence. + * + * @module @deepseek-ai/dsh-subagent-claude-code/run + */ + +import { randomUUID } from 'node:crypto' +import { + query as officialQuery, + type Options, + type Query, + type SDKMessage, + type SDKResultMessage, + type SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + settleRunResult, + subprocessRunHandle, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, + type SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' +import { + scrubbedParentEnv, + type SubprocessHandle, + type SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' +import { + claudeSpawnSpec, + ManagedClaudeCodeProcess, +} from './process.ts' + +/** Default POSIX grace between subprocess termination tiers. */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +/** + * Bound final exit observation at twice a positive finite grace without + * narrowing the public config to Node's single-timer integer range. + */ +function doubledGraceWindow(graceMs: number): { + readonly signal: AbortSignal + readonly cancel: () => void +} { + const whole = Math.floor(graceMs) + let remaining = BigInt(whole) * 2n + + BigInt(Math.ceil((graceMs - whole) * 2)) + const controller = new AbortController() + let timer: ReturnType | undefined + const arm = (): void => { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + timer = setTimeout(() => { + timer = undefined + if (remaining === 0n) { + controller.abort() + } else { + arm() + } + }, Number(chunk)) + } + arm() + return { + signal: controller.signal, + cancel: () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + }, + } +} + +type QueryFactory = (params: { + prompt: string + options: Options +}) => Query + +/** Fully resolved inputs for one official Claude Agent SDK query. */ +export interface ClaudeCodeRunSpec { + /** Parent Session workspace supplied to the SDK and real CLI. */ + readonly cwd: string + /** Explicit deployment/test environment layered after shared scrubbing. */ + readonly env: Record + /** Subprocess termination grace and final tree-exit bound. */ + readonly disposeGraceMs: number + /** Shared subprocess service spawn operation. */ + readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle + /** Official query entrypoint; replaced only by package-local unit tests. */ + readonly query?: QueryFactory + /** Diagnostic sink for a post-publication error flattened into a result. */ + readonly onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- SDK and subprocess failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Validate and preserve the one-shot task before crossing the SDK boundary. + * @param prompt - task content accepted from the shared subagent service. + * @returns the exact text sequence as one SDK prompt. + */ +export function textTask(prompt: readonly ContentBlock[]): string { + if (prompt.length === 0) { + throw new Error('subagent-claude-code: the one-shot task must contain only text blocks') + } + const texts: string[] = [] + for (const block of prompt) { + if (block.type !== 'text') { + throw new Error('subagent-claude-code: the one-shot task must contain only text blocks') + } + texts.push(block.text) + } + if (texts.every(text => text.trim().length === 0)) { + throw new Error('subagent-claude-code: the one-shot task must not be empty') + } + return texts.join('') +} + +/** + * Strictly derive the only SDK result that can complete a shared run. + * @param message - an official discriminated result union. + * @returns exact final text for a successful, non-error result. + */ +export function successfulResult(message: SDKResultMessage): string { + if ( + message.subtype !== 'success' + || message.is_error + || message.result.trim().length === 0 + ) { + const detail = message.subtype === 'success' + ? 'success result was marked as an error or contained no answer' + : message.errors.join('; ') || message.subtype + throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`) + } + return message.result +} + +/** + * Consume the complete SDK stream and require one strict success plus normal + * iterator completion. + * @param query - published official SDK query. + * @param setOutput - captures the candidate result for error diagnostics. + * @returns the completed shared result. + */ +export async function consumeClaudeQuery( + query: AsyncIterable, + setOutput: (output: ContentBlock[]) => void, +): Promise { + let answer: string | undefined + for await (const message of query) { + if (message.type !== 'result') continue + answer = successfulResult(message) + setOutput([{ type: 'text', text: answer }]) + } + if (answer === undefined) { + throw new Error('subagent-claude-code: Claude Code ended without a result') + } + return { + output: [{ type: 'text', text: answer }], + stopReason: 'completed', + } +} + +/** + * Close the official query, terminate the managed process tree, and wait for + * the subprocess owner to prove it is gone. + * @param query - official SDK query, when creation reached that point. + * @param child - shared-service handle that owns the CLI process tree. + * @param graceMs - termination grace used to bound final exit observation. + */ +export async function disposeClaudeCodeChild( + query: Pick | undefined, + child: SubprocessHandle, + graceMs: number, +): Promise { + const failures: Error[] = [] + let treeExited = child.pid <= 0 + try { + query?.close() + } catch (error: unknown) { + failures.push(thrown(error)) + } + + if (child.pid > 0) { + child.terminate() + const exitWindow = doubledGraceWindow(graceMs) + try { + treeExited = await child.waitForExit(exitWindow.signal) + if (!treeExited) { + failures.push(new Error( + 'subagent-claude-code: Claude Code process tree did not exit within its dispose window', + )) + } + } catch (error: unknown) { + failures.push(thrown(error)) + } finally { + exitWindow.cancel() + } + } + if (treeExited) { + try { + await child.done + } catch (error: unknown) { + failures.push(thrown(error)) + } + } else { + // The bounded tree observation owns teardown completion. Keep a later + // direct-child spawn failure observed without turning that bound into an + // unbounded wait. + void child.done.catch(() => {}) + } + + const firstFailure = failures[0] + if (failures.length === 1 && firstFailure !== undefined) throw firstFailure + if (failures.length > 1) { + throw new AggregateError( + failures, + 'subagent-claude-code: query and process cleanup failed', + ) + } +} + +/** + * Build the fixed official SDK options for one one-shot provider run. + * @param spec - workspace, environment, process seam, and disposal policy. + * @param controller - per-run cancellation owner. + * @param capture - receives the real managed child synchronously from the SDK hook. + * @returns options that inherit native settings while disabling persistence and user questions. + */ +export function claudeQueryOptions( + spec: ClaudeCodeRunSpec, + controller: AbortController, + capture: (child: SubprocessHandle) => void, +): Options { + return { + abortController: controller, + cwd: spec.cwd, + env: { ...scrubbedParentEnv(), ...spec.env }, + persistSession: false, + disallowedTools: ['AskUserQuestion'], + spawnClaudeCodeProcess: (options: SpawnOptions) => { + const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs)) + capture(child) + return new ManagedClaudeCodeProcess(child) + }, + } +} + +/** + * Start one official Claude Agent SDK query and publish its one-shot run. + * @param request - resolved shared subagent request. + * @param spec - workspace, environment, process seam, and diagnostic policy. + * @returns the published run after both Query and real CLI handle exist. + */ +export async function startClaudeCodeRun( + request: SubagentStartRequest, + spec: ClaudeCodeRunSpec, +): Promise { + const prompt = textTask(request.prompt) + if (request.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + + const controller = new AbortController() + const requestCancel = (): void => { + if (!controller.signal.aborted) { + controller.abort(new Error('subagent-claude-code: run cancelled locally')) + } + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + let child: SubprocessHandle | undefined + let query: Query | undefined + try { + query = (spec.query ?? officialQuery)({ + prompt, + options: claudeQueryOptions(spec, controller, (captured) => { + child = captured + }), + }) + if (child === undefined || child.pid <= 0) { + throw new Error( + 'subagent-claude-code: official SDK did not publish a controllable Claude Code process', + ) + } + if (controller.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + const cancelledBeforeCleanup = controller.signal.aborted + requestCancel() + if (child !== undefined) { + try { + await disposeClaudeCodeChild(query, child, spec.disposeGraceMs) + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-claude-code: startup failed and CLI cleanup also failed', + ) + } + } else if (query !== undefined) { + try { + query.close() + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-claude-code: startup failed and query cleanup also failed', + ) + } + } + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. + if (cancelledBeforeCleanup || request.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + throw thrown(error) + } + + let output: ContentBlock[] = [] + const publishedQuery = query + const publishedChild = child + const result = settleRunResult({ + attempt: () => consumeClaudeQuery(publishedQuery, (value) => { + output = value + }), + collectOutput: () => output, + cancelled: () => controller.signal.aborted, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: () => disposeClaudeCodeChild( + publishedQuery, + publishedChild, + spec.disposeGraceMs, + ), + }) +} diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..51a2ea0025 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -0,0 +1,72 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/', + import.meta.url, +)) +const driver = join(fixtureDir, 'driver.ts') +const configPath = join(fixtureDir, 'cordis.yml') +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +describe('product-provider public Loader composition', () => { + it('loads both opt-in packages and foreground tools without starting either product', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'product-provider Loader composition', + tempDirPrefix: 'dsh-product-provider-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + // Loading either optional package must not probe or start its binary. + PATH: '', + }, + }) + + expect(stderr).toBe('') + expect(JSON.parse(stdout)).toEqual({ + registeredProviders: ['codex', 'claude-code'], + providers: [ + { + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + { + name: 'claude-code', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + ], + tools: [ + { + name: 'subagent_codex', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + { + name: 'subagent_claude_code', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + ], + starts: 0, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts new file mode 100644 index 0000000000..720954f153 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -0,0 +1,163 @@ +import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'node:http' + +/** One deterministic response emitted by the package-private Messages server. */ +export type MessagesBehavior = + | { readonly kind: 'complete'; readonly text: string } + | { readonly kind: 'hold' } + +/** One recorded Anthropic Messages request. */ +interface RecordedMessagesRequest { + readonly method: string + readonly path: string + readonly headers: IncomingHttpHeaders + readonly body: Record +} + +/** Running package-private Anthropic Messages fixture. */ +export interface MessagesFixture { + readonly baseUrl: string + readonly requests: RecordedMessagesRequest[] + readonly requestStarted: Promise + close(): Promise +} + +function event( + response: ServerResponse, + type: string, + payload: Record, +): void { + response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`) +} + +function complete( + response: ServerResponse, + body: Record, + text: string, +): void { + const model = typeof body.model === 'string' ? body.model : 'fixture-model' + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + event(response, 'message_start', { + type: 'message_start', + message: { + id: 'msg_dsh_fixture', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 7, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + event(response, 'content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }) + event(response, 'content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text }, + }) + event(response, 'content_block_stop', { + type: 'content_block_stop', + index: 0, + }) + event(response, 'message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 1 }, + }) + event(response, 'message_stop', { type: 'message_stop' }) + response.end() +} + +/** + * Start a loopback-only Anthropic Messages SSE fixture. + * @param script - one behavior per Messages request. + * @returns the bound server and its recorded requests. + */ +export async function startMessagesFixture( + script: readonly MessagesBehavior[], +): Promise { + const requests: RecordedMessagesRequest[] = [] + let requestStartedResolve!: () => void + const requestStarted = new Promise((resolve) => { + requestStartedResolve = resolve + }) + let behaviorIndex = 0 + const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + request.on('end', () => { + const path = request.url ?? '' + if (!path.startsWith('/v1/messages')) { + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + type: 'error', + error: { type: 'not_found_error', message: `unexpected path ${path}` }, + })) + return + } + const text = Buffer.concat(chunks).toString('utf8') + const body = JSON.parse(text) as Record + requests.push({ + method: request.method ?? '', + path, + headers: request.headers, + body, + }) + requestStartedResolve() + const behavior = script[behaviorIndex++] + if (behavior === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + type: 'error', + error: { + type: 'api_error', + message: 'Messages fixture script was exhausted', + }, + })) + return + } + if (behavior.kind === 'complete') { + complete(response, body, behavior.text) + } + // A hold deliberately leaves the response pending until client abort. + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('Messages fixture did not bind a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + requestStarted, + async close(): Promise { + server.closeAllConnections() + await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + }) + }, + } +} diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts new file mode 100644 index 0000000000..99b0a69a20 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -0,0 +1,226 @@ +import { execFile } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as claudeCode from '../src/index.ts' +import { + startMessagesFixture, + type MessagesBehavior, + type MessagesFixture, +} from './messages-fixture.ts' + +const execFileAsync = promisify(execFile) +const sdkRoot = dirname(fileURLToPath( + import.meta.resolve('@anthropic-ai/claude-agent-sdk'), +)) +const sdkPackage = JSON.parse(readFileSync( + join(sdkRoot, 'package.json'), + 'utf8', +)) as { + version: string + claudeCodeVersion: string + optionalDependencies: Record +} +const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}` +const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!) +const claudeBin = join( + platformRoot, + process.platform === 'win32' ? 'claude.exe' : 'claude', +) +const settingsModel = 'dsh-settings-inheritance-marker' +const fakeKey = 'dsh-fake-anthropic-key' + +const roots: string[] = [] +const fixtures: MessagesFixture[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +interface RealHarness { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly parent: Agent + readonly workspace: string + readonly env: Record +} + +async function realHarness(script: readonly MessagesBehavior[]): Promise<{ + readonly harness: RealHarness + readonly fixture: MessagesFixture +}> { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-')) + roots.push(root) + const workspace = join(root, 'workspace') + const claudeConfig = join(root, 'claude-config') + const xdgConfig = join(root, 'xdg') + mkdirSync(workspace) + mkdirSync(claudeConfig) + mkdirSync(xdgConfig) + writeFileSync( + join(claudeConfig, 'settings.json'), + `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, + ) + const fixture = await startMessagesFixture(script) + fixtures.push(fixture) + const env = { + ANTHROPIC_API_KEY: fakeKey, + ANTHROPIC_BASE_URL: fixture.baseUrl, + CLAUDE_CONFIG_DIR: claudeConfig, + HOME: root, + XDG_CONFIG_HOME: xdgConfig, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1', + DISABLE_TELEMETRY: '1', + DISABLE_ERROR_REPORTING: '1', + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 }) + const parent = { + id: 'real-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + return { + harness: { ctx, handles, parent, workspace, env }, + fixture, + } +} + +async function expectQuiescent( + handles: readonly SubprocessHandle[], +): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + const outcome = await handle.done + expect(outcome).toHaveProperty('exitCode') + expect(outcome).toHaveProperty('signal') + } +} + +function startRequest( + harness: RealHarness, + prompt: string, + signal = new AbortController().signal, +) { + return harness.ctx.subagents.start('claude-code', { + prompt: [{ type: 'text', text: prompt }], + parent: harness.parent, + signal, + }) +} + +describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { + timeout: 60_000, +}, () => { + it('inherits host settings and sends the exact task and fake key to local Messages', async () => { + const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220' + const task = 'Return the fixture sentinel exactly.' + const { harness, fixture } = await realHarness([ + { kind: 'complete', text: sentinel }, + ]) + expect(sdkPackage.version).toBe('0.3.220') + expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') + expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') + const version = await execFileAsync(claudeBin, ['--version'], { + env: { ...process.env, ...harness.env }, + }) + expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') + + const run = await startRequest(harness, task) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(fixture.requests).toHaveLength(1) + const recorded = fixture.requests[0]! + expect(recorded.method).toBe('POST') + expect(recorded.path).toMatch(/^\/v1\/messages(?:\\?|$)/) + expect(recorded.headers['x-api-key']).toBe(fakeKey) + expect(recorded.body.model).toBe(settingsModel) + expect(Array.isArray(recorded.body.messages)).toBe(true) + const messageTexts = ( + recorded.body.messages as Array<{ content?: unknown }> + ).flatMap((message): unknown[] => + Array.isArray(message.content) ? message.content as unknown[] : []) + .filter((block): block is { type: string; text: string } => + typeof block === 'object' + && block !== null + && 'type' in block + && block.type === 'text' + && 'text' in block + && typeof block.text === 'string') + .map(block => block.text) + expect(messageTexts.filter(text => text.includes(task))).toEqual([task]) + await expectQuiescent(harness.handles) + }) + + it('maps a real CLI process failure to error', async () => { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const run = await startRequest(harness, 'Exercise the failure path.') + await fixture.requestStarted + expect(harness.handles).toHaveLength(1) + harness.handles[0]!.terminate() + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await run.dispose() + expect(fixture.requests).toHaveLength(1) + expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey) + await expectQuiescent(harness.handles) + }) + + it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const controller = new AbortController() + const run = await startRequest( + harness, + 'Wait for cancellation.', + controller.signal, + ) + await fixture.requestStarted + controller.abort(new Error('real product cancellation')) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await run.dispose() + await expectQuiescent(harness.handles) + }) +}) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts new file mode 100644 index 0000000000..1f02914163 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -0,0 +1,845 @@ +import { PassThrough } from 'node:stream' +import type { + Query, + SDKMessage, + SDKResultMessage, + SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { + SubprocessHandle, + SubprocessOutcome, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as claudeCode from '../src/index.ts' +import * as invariant from '../src/invariant.ts' +import { + claudeSpawnSpec, + definedEnvironment, + ManagedClaudeCodeProcess, +} from '../src/process.ts' +import { + claudeQueryOptions, + consumeClaudeQuery, + disposeClaudeCodeChild, + startClaudeCodeRun, + successfulResult, + textTask, + type ClaudeCodeRunSpec, +} from '../src/run.ts' + +const fakeParent = { + id: 'parent', + session: { header: { cwd: process.cwd() } }, +} as unknown as Agent + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }], + signal = new AbortController().signal, +) { + return { prompt, parent: fakeParent, signal } +} + +async function nextTask(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} + +interface FakeChildOptions { + readonly pid?: number + readonly stdin?: PassThrough | undefined + readonly stdout?: PassThrough | undefined + readonly exitOnTerminate?: boolean + readonly waitForExitResult?: boolean + readonly waitForExitError?: Error + readonly doneError?: Error +} + +interface FakeChild { + readonly handle: SubprocessHandle + readonly stdin: PassThrough + readonly stdout: PassThrough + readonly settle: (outcome?: SubprocessOutcome) => void + readonly fail: (error: Error) => void + readonly terminate: Mock + readonly waitForExit: Mock +} + +function fakeChild(options: FakeChildOptions = {}): FakeChild { + const stdin = new PassThrough() + const stdout = new PassThrough() + let exited = false + let resolveDone!: (outcome: SubprocessOutcome) => void + let rejectDone!: (error: Error) => void + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + // Individual tests deliberately exercise rejected and still-pending handles. + void done.catch(() => {}) + const settle = ( + outcome: SubprocessOutcome = { exitCode: 0, signal: null }, + ): void => { + if (exited) return + exited = true + resolveDone(outcome) + } + const fail = (error: Error): void => { + if (exited) return + exited = true + rejectDone(error) + } + if (options.doneError !== undefined) fail(options.doneError) + const terminate = vi.fn(() => { + if (options.exitOnTerminate !== false) settle() + }) + const waitForExit = vi.fn(async (signal?: AbortSignal): Promise => { + if (options.waitForExitError !== undefined) { + throw options.waitForExitError + } + if (options.waitForExitResult !== undefined) { + return options.waitForExitResult + } + if (exited) return true + if (signal === undefined) { + await done.catch(() => {}) + return true + } + return await new Promise((resolve) => { + const onAbort = (): void => { resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + void done.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + ) + }) + }) + const handle: SubprocessHandle = { + pid: options.pid ?? 1234, + stdin: options.stdin === undefined ? stdin : options.stdin, + stdout: options.stdout === undefined ? stdout : options.stdout, + stderr: undefined, + collected: {}, + done, + terminate, + waitForExit, + } + return { + handle, + stdin, + stdout, + settle, + fail, + terminate, + waitForExit, + } +} + +function success( + result = 'answer', + isError = false, +): SDKResultMessage { + return { + type: 'result', + subtype: 'success', + is_error: isError, + result, + } as SDKResultMessage +} + +type ErrorSubtype = Exclude + +function failure( + subtype: ErrorSubtype, + errors: string[] = ['fixture failure'], +): SDKResultMessage { + return { + type: 'result', + subtype, + is_error: true, + errors, + } as SDKResultMessage +} + +function queryFrom( + messages: readonly SDKMessage[], + after?: Error, + close = vi.fn(), +): Query { + async function* stream(): AsyncGenerator { + for (const message of messages) yield message + if (after !== undefined) throw after + } + return Object.assign(stream(), { close }) as unknown as Query +} + +function waitingQuery(signal: AbortSignal, close = vi.fn()): Query { + async function* stream(): AsyncGenerator { + await new Promise((_resolve, reject) => { + const fail = (): void => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) + } + if (signal.aborted) fail() + else signal.addEventListener('abort', fail, { once: true }) + }) + } + return Object.assign(stream(), { close }) as unknown as Query +} + +function sdkSpawnOptions( + overrides: Partial = {}, +): SpawnOptions { + return { + command: '/sdk/claude', + args: ['--output-format', 'stream-json'], + cwd: '/workspace', + env: { PATH: '/bin', OMITTED: undefined }, + signal: new AbortController().signal, + ...overrides, + } +} + +interface FakeRun { + readonly child: FakeChild + readonly query: Query + readonly close: ReturnType + readonly spawnSpecs: SubprocessSpawnSpec[] + readonly options: Array>[0]['options']> + readonly spec: ClaudeCodeRunSpec +} + +function fakeRun( + messages: readonly SDKMessage[] = [success()], + after?: Error, + child = fakeChild(), +): FakeRun { + const close = vi.fn() + const query = queryFrom(messages, after, close) + const spawnSpecs: SubprocessSpawnSpec[] = [] + const options: FakeRun['options'] = [] + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: { ANTHROPIC_API_KEY: 'fake-key' }, + disposeGraceMs: 5, + spawn: (spawnSpec) => { + spawnSpecs.push(spawnSpec) + return child.handle + }, + query: (params) => { + options.push(params.options) + params.options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return query + }, + } + return { child, query, close, spawnSpecs, options, spec } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +describe('task admission and package contracts', () => { + it('preserves text sequences and rejects empty, blank, and non-text tasks', () => { + expect(textTask([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ])).toBe('onetwo') + expect(() => textTask([])).toThrow('only text blocks') + expect(() => textTask([{ type: 'reasoning', text: 'hidden' }])) + .toThrow('only text blocks') + expect(() => textTask([{ type: 'text', text: ' \n ' }])) + .toThrow('must not be empty') + }) + + it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const fiber = await ctx.plugin(claudeCode, {}) + expect(ctx.subagents.getProvider('claude-code')).toMatchObject({ + name: 'claude-code', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['claude-code']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + + for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(ctx.plugin(claudeCode, { disposeGraceMs })) + .rejects.toThrow('disposeGraceMs must be a positive finite number') + } + await ctx.fiber.dispose() + }) + + it('starts through the registered provider with its resolved config and diagnostics', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const child = fakeChild() + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + .mockImplementation(() => child.handle) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + await ctx.plugin(claudeCode, { + env: { + ANTHROPIC_API_KEY: 'provider-fake-key', + CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config', + HOME: '/private/tmp/dsh-claude-code-unit-home', + }, + disposeGraceMs: 29, + }) + + const run = await ctx.subagents.start('claude-code', request()) + child.settle({ exitCode: 9, signal: null }) + child.stdout.end() + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'subagent-claude-code: child run failed (error):', + )) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + cwd: process.cwd(), + graceMs: 29, + })) + expect(spawn.mock.calls[0]?.[0].env).toMatchObject({ + ANTHROPIC_API_KEY: 'provider-fake-key', + }) + await run.dispose() + await ctx.fiber.dispose() + }) + + it('keeps the Loader namespace shape and package-owned empty invariant', async () => { + expect('default' in claudeCode).toBe(false) + expect(claudeCode.name).toBe('subagent-claude-code') + expect(claudeCode.inject).toEqual(['subagents', 'subprocess']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(claudeCode)).toBe(claudeCode) + + const dispose = vi.fn() + const register = vi.fn(( + _packageName: string, + _installer: InvariantInstaller, + ) => dispose) + const ctx = { invariants: { register } } as unknown as Context + await expect(invariant.apply(ctx)).resolves.toBe(dispose) + expect(register).toHaveBeenCalledWith( + '@deepseek-ai/dsh-subagent-claude-code', + expect.any(Function), + ) + const install = register.mock.calls[0]![1] + await install(new Context(), (message) => { throw new Error(message) }) + expect(invariant.name).toBe('subagent-claude-code-invariant') + expect(invariant.inject).toEqual(['invariants']) + }) +}) + +describe('official spawn projection', () => { + it('forwards command, arguments, cwd, environment, and signal exactly', () => { + const signal = new AbortController().signal + const options = sdkSpawnOptions({ + command: '/official/claude', + args: ['--one', 'two'], + cwd: '/parent/workspace', + env: { A: 'one', B: undefined, C: 'three' }, + signal, + }) + expect(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' }) + expect(claudeSpawnSpec(options, 321)).toEqual({ + argv: ['/official/claude', '--one', 'two'], + cwd: '/parent/workspace', + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: 321, + signal, + env: { A: 'one', C: 'three' }, + }) + const missingCwd = sdkSpawnOptions() + delete missingCwd.cwd + expect(() => claudeSpawnSpec( + missingCwd, + 321, + )).toThrow('SDK spawn request omitted its workspace') + expect(() => claudeSpawnSpec( + sdkSpawnOptions({ cwd: '' }), + 321, + )).toThrow('SDK spawn request omitted its workspace') + }) + + it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const process = new ManagedClaudeCodeProcess(child.handle) + expect(process.stdin).toBe(child.stdin) + expect(process.stdout).toBe(child.stdout) + expect(process.killed).toBe(false) + expect(process.exitCode).toBeNull() + expect(process.signalCode).toBeNull() + + const exit = vi.fn() + const once = vi.fn() + const removed = vi.fn() + process.on('exit', exit) + process.once('exit', once) + process.on('exit', removed) + process.off('exit', removed) + expect(process.kill('SIGTERM')).toBe(true) + expect(process.killed).toBe(true) + expect(process.kill('SIGKILL')).toBe(false) + expect(child.terminate).toHaveBeenCalledOnce() + + child.settle({ exitCode: null, signal: 'SIGTERM' }) + await nextTask() + expect(exit).toHaveBeenCalledWith(null, 'SIGTERM') + expect(once).toHaveBeenCalledOnce() + expect(removed).not.toHaveBeenCalled() + expect(process.signalCode).toBe('SIGTERM') + expect(process.kill('SIGTERM')).toBe(false) + }) + + it('emits spawn errors and rejects handles without the required pipes', async () => { + const child = fakeChild() + const process = new ManagedClaudeCodeProcess(child.handle) + const errorListener = vi.fn() + const removed = vi.fn() + process.once('error', errorListener) + process.on('error', removed) + process.off('error', removed) + child.fail(new Error('spawn boom')) + await nextTask() + expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({ + message: 'spawn boom', + })) + expect(removed).not.toHaveBeenCalled() + + const missingStdin = fakeChild({ stdin: undefined }) + Object.defineProperty(missingStdin.handle, 'stdin', { value: undefined }) + expect(() => new ManagedClaudeCodeProcess(missingStdin.handle)) + .toThrow('requires piped stdin and stdout') + const missingStdout = fakeChild({ stdout: undefined }) + Object.defineProperty(missingStdout.handle, 'stdout', { value: undefined }) + expect(() => new ManagedClaudeCodeProcess(missingStdout.handle)) + .toThrow('requires piped stdin and stdout') + }) + + it('exposes a settled direct-child exit code', async () => { + const child = fakeChild() + const process = new ManagedClaudeCodeProcess(child.handle) + child.settle({ exitCode: 7, signal: null }) + await nextTask() + expect(process.exitCode).toBe(7) + expect(process.signalCode).toBeNull() + expect(process.kill('SIGTERM')).toBe(false) + }) +}) + +describe('query options and result mapping', () => { + it('builds the fixed unattended options over the scrubbed environment', () => { + vi.stubEnv('HOST_VISIBLE', 'visible') + vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak') + vi.stubEnv('DSH_INTERNAL', 'must-not-leak') + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + const captured: SubprocessHandle[] = [] + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: { + HOST_VISIBLE: 'overridden', + ANTHROPIC_API_KEY: 'explicit-fake-key', + }, + disposeGraceMs: 17, + spawn, + } + const controller = new AbortController() + const options = claudeQueryOptions(spec, controller, (value) => { + captured.push(value) + }) + + expect(options).toMatchObject({ + abortController: controller, + cwd: '/workspace', + persistSession: false, + disallowedTools: ['AskUserQuestion'], + }) + expect(options.env).toMatchObject({ + HOST_VISIBLE: 'overridden', + ANTHROPIC_API_KEY: 'explicit-fake-key', + }) + expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN') + expect(options.env).not.toHaveProperty('DSH_INTERNAL') + for (const omitted of [ + 'settingSources', + 'canUseTool', + 'onElicitation', + 'onUserDialog', + 'supportedDialogKinds', + ]) { + expect(options).not.toHaveProperty(omitted) + } + + const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess) + expect(captured).toEqual([child.handle]) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + argv: ['/sdk/claude', '--output-format', 'stream-json'], + cwd: '/workspace', + graceMs: 17, + })) + }) + + it('accepts only a non-error success with a non-blank final result', () => { + expect(successfulResult(success('exact final'))).toBe('exact final') + expect(() => successfulResult(success('answer', true))) + .toThrow('marked as an error') + expect(() => successfulResult(success(' \n '))) + .toThrow('contained no answer') + expect(() => successfulResult(failure( + 'error_during_execution', + ['first', 'second'], + ))).toThrow('first; second') + expect(() => successfulResult(failure( + 'error_max_turns', + [], + ))).toThrow('error_max_turns') + }) + + it('consumes the complete stream and keeps the latest strict success', async () => { + const outputs: ContentBlock[][] = [] + const query = queryFrom([ + { type: 'system', subtype: 'init' } as SDKMessage, + success('first'), + success('last'), + ]) + await expect(consumeClaudeQuery(query, (output) => { + outputs.push(output) + })).resolves.toEqual({ + output: [{ type: 'text', text: 'last' }], + stopReason: 'completed', + }) + expect(outputs).toEqual([ + [{ type: 'text', text: 'first' }], + [{ type: 'text', text: 'last' }], + ]) + await expect(consumeClaudeQuery( + queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), + () => {}, + )).rejects.toThrow('ended without a result') + }) +}) + +describe('run publication, cancellation, and settlement', () => { + it('publishes only after Query and managed child exist, then disposes once', async () => { + const fixture = fakeRun([success('exact answer')]) + const run = await startClaudeCodeRun( + request([ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' }, + ]), + fixture.spec, + ) + expect(fixture.options).toHaveLength(1) + expect(fixture.spawnSpecs).toHaveLength(1) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'exact answer' }], + stopReason: 'completed', + }) + const first = run.dispose() + const second = run.dispose() + expect(second).toBe(first) + await first + expect(fixture.close).toHaveBeenCalledOnce() + expect(fixture.child.terminate).toHaveBeenCalledOnce() + }) + + it('flattens every SDK error result without inventing shared stop reasons', async () => { + const subtypes: ErrorSubtype[] = [ + 'error_during_execution', + 'error_max_turns', + 'error_max_budget_usd', + 'error_max_structured_output_retries', + ] + for (const subtype of subtypes) { + const fixture = fakeRun([failure(subtype)]) + const onError = vi.fn() + const run = await startClaudeCodeRun( + request(), + { ...fixture.spec, onError }, + ) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + 'error', + ) + await run.dispose() + } + }) + + it('preserves candidate output when iteration fails after a result', async () => { + const fixture = fakeRun( + [success('partial final')], + new Error('iterator boom'), + ) + const run = await startClaudeCodeRun(request(), fixture.spec) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial final' }], + stopReason: 'error', + }) + await run.dispose() + }) + + it('maps invalid success and missing result to error', async () => { + for (const messages of [ + [success('answer', true)], + [success('')], + [{ type: 'system', subtype: 'init' } as SDKMessage], + ]) { + const fixture = fakeRun(messages) + const run = await startClaudeCodeRun(request(), fixture.spec) + await expect(run.result).resolves.toMatchObject({ + stopReason: 'error', + }) + await run.dispose() + } + }) + + it('gives local cancellation precedence and isolates overlapping controllers', async () => { + const firstChild = fakeChild() + const secondChild = fakeChild() + const children = [firstChild, secondChild] + const controllers: AbortController[] = [] + let index = 0 + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: {}, + disposeGraceMs: 5, + spawn: () => children[index++]!.handle, + query: ({ prompt, options }) => { + controllers.push(options.abortController!) + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return prompt === 'wait' + ? waitingQuery(options.abortController!.signal) + : queryFrom([success('second answer')]) + }, + } + const firstAbort = new AbortController() + const first = await startClaudeCodeRun( + request([{ type: 'text', text: 'wait' }], firstAbort.signal), + spec, + ) + const second = await startClaudeCodeRun( + request([{ type: 'text', text: 'finish' }]), + spec, + ) + expect(controllers).toHaveLength(2) + expect(controllers[0]).not.toBe(controllers[1]) + firstAbort.abort(new Error('parent cancelled')) + await expect(first.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await expect(second.result).resolves.toEqual({ + output: [{ type: 'text', text: 'second answer' }], + stopReason: 'completed', + }) + expect(controllers[1]!.signal.aborted).toBe(false) + await Promise.all([first.dispose(), second.dispose()]) + }) + + it('rejects pre-abort and every incomplete startup transaction', async () => { + const preAborted = new AbortController() + preAborted.abort() + const unused = fakeRun() + await expect(startClaudeCodeRun( + request(undefined, preAborted.signal), + unused.spec, + )).rejects.toThrow('aborted before SDK startup') + expect(unused.options).toEqual([]) + + const noChildClose = vi.fn() + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + query: () => queryFrom([], undefined, noChildClose), + })).rejects.toThrow('did not publish a controllable') + expect(noChildClose).toHaveBeenCalledOnce() + + const closeFailure = vi.fn(() => { throw new Error('close boom') }) + const noChild = startClaudeCodeRun(request(), { + ...unused.spec, + query: () => queryFrom([], undefined, closeFailure), + }) + await expect(noChild).rejects.toBeInstanceOf(AggregateError) + + const startupAbort = new AbortController() + const abortedChild = fakeChild() + const abortedClose = vi.fn() + const abortedDuringStartup = startClaudeCodeRun( + request(undefined, startupAbort.signal), + { + ...unused.spec, + spawn: () => abortedChild.handle, + query: ({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + startupAbort.abort(new Error('startup cancelled')) + return queryFrom([], undefined, abortedClose) + }, + }, + ) + await expect(abortedDuringStartup) + .rejects.toThrow('aborted before SDK startup') + expect(abortedClose).toHaveBeenCalledOnce() + expect(abortedChild.terminate).toHaveBeenCalledOnce() + + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + query: () => { + throw new Error('query failed before resource creation') + }, + })).rejects.toThrow('query failed before resource creation') + + const spawned = fakeChild() + const spawnSpecs: SubprocessSpawnSpec[] = [] + let factoryController: AbortController | undefined + const factoryFailure = startClaudeCodeRun(request(), { + ...unused.spec, + spawn: (spawnSpec) => { + spawnSpecs.push(spawnSpec) + return spawned.handle + }, + query: ({ options }) => { + factoryController = options.abortController + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw new Error('query construction failed') + }, + }) + await expect(factoryFailure).rejects.toThrow('query construction failed') + expect(spawnSpecs).toHaveLength(1) + expect(factoryController?.signal.aborted).toBe(true) + expect(spawned.terminate).toHaveBeenCalledOnce() + + const failedSpawn = fakeChild({ + pid: -1, + doneError: new Error('spawn failed'), + }) + const failed = fakeRun([], undefined, failedSpawn) + await expect(startClaudeCodeRun(request(), failed.spec)) + .rejects.toBeInstanceOf(AggregateError) + expect(failed.close).toHaveBeenCalledOnce() + }) +}) + +describe('bounded query and process disposal', () => { + it('closes the query, terminates the tree, and waits for direct-child outcome', async () => { + const child = fakeChild() + const close = vi.fn() + await disposeClaudeCodeChild({ close }, child.handle, 5) + expect(close).toHaveBeenCalledOnce() + expect(child.terminate).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledOnce() + await expect(child.handle.done).resolves.toEqual({ + exitCode: 0, + signal: null, + }) + }) + + it('accepts fractional and larger-than-Node grace windows', async () => { + for (const graceMs of [0.25, Number.MAX_VALUE]) { + const child = fakeChild() + await expect(disposeClaudeCodeChild( + { close: vi.fn() }, + child.handle, + graceMs, + )).resolves.toBeUndefined() + const signal = child.waitForExit.mock.calls[0]?.[0] + expect(signal?.aborted).toBe(false) + } + }) + + it('chains a doubled grace window beyond one Node timer segment', async () => { + vi.useFakeTimers() + try { + const child = fakeChild({ exitOnTerminate: false }) + const disposal = disposeClaudeCodeChild( + { close: vi.fn() }, + child.handle, + 1_073_741_823.75, + ) + const rejected = expect(disposal) + .rejects.toThrow('did not exit within its dispose window') + await vi.advanceTimersByTimeAsync(2_147_483_647) + await vi.advanceTimersByTimeAsync(1) + await rejected + } finally { + vi.useRealTimers() + } + }) + + it('does not turn a missed tree-exit bound into an unbounded done wait', async () => { + const child = fakeChild({ + exitOnTerminate: false, + waitForExitResult: false, + }) + await expect(disposeClaudeCodeChild( + { close: vi.fn() }, + child.handle, + 5, + )).rejects.toThrow('did not exit within its dispose window') + child.fail(new Error('late direct-child failure')) + await nextTask() + }) + + it('reports wait, close, and direct-child failures without skipping cleanup', async () => { + const waitFailure = fakeChild({ + exitOnTerminate: false, + waitForExitError: new Error('wait boom'), + }) + const closeFailure = vi.fn(() => { throw new Error('close boom') }) + await expect(disposeClaudeCodeChild( + { close: closeFailure }, + waitFailure.handle, + 5, + )).rejects.toBeInstanceOf(AggregateError) + expect(waitFailure.terminate).toHaveBeenCalledOnce() + + const doneFailure = fakeChild({ + pid: -1, + doneError: new Error('spawn boom'), + }) + await expect(disposeClaudeCodeChild( + { close: vi.fn() }, + doneFailure.handle, + 5, + )).rejects.toThrow('spawn boom') + + const both = fakeChild({ + pid: -1, + doneError: new Error('spawn boom'), + }) + await expect(disposeClaudeCodeChild( + { close: () => { throw new Error('close boom') } }, + both.handle, + 5, + )).rejects.toBeInstanceOf(AggregateError) + }) +}) diff --git a/packages/subagent/subagent-claude-code/tsconfig.json b/packages/subagent/subagent-claude-code/tsconfig.json new file mode 100644 index 0000000000..72e5f73fec --- /dev/null +++ b/packages/subagent/subagent-claude-code/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/types/.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 98763ddb6e..3c3632c160 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent/README.md -README.md: 4682b06ae105a0ae70ea7e78a80776ac18d817e7 -README.zh.md: c39afb26d8c6baf4774ae3b4a8f8151529a29e15 +README.md: a388f5a57fd32768dc9b66e3637ff53bf6479149 +README.zh.md: 48e5694e82ee0269661fbb5ede75cf995cbc00aa diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4682b06ae1..a388f5a57f 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -15,6 +15,7 @@ The family separates the stable interface from implementations and model-facing | `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | | `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). | +| `@deepseek-ai/dsh-subagent-claude-code` | Fresh official Claude Agent SDK query with a real managed Claude Code CLI child (one-shot). | | `@deepseek-ai/dsh-subagent-dsh-sdk` | Fresh out-of-process harness child driven through the TypeScript SDK client (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | @@ -64,7 +65,7 @@ The seam owns the versioned `subagent/descriptor` session event vocabulary (`src The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level. -`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority. +`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority. ## One-shot ownership and lifecycle diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index c39afb26d8..48e5694e82 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -15,6 +15,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | | `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 | +| `@deepseek-ai/dsh-subagent-claude-code` | 通过官方 Claude Agent SDK 启动的全新 query,带有一个真实且受管的 Claude Code CLI 子进程(一次性)。 | | `@deepseek-ai/dsh-subagent-dsh-sdk` | 通过 TypeScript SDK 客户端驱动的全新进程外 harness 子 agent(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | @@ -64,7 +65,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。 -`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 +`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 ## 一次性所有权与生命周期 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 352a8a2abf..8bb0991739 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:* + version: link:../packages/subagent/subagent-claude-code '@deepseek-ai/dsh-subagent-codex': specifier: workspace:* version: link:../packages/subagent/subagent-codex @@ -4951,6 +4954,46 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/subagent-claude-code: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.220 + version: 0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + '@anthropic-ai/sdk': + specifier: 0.93.0 + version: 0.93.0(zod@4.4.3) + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subagent/subagent-codex: dependencies: schemastery: @@ -6824,6 +6867,58 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220': + resolution: {integrity: sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220': + resolution: {integrity: sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220': + resolution: {integrity: sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220': + resolution: {integrity: sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220': + resolution: {integrity: sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220': + resolution: {integrity: sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220': + resolution: {integrity: sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220': + resolution: {integrity: sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.220': + resolution: {integrity: sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -6833,6 +6928,15 @@ packages: zod: optional: true + '@anthropic-ai/sdk@0.93.0': + resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -12234,12 +12338,57 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.220 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.220 + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: zod: 4.4.3 + '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 7c3c8261fe..d579059f27 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], - note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'], + note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, Claude Code, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', @@ -416,7 +416,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider and continuation service', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index f31cca6879..e6e199c21a 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -2,7 +2,20 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps, virtualManifest } from './gen-third-party-notices.ts' +import { + CLAUDE_AGENT_SDK_PACKAGE, + claudeDistributionFromManifest, + collectPythonDependencies, + isOwnerAuthorizedRuntime, + isPermissive, + type Manifest, + manifestPatterns, + parsePyprojectRequirements, + parseVendoredRows, + render, + tierExternalDeps, + virtualManifest, +} from './gen-third-party-notices.ts' const root = resolve(import.meta.dirname, '..') @@ -223,7 +236,14 @@ describe('collectPythonDependencies', () => { describe('isPermissive', () => { it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => { expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true) - expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false) + expect([ + 'LGPL-3.0-only', + 'MPL-2.0', + 'GPL-3.0-or-later', + 'SEE LICENSE IN LICENSE', + 'SEE LICENSE IN README.md', + 'SEE LICENSE IN LICENSE.md', + ].some(isPermissive)).toBe(false) }) it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => { @@ -245,6 +265,66 @@ describe('isPermissive', () => { }) }) +describe('official Claude distribution authorization', () => { + it('authorizes only the direct SDK identity without relabeling its license', () => { + expect(isOwnerAuthorizedRuntime(CLAUDE_AGENT_SDK_PACKAGE)).toBe(true) + expect(isOwnerAuthorizedRuntime(`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`)) + .toBe(false) + expect(isOwnerAuthorizedRuntime('@anthropic-ai/unrelated')).toBe(false) + expect(isPermissive('SEE LICENSE IN README.md')).toBe(false) + }) + + it('derives version-independent platform payloads from the official SDK manifest', () => { + expect(claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '9.8.7', + license: 'future declared terms', + claudeCodeVersion: '6.5.4', + optionalDependencies: { + [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '9.8.7', + [`${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`]: '9.8.7', + }, + })).toEqual({ + sdkVersion: '9.8.7', + claudeCodeVersion: '6.5.4', + payloads: [ + { + name: `${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`, + version: '9.8.7', + }, + { + name: `${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`, + version: '9.8.7', + }, + ], + }) + }) + + it('rejects a wrong SDK identity, missing payloads, and unrelated optionals', () => { + expect(() => claudeDistributionFromManifest({ + name: '@anthropic-ai/unrelated', + version: '1.0.0', + claudeCodeVersion: '1.0.0', + optionalDependencies: { + [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '1.0.0', + }, + })).toThrow(`expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest`) + expect(() => claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '1.0.0', + claudeCodeVersion: '1.0.0', + })).toThrow('declares no optional platform payloads') + expect(() => claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '1.0.0', + claudeCodeVersion: '1.0.0', + optionalDependencies: { + '@anthropic-ai/unrelated': '1.0.0', + }, + })).toThrow('outside its authorized platform-payload identity') + }) +}) + describe('manifestPatterns', () => { it('derives globs from the declared members, so a new member area is read', () => { expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([ diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 6b790829d5..3e2e0917f2 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -49,6 +49,21 @@ const FIRST_PARTY = new Set([ 'node-addon-landlock-run-linux-x64', ]) +/** Official SDK identity covered by the project's narrow owner authorization. */ +export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' +const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-` +const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md' + +/** + * Whether a non-permissive runtime declaration has an identity-scoped owner + * authorization. This does not reclassify its terms as permissive. + * @param name - exact npm package identity. + * @returns true only for the official Claude Agent SDK package. + */ +export function isOwnerAuthorizedRuntime(name: string): boolean { + return name === CLAUDE_AGENT_SDK_PACKAGE +} + /** * Metadata overrides where the installed manifest is wrong or unreachable. * Each entry documents why the store cannot answer. @@ -92,6 +107,7 @@ const BUILD_TIME_TOOLS = [ /** The `package.json` fields this generator reads. */ export interface Manifest { name?: string + version?: string private?: boolean license?: string dependencies?: Record @@ -164,7 +180,74 @@ function loadWorkspaceManifests(): { manifests: Map; names: Se return { manifests, names } } -type VirtualManifest = Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string } +type VirtualManifest = Manifest & { + claudeCodeVersion?: string + license?: string + repository?: string | { url?: string } + homepage?: string +} + +/** One platform payload declared by the official Claude Agent SDK. */ +export interface ClaudePlatformPayload { + readonly name: string + readonly version: string +} + +/** Current SDK and CLI distribution facts derived from the installed SDK manifest. */ +export interface ClaudeDistribution { + readonly sdkVersion: string + readonly claudeCodeVersion: string + readonly payloads: ClaudePlatformPayload[] +} + +function requiredManifestString( + value: string | undefined, + field: string, +): string { + if (value === undefined || value.length === 0) { + throw new Error(`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} has no ${field}.`) + } + return value +} + +/** + * Derive the official platform payload set without a version or platform + * allowlist. Only identities in the SDK's own package namespace are covered. + * @param manifest - installed official SDK manifest. + * @returns current SDK, CLI, and optional platform payload facts. + */ +export function claudeDistributionFromManifest( + manifest: VirtualManifest, +): ClaudeDistribution { + if (manifest.name !== CLAUDE_AGENT_SDK_PACKAGE) { + throw new Error( + `gen-third-party-notices: expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest, got ${JSON.stringify(manifest.name)}.`, + ) + } + const sdkVersion = requiredManifestString(manifest.version, 'version') + const claudeCodeVersion = requiredManifestString( + manifest.claudeCodeVersion, + 'claudeCodeVersion', + ) + const entries = Object.entries(manifest.optionalDependencies ?? {}) + if (entries.length === 0) { + throw new Error( + `gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} declares no optional platform payloads.`, + ) + } + const payloads = entries.map(([name, version]) => { + if (!name.startsWith(CLAUDE_PLATFORM_PACKAGE_PREFIX)) { + throw new Error( + `gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} optional dependency ${name} is outside its authorized platform-payload identity.`, + ) + } + return { + name, + version: requiredManifestString(version, `${name} optional dependency version`), + } + }).sort((left, right) => left.name.localeCompare(right.name)) + return { sdkVersion, claudeCodeVersion, payloads } +} /** * Resolve one package's manifest inside a pnpm virtual store. The prefix scan @@ -193,9 +276,8 @@ export function virtualManifest(virtual: string, name: string): VirtualManifest return undefined } -/** License and repository URL for an installed external package, from the pnpm store. */ -function installedMetadata(name: string): { license: string; repo: string } { - const override = OVERRIDES[name] +/** Resolve one installed external package manifest from either pnpm store. */ +function installedManifest(name: string): VirtualManifest | undefined { let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined // The nested Landlock workspace installs into its own store, so a package // only that workspace depends on is unreachable from the root one. @@ -210,6 +292,13 @@ function installedMetadata(name: string): { license: string; repo: string } { manifest = virtualManifest(virtual, name) if (manifest !== undefined) break } + return manifest +} + +/** License and repository URL for an installed external package, from the pnpm store. */ +function installedMetadata(name: string): { license: string; repo: string } { + const override = OVERRIDES[name] + const manifest = installedManifest(name) const license = override?.license ?? manifest?.license const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage const repo = override?.repo ?? normalizeRepo(rawRepo) @@ -219,6 +308,37 @@ function installedMetadata(name: string): { license: string; repo: string } { return { license, repo } } +function collectClaudeDistribution(): ClaudeDistribution { + const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE) + if (manifest === undefined) { + throw new Error( + `gen-third-party-notices: cannot resolve ${CLAUDE_AGENT_SDK_PACKAGE}; run \`pnpm install\`.`, + ) + } + const distribution = claudeDistributionFromManifest(manifest) + let installedPayloads = 0 + for (const payload of distribution.payloads) { + const installed = installedManifest(payload.name) + if (installed === undefined) continue + installedPayloads += 1 + if ( + installed.name !== payload.name + || installed.version !== payload.version + || installed.license !== CLAUDE_PLATFORM_DECLARED_LICENSE + ) { + throw new Error( + `gen-third-party-notices: installed ${payload.name} does not match its SDK-declared version and ${CLAUDE_PLATFORM_DECLARED_LICENSE} license field.`, + ) + } + } + if (installedPayloads === 0) { + throw new Error( + 'gen-third-party-notices: no SDK-declared Claude platform payload is installed; install optional dependencies before regenerating.', + ) + } + return distribution +} + /** Normalize a manifest repository/homepage value to a browsable https URL. */ function normalizeRepo(raw: string | undefined): string | undefined { if (raw === undefined || raw === '') return undefined @@ -519,6 +639,26 @@ function renderNpmTable(deps: ExternalDep[]): string { return lines.join('\n') } +function renderClaudeDistribution( + distribution: ClaudeDistribution | undefined, +): string { + if (distribution === undefined) return '' + const rows = distribution.payloads.map(payload => + `| [\`${payload.name}\`](https://www.npmjs.com/package/${payload.name}) | ${payload.version} | ${CLAUDE_PLATFORM_DECLARED_LICENSE} |`, + ) + return ` +## Official Claude Code platform payloads + +The project owner authorizes distribution of every version of the official \`${CLAUDE_AGENT_SDK_PACKAGE}\` package and the official Claude Code CLI/platform payloads that each version declares through \`optionalDependencies\`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. + +The installed SDK ${distribution.sdkVersion} declares the following optional platform packages. Each carries the official Claude Code ${distribution.claudeCodeVersion} executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. + +| Optional platform package | Version | Declared license | +| --- | --- | --- | +${rows.join('\n')} +` +} + /** * Render the complete notices document. * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold. @@ -531,11 +671,19 @@ export function render(): string { const vendored = collectVendored() const python = collectPython() const patched = collectPatched() + const claudeDistribution = runtimeDeps.some( + dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE, + ) + ? collectClaudeDistribution() + : undefined const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) // A copyleft license reaching a shipped surface is a distribution decision, // not a rendering detail; the notices cannot quietly absorb it. - const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license)) + const nonPermissiveRuntime = runtimeDeps.filter(dep => + !isPermissive(dep.license) + && !isOwnerAuthorizedRuntime(dep.name), + ) if (nonPermissiveRuntime.length > 0) { throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`) } @@ -548,7 +696,7 @@ export function render(): string { DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml). @@ -569,6 +717,7 @@ ${renderNpmTable(runtimeDeps)} pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: ${patchedLines.join('\n')} +${renderClaudeDistribution(claudeDistribution)} ## Development-only npm dependencies diff --git a/tsconfig.host.json b/tsconfig.host.json index 06af7e5871..8d0dd3a2e7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -194,6 +194,7 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/subagent/subagent-claude-code" }, { "path": "./packages/subagent/subagent-codex" }, { "path": "./packages/subagent/subagent-dsh-sdk" }, { "path": "./packages/tasks/tasks" }, diff --git a/vitest.config.ts b/vitest.config.ts index eac84d8d20..0940df235e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -54,6 +54,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subagent/subagent-claude-code/tests/real-product.spec.ts', 'packages/subagent/subagent-codex/tests/real-product.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', From d78090267994897063b4faa88a8b104d6b8e2ebc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 21:25:48 +0800 Subject: [PATCH 13/27] refactor(subagent): share provider dispose window --- .../subagent/subagent-claude-code/src/run.ts | 42 +----------------- packages/subagent/subagent-codex/src/run.ts | 42 +----------------- .../subagent/subagent/src/out-of-process.ts | 43 +++++++++++++++++++ 3 files changed, 45 insertions(+), 82 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index a65f6f5497..dbb2af3593 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -18,6 +18,7 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { + doubledGraceWindow, settleRunResult, subprocessRunHandle, type SubagentResult, @@ -38,47 +39,6 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Bound final exit observation at twice a positive finite grace without - * narrowing the public config to Node's single-timer integer range. - */ -function doubledGraceWindow(graceMs: number): { - readonly signal: AbortSignal - readonly cancel: () => void -} { - const whole = Math.floor(graceMs) - let remaining = BigInt(whole) * 2n - + BigInt(Math.ceil((graceMs - whole) * 2)) - const controller = new AbortController() - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - controller.abort() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - signal: controller.signal, - cancel: () => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - type QueryFactory = (params: { prompt: string options: Options diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 811f7c8f98..b07bf9dc6b 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -11,6 +11,7 @@ import { randomUUID } from 'node:crypto' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { + doubledGraceWindow, settleRunResult, subprocessRunHandle, type SubagentResult, @@ -24,47 +25,6 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Bound final exit observation at twice a positive finite grace without - * narrowing the public config to Node's single-timer integer range. - */ -function doubledGraceWindow(graceMs: number): { - readonly signal: AbortSignal - readonly cancel: () => void -} { - const whole = Math.floor(graceMs) - let remaining = BigInt(whole) * 2n - + BigInt(Math.ceil((graceMs - whole) * 2)) - const controller = new AbortController() - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - controller.abort() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - signal: controller.signal, - cancel: () => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index fc78fb28fa..86b0772283 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -42,6 +42,49 @@ export function assertPositiveFinite(prefix: string, name: string, value: number } } +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +/** + * Bound final exit observation at twice a positive finite grace without + * narrowing public provider config to Node's single-timer integer range. + * @param graceMs - the already validated positive finite termination grace. + * @returns a cancellable abort signal for the doubled observation window. + */ +export function doubledGraceWindow(graceMs: number): { + readonly signal: AbortSignal + readonly cancel: () => void +} { + const whole = Math.floor(graceMs) + let remaining = BigInt(whole) * 2n + + BigInt(Math.ceil((graceMs - whole) * 2)) + const controller = new AbortController() + let timer: ReturnType | undefined + const arm = (): void => { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + timer = setTimeout(() => { + timer = undefined + if (remaining === 0n) { + controller.abort() + } else { + arm() + } + }, Number(chunk)) + } + arm() + return { + signal: controller.signal, + cancel: () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + }, + } +} + /** * Whether `path` names an existing directory the harness can ENTER. The * search-permission probe matters: `statSync().isDirectory()` is true for a From 32f829c4e6a3004268afd26dce8684e3555d83d7 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 22:18:35 +0800 Subject: [PATCH 14/27] fix(subagent): complete product provider lifecycle --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 8 +- .../2026-06-21-subagent-capability-seam.zh.md | 8 +- .../2026-06-22-acp-subagent-backend.i18n.yaml | 4 +- .../2026-06-22-acp-subagent-backend.md | 4 +- .../2026-06-22-acp-subagent-backend.zh.md | 4 +- docs/module-graph.md | 7 + .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent/subagent-claude-code/src/run.ts | 69 ++---- .../tests/subagent-claude-code.spec.ts | 201 ++++++++++-------- .../subagent-claude-code/tsconfig.json | 6 + .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 2 +- packages/subagent/subagent-codex/README.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 29 +-- .../tests/subagent-codex.spec.ts | 79 ++----- .../subagent/subagent/src/out-of-process.ts | 70 ++---- scripts/run-gates.spec.ts | 6 + scripts/run-gates.ts | 2 + 21 files changed, 216 insertions(+), 301 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index f8b82ebfbf..80508d16b4 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 5b9b018df151f0d734b54cfdd4dacfd09058f7d7 -2026-06-21-subagent-capability-seam.zh.md: 49571288e35abb1369c16abd5c77a81dd3212a12 +2026-06-21-subagent-capability-seam.md: 35fe7b7aaf02d9d55012e3285b3f5a58bc76cde8 +2026-06-21-subagent-capability-seam.zh.md: 221335859cec104a55136201e4923d783d616e86 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 5b9b018df1..35fe7b7aaf 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-06-21-subagent-capability-seam.zh.md) -> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its Agent Note](2026-06-22-acp-subagent-backend.md)). +> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process ACP, Codex, and Claude Code backends ([ACP Agent Note](2026-06-22-acp-subagent-backend.md), [product-provider Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md)). ## Problem @@ -14,8 +14,8 @@ The distinctive requirement — the one that shapes the whole design — is that - **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); -- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); -- later: **A2A** and the **Claude Code Agent SDK** — the same out-of-process "start a child, prompt it, settle, cancel" shape; the Claude sibling remains in the product-provider proposal. +- **Codex app-server and Claude Code Agent SDK** — current one-shot siblings that apply the same named-provider seam to official product processes ([product-provider Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md)); +- later: **A2A** using the same out-of-process "start a child, prompt it, settle, cancel" shape. ## Alternatives considered @@ -35,6 +35,8 @@ A new package group `packages/subagent/`: | `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` | | `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log | | `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process | +| `@deepseek-ai/dsh-subagent-codex` | implementation: a one-shot official Codex app-server process | +| `@deepseek-ai/dsh-subagent-claude-code` | implementation: a one-shot official Claude Code process through the Agent SDK | | `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` | ### The primitive: async `start → SubagentRun` diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 49571288e3..221335859c 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-21-subagent-capability-seam.md) | 中文 -> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`([其 Agent Note](2026-06-22-acp-subagent-backend.md))。 +> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外的 ACP、Codex 与 Claude Code 后端([ACP Agent Note](2026-06-22-acp-subagent-backend.md)、[产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md))。 ## 问题 @@ -14,8 +14,8 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智 - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); -- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); -- 后续:**A2A** 与 **Claude Code Agent SDK**——两者采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」;Claude 兄弟提供方仍在产品提供方提案中。 +- **Codex app-server 与 Claude Code Agent SDK**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md)); +- 后续:**A2A**,采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」。 ## 曾考虑的替代方案 @@ -35,6 +35,8 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 | `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent | | `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent | | `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 | +| `@deepseek-ai/dsh-subagent-codex` | 实现:一次性官方 Codex app-server 进程 | +| `@deepseek-ai/dsh-subagent-claude-code` | 实现:通过 Agent SDK 运行的一次性官方 Claude Code 进程 | | `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 | ### 原语:异步 `start → SubagentRun` diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 54ba7268df..58325b9b0e 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md -2026-06-22-acp-subagent-backend.md: d839ab6f75d8a518c9bc850894d1c3c5ffdbed92 -2026-06-22-acp-subagent-backend.zh.md: e9027e282bf351890643e0545b01fe00287375a6 +2026-06-22-acp-subagent-backend.md: c994ebfa69649bb9e79d3aa389a0e13c178131c7 +2026-06-22-acp-subagent-backend.zh.md: e3c651f752e93b9ba8298b7fa52f83c33ed71a2e diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index d839ab6f75..c994ebfa69 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -57,6 +57,6 @@ Persistent-process pooling (reuse a warm child across runs) is a performance opt Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The parent surfaces only the child's final answer: `session/update` thoughts and tool-call cards are consumed and dropped, and permission prompts never reach a human — the configured policy answers them. The child's environment is credential-scrubbed by default, so its own model key is supplied explicitly via `config.env`. -## Future providers +## Product-provider siblings -The [Codex app-server provider](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols. +The [Codex app-server and Claude Code Agent SDK providers](2026-08-04-claude-code-and-codex-subagent-backends.md) apply the same out-of-process spawn/prompt/settle/cancel boundary as siblings registered by name. A2A remains a future sibling transport; the ACP backend proves that the common seam supports this boundary without owning product-private protocols. diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index e9027e282b..e3c651f752 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -57,6 +57,6 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` 每次运行都要付出一个全新子进程的代价(spawn + `initialize` + `newSession`)。父进程仅暴露子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示从不到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥需通过 `config.env` 显式提供。 -## 后续提供方 +## 兄弟产品提供方 -[Codex app-server 提供方](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。 +[Codex app-server 与 Claude Code Agent SDK 提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)作为按名称注册的兄弟提供方,采用同样的进程外启动/提示词/结算/取消边界。A2A 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持这项边界,而无需负责产品私有协议。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 35f832154b..341655c57f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_claude_code["subagent-claude-code"] pkg_subagent_codex["subagent-codex"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] @@ -892,6 +893,11 @@ flowchart TD pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess + pkg_subagent_claude_code --> pkg_invariants + pkg_subagent_claude_code --> pkg_llm + pkg_subagent_claude_code --> pkg_session + pkg_subagent_claude_code --> pkg_subagent + pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -1218,6 +1224,7 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index ac60e83f76..63570333f1 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-claude-code/README.md -README.md: facaae300eeb8907076182a129aa216863dec8ec -README.zh.md: 75627cb54032edde07ec1ba5e21a058768515e29 +README.md: e19b119e355953a388ec4fca6a2db511e7eb43da +README.zh.md: 6d3b8719329691681582abd91b82abd167e89f6d diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index facaae300e..e19b119e35 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | -| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 75627cb540..6d3b871932 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -27,7 +27,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | 生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。 diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index dbb2af3593..2ad305f155 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -18,9 +18,9 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { - doubledGraceWindow, settleRunResult, subprocessRunHandle, + thrownError, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -39,32 +39,20 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -type QueryFactory = (params: { - prompt: string - options: Options -}) => Query - /** Fully resolved inputs for one official Claude Agent SDK query. */ export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ readonly cwd: string /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record - /** Subprocess termination grace and final tree-exit bound. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle - /** Official query entrypoint; replaced only by package-local unit tests. */ - readonly query?: QueryFactory /** Diagnostic sink for a post-publication error flattened into a result. */ readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } -function thrown(value: unknown): Error { - /* v8 ignore next -- SDK and subprocess failures reject with Error. */ - return value instanceof Error ? value : new Error(String(value)) -} - /** * Validate and preserve the one-shot task before crossing the SDK boundary. * @param prompt - task content accepted from the shared subagent service. @@ -110,18 +98,15 @@ export function successfulResult(message: SDKResultMessage): string { * Consume the complete SDK stream and require one strict success plus normal * iterator completion. * @param query - published official SDK query. - * @param setOutput - captures the candidate result for error diagnostics. * @returns the completed shared result. */ export async function consumeClaudeQuery( query: AsyncIterable, - setOutput: (output: ContentBlock[]) => void, ): Promise { let answer: string | undefined for await (const message of query) { if (message.type !== 'result') continue answer = successfulResult(message) - setOutput([{ type: 'text', text: answer }]) } if (answer === undefined) { throw new Error('subagent-claude-code: Claude Code ended without a result') @@ -137,48 +122,30 @@ export async function consumeClaudeQuery( * the subprocess owner to prove it is gone. * @param query - official SDK query, when creation reached that point. * @param child - shared-service handle that owns the CLI process tree. - * @param graceMs - termination grace used to bound final exit observation. */ export async function disposeClaudeCodeChild( query: Pick | undefined, child: SubprocessHandle, - graceMs: number, ): Promise { const failures: Error[] = [] - let treeExited = child.pid <= 0 try { query?.close() } catch (error: unknown) { - failures.push(thrown(error)) + failures.push(thrownError(error)) } if (child.pid > 0) { child.terminate() - const exitWindow = doubledGraceWindow(graceMs) try { - treeExited = await child.waitForExit(exitWindow.signal) - if (!treeExited) { - failures.push(new Error( - 'subagent-claude-code: Claude Code process tree did not exit within its dispose window', - )) - } + await child.waitForExit() } catch (error: unknown) { - failures.push(thrown(error)) - } finally { - exitWindow.cancel() + failures.push(thrownError(error)) } } - if (treeExited) { - try { - await child.done - } catch (error: unknown) { - failures.push(thrown(error)) - } - } else { - // The bounded tree observation owns teardown completion. Keep a later - // direct-child spawn failure observed without turning that bound into an - // unbounded wait. - void child.done.catch(() => {}) + try { + await child.done + } catch (error: unknown) { + failures.push(thrownError(error)) } const firstFailure = failures[0] @@ -244,7 +211,7 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined try { - query = (spec.query ?? officialQuery)({ + query = officialQuery({ prompt, options: claudeQueryOptions(spec, controller, (captured) => { child = captured @@ -264,10 +231,10 @@ export async function startClaudeCodeRun( requestCancel() if (child !== undefined) { try { - await disposeClaudeCodeChild(query, child, spec.disposeGraceMs) + await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [thrownError(error), thrownError(disposeError)], 'subagent-claude-code: startup failed and CLI cleanup also failed', ) } @@ -276,7 +243,7 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [thrownError(error), thrownError(disposeError)], 'subagent-claude-code: startup failed and query cleanup also failed', ) } @@ -285,17 +252,14 @@ export async function startClaudeCodeRun( if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrown(error) + throw thrownError(error) } - let output: ContentBlock[] = [] const publishedQuery = query const publishedChild = child const result = settleRunResult({ - attempt: () => consumeClaudeQuery(publishedQuery, (value) => { - output = value - }), - collectOutput: () => output, + attempt: () => consumeClaudeQuery(publishedQuery), + collectOutput: () => [], cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, @@ -311,7 +275,6 @@ export async function startClaudeCodeRun( teardown: () => disposeClaudeCodeChild( publishedQuery, publishedChild, - spec.disposeGraceMs, ), }) } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 1f02914163..8c8158fb28 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1,5 +1,6 @@ import { PassThrough } from 'node:stream' import type { + Options, Query, SDKMessage, SDKResultMessage, @@ -7,7 +8,15 @@ import type { } from '@anthropic-ai/claude-agent-sdk' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { afterEach, describe, expect, it, type Mock, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -35,6 +44,18 @@ import { type ClaudeCodeRunSpec, } from '../src/run.ts' +type QueryFactory = (params: { + prompt: string + options: Options +}) => Query + +const queryMock = vi.hoisted(() => vi.fn()) + +vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({ + ...await importOriginal(), + query: queryMock, +})) + const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } }, @@ -56,7 +77,6 @@ interface FakeChildOptions { readonly stdin?: PassThrough | undefined readonly stdout?: PassThrough | undefined readonly exitOnTerminate?: boolean - readonly waitForExitResult?: boolean readonly waitForExitError?: Error readonly doneError?: Error } @@ -103,9 +123,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { if (options.waitForExitError !== undefined) { throw options.waitForExitError } - if (options.waitForExitResult !== undefined) { - return options.waitForExitResult - } if (exited) return true if (signal === undefined) { await done.catch(() => {}) @@ -218,7 +235,7 @@ interface FakeRun { readonly query: Query readonly close: ReturnType readonly spawnSpecs: SubprocessSpawnSpec[] - readonly options: Array>[0]['options']> + readonly options: Options[] readonly spec: ClaudeCodeRunSpec } @@ -239,16 +256,28 @@ function fakeRun( spawnSpecs.push(spawnSpec) return child.handle }, - query: (params) => { - options.push(params.options) - params.options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - return query - }, } + queryMock.mockImplementation((params) => { + options.push(params.options) + params.options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return query + }) return { child, query, close, spawnSpecs, options, spec } } +beforeEach(() => { + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions({ + cwd: options.cwd!, + env: options.env!, + signal: options.abortController!.signal, + })) + return queryFrom([]) + }) +}) + afterEach(() => { + queryMock.mockReset() vi.restoreAllMocks() vi.unstubAllEnvs() }) @@ -523,25 +552,17 @@ describe('query options and result mapping', () => { }) it('consumes the complete stream and keeps the latest strict success', async () => { - const outputs: ContentBlock[][] = [] const query = queryFrom([ { type: 'system', subtype: 'init' } as SDKMessage, success('first'), success('last'), ]) - await expect(consumeClaudeQuery(query, (output) => { - outputs.push(output) - })).resolves.toEqual({ + await expect(consumeClaudeQuery(query)).resolves.toEqual({ output: [{ type: 'text', text: 'last' }], stopReason: 'completed', }) - expect(outputs).toEqual([ - [{ type: 'text', text: 'first' }], - [{ type: 'text', text: 'last' }], - ]) await expect(consumeClaudeQuery( queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), - () => {}, )).rejects.toThrow('ended without a result') }) }) @@ -596,14 +617,14 @@ describe('run publication, cancellation, and settlement', () => { } }) - it('preserves candidate output when iteration fails after a result', async () => { + it('fails closed when iteration rejects after a result', async () => { const fixture = fakeRun( [success('partial final')], new Error('iterator boom'), ) const run = await startClaudeCodeRun(request(), fixture.spec) await expect(run.result).resolves.toEqual({ - output: [{ type: 'text', text: 'partial final' }], + output: [], stopReason: 'error', }) await run.dispose() @@ -635,14 +656,14 @@ describe('run publication, cancellation, and settlement', () => { env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, - query: ({ prompt, options }) => { - controllers.push(options.abortController!) - options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - return prompt === 'wait' - ? waitingQuery(options.abortController!.signal) - : queryFrom([success('second answer')]) - }, } + queryMock.mockImplementation(({ prompt, options }) => { + controllers.push(options.abortController!) + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return prompt === 'wait' + ? waitingQuery(options.abortController!.signal) + : queryFrom([success('second answer')]) + }) const firstAbort = new AbortController() const first = await startClaudeCodeRun( request([{ type: 'text', text: 'wait' }], firstAbort.signal), @@ -667,6 +688,33 @@ describe('run publication, cancellation, and settlement', () => { await Promise.all([first.dispose(), second.dispose()]) }) + it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => { + const parentAbort = new AbortController() + const child = fakeChild() + async function* stream(): AsyncGenerator { + yield success('candidate answer') + parentAbort.abort(new Error('parent cancelled at iterator completion')) + } + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return Object.assign(stream(), { close: vi.fn() }) as unknown as Query + }) + const run = await startClaudeCodeRun( + request(undefined, parentAbort.signal), + { + cwd: '/workspace', + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }, + ) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await run.dispose() + }) + it('rejects pre-abort and every incomplete startup transaction', async () => { const preAborted = new AbortController() preAborted.abort() @@ -678,32 +726,36 @@ describe('run publication, cancellation, and settlement', () => { expect(unused.options).toEqual([]) const noChildClose = vi.fn() + queryMock.mockImplementationOnce( + () => queryFrom([], undefined, noChildClose), + ) await expect(startClaudeCodeRun(request(), { ...unused.spec, - query: () => queryFrom([], undefined, noChildClose), })).rejects.toThrow('did not publish a controllable') expect(noChildClose).toHaveBeenCalledOnce() const closeFailure = vi.fn(() => { throw new Error('close boom') }) + queryMock.mockImplementationOnce( + () => queryFrom([], undefined, closeFailure), + ) const noChild = startClaudeCodeRun(request(), { ...unused.spec, - query: () => queryFrom([], undefined, closeFailure), }) await expect(noChild).rejects.toBeInstanceOf(AggregateError) const startupAbort = new AbortController() const abortedChild = fakeChild() const abortedClose = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + startupAbort.abort(new Error('startup cancelled')) + return queryFrom([], undefined, abortedClose) + }) const abortedDuringStartup = startClaudeCodeRun( request(undefined, startupAbort.signal), { ...unused.spec, spawn: () => abortedChild.handle, - query: ({ options }) => { - options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - startupAbort.abort(new Error('startup cancelled')) - return queryFrom([], undefined, abortedClose) - }, }, ) await expect(abortedDuringStartup) @@ -711,27 +763,27 @@ describe('run publication, cancellation, and settlement', () => { expect(abortedClose).toHaveBeenCalledOnce() expect(abortedChild.terminate).toHaveBeenCalledOnce() + queryMock.mockImplementationOnce(() => { + throw new Error('query failed before resource creation') + }) await expect(startClaudeCodeRun(request(), { ...unused.spec, - query: () => { - throw new Error('query failed before resource creation') - }, })).rejects.toThrow('query failed before resource creation') const spawned = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] let factoryController: AbortController | undefined + queryMock.mockImplementationOnce(({ options }) => { + factoryController = options.abortController + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw new Error('query construction failed') + }) const factoryFailure = startClaudeCodeRun(request(), { ...unused.spec, spawn: (spawnSpec) => { spawnSpecs.push(spawnSpec) return spawned.handle }, - query: ({ options }) => { - factoryController = options.abortController - options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - throw new Error('query construction failed') - }, }) await expect(factoryFailure).rejects.toThrow('query construction failed') expect(spawnSpecs).toHaveLength(1) @@ -749,76 +801,45 @@ describe('run publication, cancellation, and settlement', () => { }) }) -describe('bounded query and process disposal', () => { +describe('query and process disposal', () => { it('closes the query, terminates the tree, and waits for direct-child outcome', async () => { const child = fakeChild() const close = vi.fn() - await disposeClaudeCodeChild({ close }, child.handle, 5) + await disposeClaudeCodeChild({ close }, child.handle) expect(close).toHaveBeenCalledOnce() expect(child.terminate).toHaveBeenCalledOnce() expect(child.waitForExit).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledWith() await expect(child.handle.done).resolves.toEqual({ exitCode: 0, signal: null, }) }) - it('accepts fractional and larger-than-Node grace windows', async () => { - for (const graceMs of [0.25, Number.MAX_VALUE]) { - const child = fakeChild() - await expect(disposeClaudeCodeChild( - { close: vi.fn() }, - child.handle, - graceMs, - )).resolves.toBeUndefined() - const signal = child.waitForExit.mock.calls[0]?.[0] - expect(signal?.aborted).toBe(false) - } - }) - - it('chains a doubled grace window beyond one Node timer segment', async () => { - vi.useFakeTimers() - try { - const child = fakeChild({ exitOnTerminate: false }) - const disposal = disposeClaudeCodeChild( - { close: vi.fn() }, - child.handle, - 1_073_741_823.75, - ) - const rejected = expect(disposal) - .rejects.toThrow('did not exit within its dispose window') - await vi.advanceTimersByTimeAsync(2_147_483_647) - await vi.advanceTimersByTimeAsync(1) - await rejected - } finally { - vi.useRealTimers() - } - }) - - it('does not turn a missed tree-exit bound into an unbounded done wait', async () => { - const child = fakeChild({ - exitOnTerminate: false, - waitForExitResult: false, - }) - await expect(disposeClaudeCodeChild( + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + let disposed = false + const disposal = disposeClaudeCodeChild( { close: vi.fn() }, child.handle, - 5, - )).rejects.toThrow('did not exit within its dispose window') - child.fail(new Error('late direct-child failure')) + ).then(() => { + disposed = true + }) await nextTask() + expect(disposed).toBe(false) + child.settle() + await disposal + expect(disposed).toBe(true) }) it('reports wait, close, and direct-child failures without skipping cleanup', async () => { const waitFailure = fakeChild({ - exitOnTerminate: false, waitForExitError: new Error('wait boom'), }) const closeFailure = vi.fn(() => { throw new Error('close boom') }) await expect(disposeClaudeCodeChild( { close: closeFailure }, waitFailure.handle, - 5, )).rejects.toBeInstanceOf(AggregateError) expect(waitFailure.terminate).toHaveBeenCalledOnce() @@ -829,7 +850,6 @@ describe('bounded query and process disposal', () => { await expect(disposeClaudeCodeChild( { close: vi.fn() }, doneFailure.handle, - 5, )).rejects.toThrow('spawn boom') const both = fakeChild({ @@ -839,7 +859,6 @@ describe('bounded query and process disposal', () => { await expect(disposeClaudeCodeChild( { close: () => { throw new Error('close boom') } }, both.handle, - 5, )).rejects.toBeInstanceOf(AggregateError) }) }) diff --git a/packages/subagent/subagent-claude-code/tsconfig.json b/packages/subagent/subagent-claude-code/tsconfig.json index 72e5f73fec..61e81d7fcc 100644 --- a/packages/subagent/subagent-claude-code/tsconfig.json +++ b/packages/subagent/subagent-claude-code/tsconfig.json @@ -9,6 +9,12 @@ "src/**/*.ts" ], "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../llm/llm" }, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 3e8e805c88..c3d4da77bf 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-codex/README.md -README.md: ce1c66427b562c08af06320f012f28b9e125ac45 -README.zh.md: bef47586db77c70bec741629d8579ba0e2efba1e +README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8 +README.zh.md: 110953312162e146f01ef037a40d2f70b136850c diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index ce1c66427b..d7293a0ef3 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | -| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index bef47586db..1109533121 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -23,7 +23,7 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index b07bf9dc6b..27d01f6ac6 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -11,9 +11,9 @@ import { randomUUID } from 'node:crypto' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { - doubledGraceWindow, settleRunResult, subprocessRunHandle, + thrownError, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -31,7 +31,7 @@ export interface CodexRunSpec { readonly cwd: string /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record - /** Subprocess termination grace and final tree-exit bound. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -39,11 +39,6 @@ export interface CodexRunSpec { readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } -function thrown(value: unknown): Error { - /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ - return value instanceof Error ? value : new Error(String(value)) -} - /** * Validate and preserve the one-shot task before crossing the process seam. * @param prompt - task content accepted from the shared subagent service. @@ -71,12 +66,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { * subprocess owner to prove it is gone. * @param wire - private app-server protocol connection. * @param child - shared-service handle that owns the process tree. - * @param graceMs - termination grace used to bound final exit observation. */ export async function disposeCodexChild( wire: CodexAppServerWire, child: SubprocessHandle, - graceMs: number, ): Promise { wire.close() if (child.pid <= 0) { @@ -89,14 +82,7 @@ export async function disposeCodexChild( // A concurrently closed stdin does not change tree ownership below. } child.terminate() - const exitWindow = doubledGraceWindow(graceMs) - try { - if (!(await child.waitForExit(exitWindow.signal))) { - throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') - } - } finally { - exitWindow.cancel() - } + await child.waitForExit() await child.done } @@ -127,15 +113,14 @@ export async function startCodexRun( child.stdout as NonNullable, child.stdin as NonNullable, ) - const disposeProcess = (): Promise => - disposeCodexChild(wire, child, spec.disposeGraceMs) + const disposeProcess = (): Promise => disposeCodexChild(wire, child) const processFailure: Promise = child.done.then( outcome => Promise.reject(new Error( 'subagent-codex: app-server exited before the run settled ' + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, )), - (error: unknown) => Promise.reject(thrown(error)), + (error: unknown) => Promise.reject(thrownError(error)), ) // A normal post-result dispose also closes the process. Keep that expected // late rejection observed after the result race has already settled. @@ -160,14 +145,14 @@ export async function startCodexRun( await disposeProcess() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [thrownError(error), thrownError(disposeError)], 'subagent-codex: startup failed and app-server cleanup also failed', ) } if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before app-server startup') } - throw thrown(error) + throw thrownError(error) } const collectOutput = (): ContentBlock[] => wire.collectOutput() diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 8e6c7ebd51..7d550d42cb 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -91,7 +91,6 @@ class ProtocolPeer { interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean - readonly waitForExitResult?: boolean readonly doneError?: Error } @@ -134,9 +133,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { if (options.exitOnTerminate !== false) settle() }) const waitForExit = vi.fn(async (signal?: AbortSignal) => { - if (options.waitForExitResult !== undefined) { - return options.waitForExitResult - } if (exited) return true if (signal === undefined) { await done.catch(() => {}) @@ -964,19 +960,6 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) - it('reports both startup and rollback failures', async () => { - const child = fakeChild({ waitForExitResult: false, exitOnTerminate: false }) - const starting = startCodexRun( - request(), - runSpec(child, { disposeGraceMs: 1 }), - ) - const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: '' }) - await expect(starting).rejects.toThrow( - 'startup failed and app-server cleanup also failed', - ) - }) - it('keeps overlapping runs isolated', async () => { const first = fakeChild() const second = fakeChild() @@ -1047,41 +1030,25 @@ describe('disposeCodexChild', () => { const child = fakeChild() const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) const end = vi.spyOn(child.toChild, 'end') - await disposeCodexChild(wire, child.handle, 100) + await disposeCodexChild(wire, child.handle) expect(end).toHaveBeenCalled() expect(child.terminate).toHaveBeenCalledTimes(1) expect(child.waitForExit).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledWith() }) - it('accepts fractional and larger-than-Node grace windows', async () => { - for (const graceMs of [0.25, Number.MAX_VALUE]) { - const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, graceMs)) - .resolves.toBeUndefined() - const signal = vi.mocked(child.waitForExit).mock.calls[0]?.[0] - expect(signal?.aborted).toBe(false) - } - }) - - it('chains a doubled grace window beyond one Node timer segment', async () => { - vi.useFakeTimers() - try { - const child = fakeChild({ exitOnTerminate: false }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - const disposal = disposeCodexChild( - wire, - child.handle, - 1_073_741_823.75, - ) - const rejected = expect(disposal) - .rejects.toThrow('did not exit within its dispose window') - await vi.advanceTimersByTimeAsync(2_147_483_647) - await vi.advanceTimersByTimeAsync(1) - await rejected - } finally { - vi.useRealTimers() - } + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + let disposed = false + const disposal = disposeCodexChild(wire, child.handle).then(() => { + disposed = true + }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(disposed).toBe(false) + child.settle() + await disposal + expect(disposed).toBe(true) }) it('contains a concurrently closed stdin error', async () => { @@ -1090,7 +1057,7 @@ describe('disposeCodexChild', () => { vi.spyOn(child.toChild, 'end').mockImplementation(() => { throw new Error('already closed') }) - await expect(disposeCodexChild(wire, child.handle, 100)) + await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() }) @@ -1100,34 +1067,26 @@ describe('disposeCodexChild', () => { doneError: new Error('spawn failed'), }) const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 100)) + await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() expect(child.terminate).not.toHaveBeenCalled() expect(child.waitForExit).not.toHaveBeenCalled() }) - it('fails when the tree misses the release window or done rejects', async () => { - { - const child = fakeChild({ - exitOnTerminate: false, - }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 1)) - .rejects.toThrow('did not exit within its dispose window') - } + it('reports direct-child observer failure and accepts absent stdin', async () => { { const child = fakeChild({ doneError: new Error('close observer failed'), }) const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 1)) + await expect(disposeCodexChild(wire, child.handle)) .rejects.toThrow('close observer failed') } { const child = fakeChild() const handle = { ...child.handle, stdin: undefined } const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, handle, 1)).resolves.toBeUndefined() + await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) }) diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index 86b0772283..eac2125897 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -42,49 +42,6 @@ export function assertPositiveFinite(prefix: string, name: string, value: number } } -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Bound final exit observation at twice a positive finite grace without - * narrowing public provider config to Node's single-timer integer range. - * @param graceMs - the already validated positive finite termination grace. - * @returns a cancellable abort signal for the doubled observation window. - */ -export function doubledGraceWindow(graceMs: number): { - readonly signal: AbortSignal - readonly cancel: () => void -} { - const whole = Math.floor(graceMs) - let remaining = BigInt(whole) * 2n - + BigInt(Math.ceil((graceMs - whole) * 2)) - const controller = new AbortController() - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - controller.abort() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - signal: controller.signal, - cancel: () => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - /** * Whether `path` names an existing directory the harness can ENTER. The * search-permission probe matters: `statSync().isDirectory()` is true for a @@ -162,8 +119,12 @@ export function resolveChildCwd(prefix: string, configured: string | undefined, return assertUsableCwd(prefix, 'parent session cwd', parentCwd) } -/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */ -function toError(value: unknown): Error { +/** + * Normalize an unknown thrown value to an Error. + * @param value - the unknown catch binding. + * @returns the original Error or a defensive Error wrapper. + */ +export function thrownError(value: unknown): Error { // The rejecting surfaces (wire clients, spawn failures) only throw // `Error`s; the `String(value)` arm is a defensive fallback for a non-Error // throw the typed surfaces cannot produce. @@ -175,9 +136,9 @@ function toError(value: unknown): Error { export interface RunResultSettlement { /** The turn attempt (typically racing local cancellation); returns the terminal result. */ attempt: () => Promise - /** Snapshot of the child output streamed so far (a partial answer survives failure). */ + /** Snapshot the provider exposes when cancellation or failure wins settlement. */ collectOutput: () => ContentBlock[] - /** Whether local cancellation settled (an in-flight rejection then reads as `aborted`). */ + /** Whether local cancellation settled before the attempt's outcome is observed. */ cancelled: () => boolean /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */ onError?: ((error: Error, stopReason: SubagentStopReason) => void) | undefined @@ -189,22 +150,25 @@ export interface RunResultSettlement { /** * Settle an out-of-process run result under the seam contract: `result` never - * rejects after publication. A rejection from the attempt resolves as - * `aborted` when cancellation already settled locally, else it is flattened - * to `stopReason: 'error'` through the contained diagnostic sink; the abort - * listener is removed on every path. + * rejects after publication. A normally completed or rejected attempt resolves + * as `aborted` when cancellation already settled locally; another rejection is + * flattened to `stopReason: 'error'` through the contained diagnostic sink. + * The abort listener is removed on every path. * @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring. * @returns the terminal result (never a rejection). */ export async function settleRunResult(parts: RunResultSettlement): Promise { try { - return await parts.attempt() + const result = await parts.attempt() + return parts.cancelled() + ? { output: parts.collectOutput(), stopReason: 'aborted' } + : result } catch (error: unknown) { // Cover a rejection already queued when cancellation arrives. if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: 'aborted' } // Flatten post-publication transport failures while preserving diagnostics. try { - parts.onError?.(toError(error), 'error') + parts.onError?.(thrownError(error), 'error') } catch { // The diagnostic sink cannot reject the run result. } diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index d7fb7e1b13..84eeeb10bb 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -189,6 +189,12 @@ describe('Node 24 lane ownership', () => { expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({ DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1', }) + expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual( + expect.arrayContaining([ + 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', + 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', + ]), + ) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..51bdf51f0e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -598,6 +598,8 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', + 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', + 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). From a93968bf9361a3240228d8e35bd43bfa1f7f7799 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 22:36:31 +0800 Subject: [PATCH 15/27] fix(subagent-codex): await managed tree exit --- docs/config-catalog.md | 4 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 2 +- packages/subagent/subagent-codex/README.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 59 +------------- .../tests/subagent-codex.spec.ts | 81 +++++-------------- packages/subagent/tool-subagent/src/index.ts | 4 +- 7 files changed, 32 insertions(+), 124 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d063d793e6..008d4ee2dd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1949,8 +1949,8 @@ export interface Config { * requires the provider's `depthLimit` capability (mount fails loud * otherwise). The provider checks the calling agent's current depth at every * start; the tool remains model-visible so runtime policy owns rejection. - * `'provider-managed'` is for an out-of-process provider (ACP) whose - * recursion budget belongs to the child harness's own deployment. + * `'provider-managed'` is for an out-of-process provider whose recursion + * budget belongs to the child runtime or its own deployment. */ maxDepth?: number | 'provider-managed' } diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 3e8e805c88..c3d4da77bf 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-codex/README.md -README.md: ce1c66427b562c08af06320f012f28b9e125ac45 -README.zh.md: bef47586db77c70bec741629d8579ba0e2efba1e +README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8 +README.zh.md: 110953312162e146f01ef037a40d2f70b136850c diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index ce1c66427b..d7293a0ef3 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | -| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index bef47586db..1109533121 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -23,7 +23,7 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 811f7c8f98..9f52e18f12 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -24,54 +24,13 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Bound final exit observation at twice a positive finite grace without - * narrowing the public config to Node's single-timer integer range. - */ -function doubledGraceWindow(graceMs: number): { - readonly signal: AbortSignal - readonly cancel: () => void -} { - const whole = Math.floor(graceMs) - let remaining = BigInt(whole) * 2n - + BigInt(Math.ceil((graceMs - whole) * 2)) - const controller = new AbortController() - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - controller.abort() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - signal: controller.signal, - cancel: () => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ readonly cwd: string /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record - /** Subprocess termination grace and final tree-exit bound. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -111,12 +70,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { * subprocess owner to prove it is gone. * @param wire - private app-server protocol connection. * @param child - shared-service handle that owns the process tree. - * @param graceMs - termination grace used to bound final exit observation. */ export async function disposeCodexChild( wire: CodexAppServerWire, child: SubprocessHandle, - graceMs: number, ): Promise { wire.close() if (child.pid <= 0) { @@ -129,14 +86,7 @@ export async function disposeCodexChild( // A concurrently closed stdin does not change tree ownership below. } child.terminate() - const exitWindow = doubledGraceWindow(graceMs) - try { - if (!(await child.waitForExit(exitWindow.signal))) { - throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') - } - } finally { - exitWindow.cancel() - } + await child.waitForExit() await child.done } @@ -167,8 +117,7 @@ export async function startCodexRun( child.stdout as NonNullable, child.stdin as NonNullable, ) - const disposeProcess = (): Promise => - disposeCodexChild(wire, child, spec.disposeGraceMs) + const disposeProcess = (): Promise => disposeCodexChild(wire, child) const processFailure: Promise = child.done.then( outcome => Promise.reject(new Error( @@ -205,7 +154,7 @@ export async function startCodexRun( ) } if (runAbort.signal.aborted) { - throw new Error('subagent-codex: request was aborted before app-server startup') + throw new Error('subagent-codex: request was aborted before run publication') } throw thrown(error) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 8e6c7ebd51..181ddb919e 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -91,7 +91,6 @@ class ProtocolPeer { interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean - readonly waitForExitResult?: boolean readonly doneError?: Error } @@ -134,9 +133,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { if (options.exitOnTerminate !== false) settle() }) const waitForExit = vi.fn(async (signal?: AbortSignal) => { - if (options.waitForExitResult !== undefined) { - return options.waitForExitResult - } if (exited) return true if (signal === undefined) { await done.catch(() => {}) @@ -943,7 +939,7 @@ describe('run lifecycle and quiescence', () => { const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) controller.abort('startup race') - await expect(starting).rejects.toThrow('aborted before app-server startup') + await expect(starting).rejects.toThrow('aborted before run publication') expect(child.terminate).toHaveBeenCalledTimes(1) }) @@ -964,19 +960,6 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) - it('reports both startup and rollback failures', async () => { - const child = fakeChild({ waitForExitResult: false, exitOnTerminate: false }) - const starting = startCodexRun( - request(), - runSpec(child, { disposeGraceMs: 1 }), - ) - const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: '' }) - await expect(starting).rejects.toThrow( - 'startup failed and app-server cleanup also failed', - ) - }) - it('keeps overlapping runs isolated', async () => { const first = fakeChild() const second = fakeChild() @@ -1047,41 +1030,25 @@ describe('disposeCodexChild', () => { const child = fakeChild() const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) const end = vi.spyOn(child.toChild, 'end') - await disposeCodexChild(wire, child.handle, 100) + await disposeCodexChild(wire, child.handle) expect(end).toHaveBeenCalled() expect(child.terminate).toHaveBeenCalledTimes(1) expect(child.waitForExit).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledWith() }) - it('accepts fractional and larger-than-Node grace windows', async () => { - for (const graceMs of [0.25, Number.MAX_VALUE]) { - const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, graceMs)) - .resolves.toBeUndefined() - const signal = vi.mocked(child.waitForExit).mock.calls[0]?.[0] - expect(signal?.aborted).toBe(false) - } - }) - - it('chains a doubled grace window beyond one Node timer segment', async () => { - vi.useFakeTimers() - try { - const child = fakeChild({ exitOnTerminate: false }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - const disposal = disposeCodexChild( - wire, - child.handle, - 1_073_741_823.75, - ) - const rejected = expect(disposal) - .rejects.toThrow('did not exit within its dispose window') - await vi.advanceTimersByTimeAsync(2_147_483_647) - await vi.advanceTimersByTimeAsync(1) - await rejected - } finally { - vi.useRealTimers() - } + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + let disposed = false + const disposal = disposeCodexChild(wire, child.handle).then(() => { + disposed = true + }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(disposed).toBe(false) + child.settle() + await disposal + expect(disposed).toBe(true) }) it('contains a concurrently closed stdin error', async () => { @@ -1090,7 +1057,7 @@ describe('disposeCodexChild', () => { vi.spyOn(child.toChild, 'end').mockImplementation(() => { throw new Error('already closed') }) - await expect(disposeCodexChild(wire, child.handle, 100)) + await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() }) @@ -1100,34 +1067,26 @@ describe('disposeCodexChild', () => { doneError: new Error('spawn failed'), }) const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 100)) + await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() expect(child.terminate).not.toHaveBeenCalled() expect(child.waitForExit).not.toHaveBeenCalled() }) - it('fails when the tree misses the release window or done rejects', async () => { - { - const child = fakeChild({ - exitOnTerminate: false, - }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 1)) - .rejects.toThrow('did not exit within its dispose window') - } + it('reports direct-child observer failure and accepts absent stdin', async () => { { const child = fakeChild({ doneError: new Error('close observer failed'), }) const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 1)) + await expect(disposeCodexChild(wire, child.handle)) .rejects.toThrow('close observer failed') } { const child = fakeChild() const handle = { ...child.handle, stdin: undefined } const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, handle, 1)).resolves.toBeUndefined() + await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f95c5ad09d..67894c32cb 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -67,8 +67,8 @@ export interface Config { * requires the provider's `depthLimit` capability (mount fails loud * otherwise). The provider checks the calling agent's current depth at every * start; the tool remains model-visible so runtime policy owns rejection. - * `'provider-managed'` is for an out-of-process provider (ACP) whose - * recursion budget belongs to the child harness's own deployment. + * `'provider-managed'` is for an out-of-process provider whose recursion + * budget belongs to the child runtime or its own deployment. */ maxDepth?: number | 'provider-managed' } From 46db6088436d948aaee311f68151aa5d2d095fe1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 23:42:35 +0800 Subject: [PATCH 16/27] Align Codex provider review evidence --- packages/subagent/subagent-acp/src/run.ts | 44 +++--------- .../subagent-acp/tests/subagent-acp.spec.ts | 68 +------------------ .../subagent-codex/tests/real-product.spec.ts | 6 +- vitest.config.ts | 1 - 4 files changed, 13 insertions(+), 106 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index f264261b0e..fba0403739 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -90,41 +90,15 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -function scaledFiniteMilliseconds(ms: number, scale: number): bigint { - const whole = Math.floor(ms) - return BigInt(whole) * BigInt(scale) - + BigInt(Math.ceil((ms - whole) * scale)) -} - -/** - * Bounded whole-tree exit wait across Node-safe timer segments. - * @param child - process tree whose liveness is authoritative. - * @param ms - positive finite base window in milliseconds. - * @param scale - integer multiplier applied without Number overflow. - */ -async function treeExitsWithin( - child: SubprocessHandle, - ms: number, - scale = 1, -): Promise { - let remaining = scaledFiniteMilliseconds(ms, scale) - while (remaining > 0n) { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - const controller = new AbortController() - const timer = setTimeout(() => { controller.abort() }, Number(chunk)) - try { - if (await child.waitForExit(controller.signal)) return true - } finally { - clearTimeout(timer) - } +/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ +async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, ms) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) } - return false } /** @@ -151,7 +125,7 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe // (this plugin passes disposeGraceMs there), so the bound covers both the // escalation window and an equal confirmation window after the SIGKILL. child.terminate() - if (!(await treeExitsWithin(child, graceMs, 2))) { + if (!(await treeExitsWithin(child, graceMs * 2))) { throw new Error('ACP child process tree did not exit within its dispose windows') } } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 1a8bc577c1..f2cbeda27b 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' @@ -190,72 +190,6 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) }) - it('keeps an oversized finite escalation window instead of collapsing it to one millisecond', async () => { - vi.useFakeTimers() - try { - let waitCount = 0 - let reportExited!: (exited: boolean) => void - const terminate = vi.fn() - const waitForExit = vi.fn((signal?: AbortSignal) => { - waitCount += 1 - return new Promise((resolve) => { - signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) - if (waitCount === 2) reportExited = resolve - }) - }) - const child: Parameters[0] = { - pid: 1, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: {}, - done: new Promise(() => {}), - terminate, - waitForExit, - } - const disposal = disposeAcpChild(child, 0.25, Number.MAX_VALUE) - await vi.advanceTimersByTimeAsync(1) - expect(terminate).toHaveBeenCalledOnce() - expect(waitForExit).toHaveBeenCalledTimes(2) - const escalationSignal = waitForExit.mock.calls[1]?.[0] - await vi.advanceTimersByTimeAsync(1) - expect(escalationSignal?.aborted).toBe(false) - reportExited(true) - await expect(disposal).resolves.toBeUndefined() - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('chains a doubled grace beyond one Node timer segment', async () => { - vi.useFakeTimers() - try { - const waitForExit = vi.fn((signal?: AbortSignal) => new Promise((resolve) => { - signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) - })) - const child: Parameters[0] = { - pid: 1, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: {}, - done: new Promise(() => {}), - terminate: vi.fn(), - waitForExit, - } - const disposal = disposeAcpChild(child, 0.25, 1_073_741_823.75) - const rejected = expect(disposal).rejects.toThrow(/did not exit within its dispose windows/) - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(2_147_483_647) - expect(waitForExit).toHaveBeenCalledTimes(3) - await vi.advanceTimersByTimeAsync(1) - await rejected - } finally { - vi.useRealTimers() - } - }) - it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ argv: ['bash', '-c', 'true'], diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index dd7c0c458f..5f73adaf7e 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -170,7 +170,7 @@ describe('real @openai/codex 0.146.0 product', () => { expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key') expect(responseInputTexts(recorded.body)).toContain(task) await expectQuiescent(harness.handles) - }, 20_000) + }, 60_000) it('cancels a real app-server command approval without executing the command', async () => { const { harness, fixture } = await realHarness([ @@ -206,7 +206,7 @@ describe('real @openai/codex 0.146.0 product', () => { requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', )).toBe(true) await expectQuiescent(harness.handles) - }, 20_000) + }, 60_000) it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { const { harness, fixture } = await realHarness([{ kind: 'hold' }]) @@ -221,5 +221,5 @@ describe('real @openai/codex 0.146.0 product', () => { await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) await run.dispose() await expectQuiescent(harness.handles) - }, 20_000) + }, 60_000) }) diff --git a/vitest.config.ts b/vitest.config.ts index eac84d8d20..ddf7741716 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -54,7 +54,6 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', - 'packages/subagent/subagent-codex/tests/real-product.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', From 34b6cb91eda44b9febd928eaba192aecec63b909 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 00:26:39 +0800 Subject: [PATCH 17/27] Keep provider adapters private --- .../subagent-claude-code/src/process.ts | 7 ++----- .../subagent/subagent-claude-code/src/run.ts | 21 ++++++++++++------- .../tests/subagent-claude-code.spec.ts | 11 +--------- packages/subagent/subagent-codex/src/run.ts | 12 +++++++---- .../subagent/subagent/src/out-of-process.ts | 10 +++------ 5 files changed, 28 insertions(+), 33 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index b8b09216fa..e27e0a6649 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -76,11 +76,8 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { * @param child - shared handle that remains the process-tree authority. */ constructor(private readonly child: SubprocessHandle) { - if (child.stdin === undefined || child.stdout === undefined) { - throw new Error('subagent-claude-code: SDK child requires piped stdin and stdout') - } - this.stdin = child.stdin - this.stdout = child.stdout + this.stdin = child.stdin as NonNullable + this.stdout = child.stdout as NonNullable // EventEmitter gives `error` special throw semantics without a listener. // The SDK attaches its listener synchronously after custom spawn returns, // while this no-op also contains an already-rejected spawn handle. diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 2ad305f155..d5f222b6c4 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -20,7 +20,6 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { settleRunResult, subprocessRunHandle, - thrownError, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -39,6 +38,8 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/* jscpd:ignore-start -- sibling providers intentionally keep product-private + * run inputs and error normalization instead of adding a shared lifecycle owner. */ /** Fully resolved inputs for one official Claude Agent SDK query. */ export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ @@ -53,6 +54,12 @@ export interface ClaudeCodeRunSpec { readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } +function thrown(value: unknown): Error { + /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} +/* jscpd:ignore-end */ + /** * Validate and preserve the one-shot task before crossing the SDK boundary. * @param prompt - task content accepted from the shared subagent service. @@ -131,7 +138,7 @@ export async function disposeClaudeCodeChild( try { query?.close() } catch (error: unknown) { - failures.push(thrownError(error)) + failures.push(thrown(error)) } if (child.pid > 0) { @@ -139,13 +146,13 @@ export async function disposeClaudeCodeChild( try { await child.waitForExit() } catch (error: unknown) { - failures.push(thrownError(error)) + failures.push(thrown(error)) } } try { await child.done } catch (error: unknown) { - failures.push(thrownError(error)) + failures.push(thrown(error)) } const firstFailure = failures[0] @@ -234,7 +241,7 @@ export async function startClaudeCodeRun( await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { throw new AggregateError( - [thrownError(error), thrownError(disposeError)], + [thrown(error), thrown(disposeError)], 'subagent-claude-code: startup failed and CLI cleanup also failed', ) } @@ -243,7 +250,7 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { throw new AggregateError( - [thrownError(error), thrownError(disposeError)], + [thrown(error), thrown(disposeError)], 'subagent-claude-code: startup failed and query cleanup also failed', ) } @@ -252,7 +259,7 @@ export async function startClaudeCodeRun( if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrownError(error) + throw thrown(error) } const publishedQuery = query diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 8c8158fb28..0fa781c082 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -445,7 +445,7 @@ describe('official spawn projection', () => { expect(process.kill('SIGTERM')).toBe(false) }) - it('emits spawn errors and rejects handles without the required pipes', async () => { + it('emits spawn errors', async () => { const child = fakeChild() const process = new ManagedClaudeCodeProcess(child.handle) const errorListener = vi.fn() @@ -459,15 +459,6 @@ describe('official spawn projection', () => { message: 'spawn boom', })) expect(removed).not.toHaveBeenCalled() - - const missingStdin = fakeChild({ stdin: undefined }) - Object.defineProperty(missingStdin.handle, 'stdin', { value: undefined }) - expect(() => new ManagedClaudeCodeProcess(missingStdin.handle)) - .toThrow('requires piped stdin and stdout') - const missingStdout = fakeChild({ stdout: undefined }) - Object.defineProperty(missingStdout.handle, 'stdout', { value: undefined }) - expect(() => new ManagedClaudeCodeProcess(missingStdout.handle)) - .toThrow('requires piped stdin and stdout') }) it('exposes a settled direct-child exit code', async () => { diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 54a2bc1d79..9f52e18f12 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -13,7 +13,6 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { settleRunResult, subprocessRunHandle, - thrownError, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -39,6 +38,11 @@ export interface CodexRunSpec { readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } +function thrown(value: unknown): Error { + /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + /** * Validate and preserve the one-shot task before crossing the process seam. * @param prompt - task content accepted from the shared subagent service. @@ -120,7 +124,7 @@ export async function startCodexRun( 'subagent-codex: app-server exited before the run settled ' + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, )), - (error: unknown) => Promise.reject(thrownError(error)), + (error: unknown) => Promise.reject(thrown(error)), ) // A normal post-result dispose also closes the process. Keep that expected // late rejection observed after the result race has already settled. @@ -145,14 +149,14 @@ export async function startCodexRun( await disposeProcess() } catch (disposeError: unknown) { throw new AggregateError( - [thrownError(error), thrownError(disposeError)], + [thrown(error), thrown(disposeError)], 'subagent-codex: startup failed and app-server cleanup also failed', ) } if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before run publication') } - throw thrownError(error) + throw thrown(error) } const collectOutput = (): ContentBlock[] => wire.collectOutput() diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index eac2125897..d049dba2be 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -119,12 +119,8 @@ export function resolveChildCwd(prefix: string, configured: string | undefined, return assertUsableCwd(prefix, 'parent session cwd', parentCwd) } -/** - * Normalize an unknown thrown value to an Error. - * @param value - the unknown catch binding. - * @returns the original Error or a defensive Error wrapper. - */ -export function thrownError(value: unknown): Error { +/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */ +function toError(value: unknown): Error { // The rejecting surfaces (wire clients, spawn failures) only throw // `Error`s; the `String(value)` arm is a defensive fallback for a non-Error // throw the typed surfaces cannot produce. @@ -168,7 +164,7 @@ export async function settleRunResult(parts: RunResultSettlement): Promise Date: Wed, 5 Aug 2026 01:07:58 +0800 Subject: [PATCH 18/27] Preserve Claude SDK child environment --- .../core-data-structures/subprocess.i18n.yaml | 6 ++-- docs/core-data-structures/subprocess.md | 13 ++++----- docs/core-data-structures/subprocess.zh.md | 13 ++++----- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../subagent-claude-code/src/process.ts | 28 +++++++++---------- .../tests/subagent-claude-code.spec.ts | 20 ++++++++++--- .../subprocess/subprocess-local/src/spawn.ts | 8 +++--- .../subprocess-local/tests/spawn.spec.ts | 13 +++++++++ .../subprocess/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/types.ts | 11 ++++---- 12 files changed, 72 insertions(+), 50 deletions(-) diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index b85701557b..39e7915bea 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -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 -subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2 -subprocess.zh.md: 5befdcdfc9b0e1d2a9adc825b177c90e53269def +# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md +subprocess.md: a97e407290aa12881e7d6e847d51fec13c134135 +subprocess.zh.md: e0b6f3fc2249d7095b5e540f91a0f13b544db978 diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 922e7ad0ee..a97e407290 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -8,7 +8,7 @@ Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subproces ## Managed environment namespace and captured output -`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate entry, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate string entry, while an explicit `undefined` tombstone removes an ordinary ambient value. Each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -115,13 +115,12 @@ interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } ``` diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 5befdcdfc9..e0b6f3fc22 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -8,7 +8,7 @@ ## 受管环境命名空间与捕获的输出 -`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的条目形式到达,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的字符串条目形式到达,而显式的 `undefined` tombstone 会删除普通环境中已有的值。每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -115,13 +115,12 @@ interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5f9af11105..2c62887faf 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2787,7 +2787,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessSpawnSpec', - declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n}', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: NodeJS.ProcessEnv | undefined;\n}', }, { name: 'SubprocessStdinMode', diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index e27e0a6649..32a545bf08 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -10,9 +10,10 @@ import type { SpawnedProcess, SpawnOptions, } from '@anthropic-ai/claude-agent-sdk' -import type { - SubprocessHandle, - SubprocessSpawnSpec, +import { + scrubbedParentEnv, + type SubprocessHandle, + type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' function thrown(value: unknown): Error { @@ -21,19 +22,18 @@ function thrown(value: unknown): Error { } /** - * Convert the SDK environment to the shared subprocess seam's defined-value - * overlay without changing the effective child environment. - * @param env - SDK-composed child environment. - * @returns entries whose values survive Node's subprocess environment. + * Encode the SDK's complete child environment as a subprocess overlay. + * @param env - SDK-composed child environment after its removals and replacements. + * @returns explicit values plus tombstones for surviving ambient names the SDK removed. */ -export function definedEnvironment( +export function sdkEnvironmentOverlay( env: SpawnOptions['env'], -): Record { - const defined: Record = {} - for (const [name, value] of Object.entries(env)) { - if (value !== undefined) defined[name] = value +): NodeJS.ProcessEnv { + const overlay: NodeJS.ProcessEnv = { ...env } + for (const name of Object.keys(scrubbedParentEnv())) { + if (!(name in env)) overlay[name] = undefined } - return defined + return overlay } /** @@ -55,7 +55,7 @@ export function claudeSpawnSpec( stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env: definedEnvironment(options.env), + env: sdkEnvironmentOverlay(options.env), } } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 0fa781c082..3dab6ab5cd 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -31,8 +31,8 @@ import * as claudeCode from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { claudeSpawnSpec, - definedEnvironment, ManagedClaudeCodeProcess, + sdkEnvironmentOverlay, } from '../src/process.ts' import { claudeQueryOptions, @@ -386,6 +386,7 @@ describe('task admission and package contracts', () => { describe('official spawn projection', () => { it('forwards command, arguments, cwd, environment, and signal exactly', () => { + vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value') const signal = new AbortController().signal const options = sdkSpawnOptions({ command: '/official/claude', @@ -394,15 +395,26 @@ describe('official spawn projection', () => { env: { A: 'one', B: undefined, C: 'three' }, signal, }) - expect(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' }) - expect(claudeSpawnSpec(options, 321)).toEqual({ + expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({ + A: 'one', + B: undefined, + C: 'three', + SDK_REMOVED_AMBIENT: undefined, + })) + const spawnSpec = claudeSpawnSpec(options, 321) + expect(spawnSpec).toMatchObject({ argv: ['/official/claude', '--one', 'two'], cwd: '/parent/workspace', stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: 321, signal, - env: { A: 'one', C: 'three' }, }) + expect(spawnSpec.env).toEqual(expect.objectContaining({ + A: 'one', + B: undefined, + C: 'three', + SDK_REMOVED_AMBIENT: undefined, + })) const missingCwd = sdkSpawnOptions() delete missingCwd.cwd expect(() => claudeSpawnSpec( diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 932daa2c59..d3cad162fb 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -26,12 +26,12 @@ import type { /** * Build a child environment: explicit caller entries merge after the scrubbed - * parent base, so a deliberately supplied credential or current `DSH_*` fact - * wins over the scrub that dropped its ambient namesake. - * @param extra - explicit caller entries, merged verbatim after the scrub. + * parent base. A string deliberately restores or overrides an entry; an + * explicit `undefined` tombstone removes an ordinary ambient entry. + * @param extra - explicit caller entries and tombstones, merged after the scrub. * @returns the environment to hand to `spawn` for the child process. */ -export function childEnv(extra?: Readonly>): NodeJS.ProcessEnv { +export function childEnv(extra?: Readonly): NodeJS.ProcessEnv { return { ...scrubbedParentEnv(), ...extra } } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ad2fc0f30a..224476b8d5 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -343,6 +343,19 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(result.stdout.text).toBe('alpha/beta\n') }) + it('lets an explicit tombstone remove an ordinary ambient env entry', async () => { + process.env.SUBPROCESS_TOMBSTONE_PROBE = 'ambient-value' + try { + const result = await finish(spawnSubprocess(spec( + 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"', + { env: { SUBPROCESS_TOMBSTONE_PROBE: undefined } }, + ))) + expect(result.stdout.text).toBe('absent\n') + } finally { + delete process.env.SUBPROCESS_TOMBSTONE_PROBE + } + }) + it('an explicit extra env entry overrides the credential scrub', async () => { // EXPLICIT_OVERRIDE_PASSWORD matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 64f64d65ed..a5e47747f0 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subprocess/subprocess/README.md -README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260 -README.zh.md: dac459a6ed1b92c2354bf0a2cc4e0c23e824154f +README.md: 13c634429bfae9408dc732aea69df673e5da87aa +README.zh.md: fe7b28d3a8f256e0eb9b4cbb98093bac33816fdf diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index c360437bf2..13c634429b 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a string deliberately forwards or overrides a value, while an `undefined` tombstone removes an ordinary ambient entry. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index dac459a6ed..fe7b28d3a8 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清理后合并且不做命名空间校验——字符串会有意转发或覆盖某个值,而 `undefined` tombstone 则会删除普通的环境条目。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)会导入该环境清理定义。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 参见[子进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与[seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index fdfc44b3c2..a21f6ffb0d 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -94,13 +94,12 @@ export interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } /** From 01704b9a3ab7008664be6599e003e4eae499dae1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 01:30:42 +0800 Subject: [PATCH 19/27] Add Codex DeepSeek credentialed e2e --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 11 +- ...ude-code-and-codex-subagent-backends.zh.md | 11 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 4 +- packages/subagent/subagent-codex/README.zh.md | 4 +- .../tests/deepseek-responses-bridge.ts | 190 ++++++++++++++++++ .../subagent-codex/tests/real-deepseek.e2e.ts | 141 +++++++++++++ .../subagent-codex/tests/responses-fixture.ts | 9 +- 9 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts create mode 100644 packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index bde3f3cf11..9331bac1a3 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 3b9fd51632439da5b3c3fd9187de552d6c9ca5e2 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 36be903640ad1c839d45ed1bf5e605f4e4d6e000 +2026-08-04-claude-code-and-codex-subagent-backends.md: 1908eb3466fd6ae6cd14f74e70366d2c7b4c977f +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 6d173133a308be2613cc71b99c5dfd695d18f337 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 3b9fd51632..1908eb3466 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -8,7 +8,7 @@ English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) 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, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official protocol, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot replace the former; a hand-mounted plugin cannot replace the latter. +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required evidence therefore separates three facts: a keyless real-product test proves the official protocol, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. ## Proposal @@ -45,16 +45,20 @@ For command and file approvals, the unattended wire selects a non-approval decis An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. +Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. + ## Claude Code provider The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented. ## Evidence contract -Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. +Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. The Codex evidence pins `@openai/codex@0.146.0` and `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 whole-tree exit. Its Loader e2e resolves `@deepseek-ai/dsh-subagent-codex` by package name, verifies the `codex` registration and `subagent_codex` schema with background omitted, accepts `maxDepth: 'provider-managed'`, and records zero child starts while no `codex` command is available. The npm package is a development dependency for reproducible real-product evidence; production still supplies `codex` on `PATH`. +The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. + The combined contract is complete only when the Claude sibling has equivalent real-product evidence and both public Loader configurations prove the fixed tools use the unchanged common subagent contract. ## Alternatives considered @@ -73,7 +77,7 @@ The combined contract is complete only when the Claude sibling has equivalent re ## Acceptance criteria -Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required real-product specs return exact final answers or explicit failure or cancellation and prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. +Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required keyless real-product specs return exact final answers or explicit failure or cancellation, and separate credentialed e2e tests traverse each production provider and real product to a unique DeepSeek answer; both tiers prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. @@ -81,6 +85,7 @@ The implemented Codex half satisfies this contract for its fixed tool and 0.146. - The product protocols are versioned and may change. Production performs no runtime version probe, so every supported baseline change requires refreshed 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 authority. +- Credentialed e2e runs spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. - 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 interaction denial prevents hidden approval hangs but cannot satisfy tasks that require new permission or human input. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 36be903640..6d173133a3 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -8,7 +8,7 @@ Status: proposed 命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 -产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据会分别证明两个事实:真实产品测试证明官方协议、原生身份验证形态、最终回答和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品。直接发起模型 HTTP 请求或使用产品替身无法取代前者,手工挂载插件则无法取代后者。 +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性证据会分别证明三个事实:无密钥真实产品测试证明官方协议、原生身份验证形态、确定性答案和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务获得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代任一产品运行层级,手工挂载插件则无法取代 Loader 层级。 ## 提案 @@ -45,16 +45,20 @@ fixed tool → shared subagent service → product provider → official product 若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 +Codex 0.146.0 使用 Responses 协议,而 DeepSeek 公开的 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会使用一个仅限回环、仅供测试内部使用的桥接层来完成一次不使用工具的随机数请求:真实 Codex 向该桥接层发送 Responses 请求,桥接层将收到的 Bearer 凭证与提取出的任务转发到固定的 DeepSeek 官方端点,并将真实文本封装进最小的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能证明 Codex 原生连接 DeepSeek Chat Completions。 + ## Claude Code 提供方 Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界,本 Agent Note 才能进入 implemented 状态。 ## 证据契约 -每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一项 Loader 装配 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。 +每个产品都负责覆盖所有分支的包(package)测试、一项必跑的无密钥真实产品测试、一项 Loader 装配 e2e 和一项带密钥的 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。带密钥层级使用仅在运行时提供的密钥启动相同的生产提供方与真实产品,要求从固定的 DeepSeek 官方服务获得一个唯一的随机数,并再次证明完全停稳;只有本地操作方未提供密钥时才会自行跳过,受信 CI 则会对该 secret 执行 preflight 检查。 Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader e2e 会按包名解析 `@deepseek-ai/dsh-subagent-codex`,验证 `codex` 注册与省略后台参数的 `subagent_codex` schema,接受 `maxDepth: 'provider-managed'`,并在环境中没有可用 `codex` 命令时记录零次子级启动。该 NPM 包是用于复现真实产品证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 带密钥 e2e 会注册生产提供方,启动同一个真实 app-server,并通过上文所述、仅供测试内部使用的桥接层请求一个随机数。它会固定外部端点和模型,不存储任何凭证或请求 payload,要求恰好有一个上游响应完成,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待每个受管句柄退出。 + 只有在 Claude 兄弟提供方具备同等的真实产品证据,并且两个公开 Loader 配置都证明固定工具使用未变的通用 subagent 契约时,组合契约才算完整。 ## 曾考虑的替代方案 @@ -73,7 +77,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 ## 验收标准 -两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,并证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 +两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制无密钥真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,独立的带密钥 e2e 测试则会贯穿每个生产提供方与真实产品,取得唯一的 DeepSeek 答案;两个层级都会证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 @@ -81,6 +85,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 - 产品协议受版本约束,且可能发生变化。生产环境不会执行运行时版本探测,因此每次更改受支持的基线都必须刷新兼容性证据。 - 产品原生配置使行为取决于部署环境中安装的产品与账户状态。强制测试会隔离这些输入,而生产环境会有意让产品继续负责它们。 +- 带密钥 e2e 运行会消耗外部 API 配额,并依赖 DeepSeek 官方端点;协议、失败、取消与审批的确定性覆盖仍由无密钥层级负责。 - 每次委派都要承担新建进程和独立模型上下文的开销,且只有最终文本会到达父级。 - 运行失败或被取消时,产品工具或文件产生的副作用不会回滚。 - 拒绝无人值守交互可以防止审批流程暗中挂起,但无法完成需要新权限或人工输入的任务。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index c3d4da77bf..a40167b79a 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-codex/README.md -README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8 -README.zh.md: 110953312162e146f01ef037a40d2f70b136850c +README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece +README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index d7293a0ef3..1dde57e10e 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -47,7 +47,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. ## Model Experience @@ -83,7 +83,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. -- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, and real-product tests. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. - **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 1109533121..cb56281d90 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -47,7 +47,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex,再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 ## 模型体验 @@ -83,7 +83,7 @@ Codex 子任务会在一个全新的临时线程中,以单个轮次接收这 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 -- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消和真实产品测试。 +- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 - **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts b/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts new file mode 100644 index 0000000000..b59738031e --- /dev/null +++ b/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts @@ -0,0 +1,190 @@ +import { createServer } from 'node:http' +import type { + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' +import { completeResponsesEvents } from './responses-fixture.ts' + +const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com' +const MAX_REQUEST_BYTES = 1_048_576 + +/** One running test-only Responses-to-DeepSeek bridge. */ +export interface DeepSeekResponsesBridge { + readonly baseUrl: string + readonly completedRequests: number + close(): Promise +} + +function readRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { + body += chunk + if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) { + request.destroy(new Error('DeepSeek bridge request exceeded its byte limit')) + } + }) + request.on('end', () => { resolve(body) }) + request.on('error', reject) + }) +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +function taskText(body: Record): string { + const input = responseInputTexts(body).join('\n') + if (input.trim().length > 0) return input + return typeof body.instructions === 'string' ? body.instructions : '' +} + +function deepSeekBaseUrl(): string { + const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL) + .replace(/\/+$/, '') + if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) { + throw new Error('Codex DeepSeek e2e requires the official DeepSeek base URL') + } + return configured +} + +async function completeWithDeepSeek( + authorization: string, + task: string, +): Promise { + const response = await fetch(`${deepSeekBaseUrl()}/chat/completions`, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-v4-flash', + messages: [ + { + role: 'system', + content: 'Follow the user instruction and return only the requested nonce.', + }, + { role: 'user', content: task }, + ], + temperature: 0, + max_tokens: 64, + stream: false, + }), + }) + if (!response.ok) { + void response.body?.cancel() + throw new Error(`DeepSeek bridge upstream returned HTTP ${response.status}`) + } + const payload = await response.json() as { + choices?: Array<{ message?: { content?: unknown } }> + } + const content = payload.choices?.[0]?.message?.content + if (typeof content !== 'string' || content.trim().length === 0) { + throw new Error('DeepSeek bridge upstream returned no text') + } + return content +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + server.closeAllConnections() + }) +} + +/** + * Start the single-purpose loopback bridge used by the Codex credentialed e2e. + * @param nonce - unique answer the incoming Responses task must request. + * @returns loopback endpoint, completion count, and close operation. + */ +export async function startDeepSeekResponsesBridge( + nonce: string, +): Promise { + let seenRequests = 0 + let completedRequests = 0 + const openResponses = new Set() + const server = createServer((request, response) => { + openResponses.add(response) + response.on('close', () => { openResponses.delete(response) }) + void (async () => { + if (request.method !== 'POST' || request.url !== '/v1/responses') { + response.writeHead(404) + response.end() + return + } + if (seenRequests !== 0) { + response.writeHead(409) + response.end() + return + } + seenRequests += 1 + const authorization = request.headers.authorization + if ( + typeof authorization !== 'string' + || !authorization.startsWith('Bearer ') + || authorization.length === 'Bearer '.length + ) { + throw new Error('Codex DeepSeek bridge received no bearer credential') + } + const body = JSON.parse(await readRequest(request)) as Record + const task = taskText(body) + if (!task.includes(nonce)) { + throw new Error('Codex DeepSeek bridge request omitted the expected nonce') + } + const text = await completeWithDeepSeek(authorization, task) + completedRequests += 1 + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'x-request-id': 'req_deepseek_e2e', + }) + for (const event of completeResponsesEvents(text)) { + response.write(`data: ${JSON.stringify(event)}\n\n`) + } + response.end('data: [DONE]\n\n') + })().catch(() => { + if (!response.headersSent) { + response.writeHead(502, { 'content-type': 'application/json' }) + } + response.end(JSON.stringify({ error: { message: 'DeepSeek bridge request failed' } })) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('DeepSeek bridge did not acquire a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + get completedRequests(): number { return completedRequests }, + async close(): Promise { + for (const response of openResponses) response.destroy() + await closeServer(server) + }, + } +} diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts new file mode 100644 index 0000000000..29c5536bc0 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -0,0 +1,141 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import { + startDeepSeekResponsesBridge, + type DeepSeekResponsesBridge, +} from './deepseek-responses-bridge.ts' + +const execFileAsync = promisify(execFile) +const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackage = JSON.parse(readFileSync( + join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + 'utf8', +)) as { version: string } + +const roots: string[] = [] +const contexts: Context[] = [] +const bridges: DeepSeekResponsesBridge[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(bridges.splice(0).map(bridge => bridge.close())) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.done).resolves.toHaveProperty('exitCode') + } +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)( + 'Codex provider with real DeepSeek API', + () => { + it('returns one unique nonce through the production provider and real Codex', async () => { + const apiKey = process.env.DEEPSEEK_API_KEY + if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-deepseek-e2e-')) + roots.push(root) + const workspace = join(root, 'workspace') + const codexHome = join(root, 'codex-home') + mkdirSync(workspace) + mkdirSync(codexHome) + const nonce = `DSH_CODEX_DEEPSEEK_${randomUUID()}` + const bridge = await startDeepSeekResponsesBridge(nonce) + bridges.push(bridge) + writeFileSync(join(codexHome, 'config.toml'), [ + 'model = "deepseek-v4-flash"', + 'model_provider = "deepseek-e2e"', + 'approval_policy = "never"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.deepseek-e2e]', + 'name = "DeepSeek E2E bridge"', + `base_url = "${bridge.baseUrl}"`, + 'env_key = "DEEPSEEK_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + const env = { + DEEPSEEK_API_KEY: apiKey, + CODEX_HOME: codexHome, + HOME: root, + XDG_CONFIG_HOME: join(root, 'xdg-config'), + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + env: { ...process.env, ...env }, + }) + expect(codexPackage.version).toBe('0.146.0') + expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + + const parent = { + id: 'deepseek-e2e-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + const run = await ctx.subagents.start('codex', { + prompt: [{ + type: 'text', + text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`, + }], + parent, + signal: new AbortController().signal, + }) + const result = await run.result + await run.dispose() + + expect(result.stopReason).toBe('completed') + const text = result.output + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .trim() + expect(text).toBe(nonce) + expect(bridge.completedRequests).toBe(1) + await expectQuiescent(handles) + }, 180_000) + }, +) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index 940b0d52a0..cac49b9158 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -85,7 +85,12 @@ function responseObject(text: string): Record { } } -function completeEvents(text: string): Record[] { +/** + * Build the minimal Responses SSE event sequence consumed by Codex 0.146.0. + * @param text - exact assistant answer. + * @returns ordered response lifecycle events. + */ +export function completeResponsesEvents(text: string): Record[] { const completed = responseObject(text) const message = (completed.output as Record[])[0]! const part = (message.content as Record[])[0]! @@ -250,7 +255,7 @@ export async function startResponsesFixture( }) if (behavior.kind === 'hold') return const events = behavior.kind === 'complete' - ? completeEvents(behavior.text) + ? completeResponsesEvents(behavior.text) : functionCallEvents(behavior.name, behavior.arguments) for (const event of events) { response.write(`data: ${JSON.stringify(event)}\n\n`) From a49ef7581fbc15341e1c34bc7549d51981dda6c7 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 04:21:29 +0800 Subject: [PATCH 20/27] Fix Codex wire frames and bound grace timers --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- docs/config-catalog.md | 2 +- .../core-data-structures/subprocess.i18n.yaml | 6 +- docs/core-data-structures/subprocess.md | 9 +-- docs/core-data-structures/subprocess.zh.md | 9 +-- docs/module-graph.md | 6 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 2 +- packages/subagent/subagent-codex/README.zh.md | 2 +- packages/subagent/subagent-codex/package.json | 2 + packages/subagent/subagent-codex/src/index.ts | 6 ++ packages/subagent/subagent-codex/src/wire.ts | 31 +++------- .../tests/subagent-codex.spec.ts | 13 ++++ .../subagent/subagent-codex/tsconfig.json | 3 + .../subprocess/subprocess-local/package.json | 2 + .../subprocess/subprocess-local/src/spawn.ts | 60 ++++--------------- .../subprocess-local/tests/spawn.spec.ts | 45 +++----------- .../subprocess/subprocess-local/tsconfig.json | 3 + .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/types.ts | 9 +-- pnpm-lock.yaml | 6 ++ 25 files changed, 100 insertions(+), 136 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 9331bac1a3..0045a33382 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 1908eb3466fd6ae6cd14f74e70366d2c7b4c977f -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 6d173133a308be2613cc71b99c5dfd695d18f337 +2026-08-04-claude-code-and-codex-subagent-backends.md: 0afeae6269fcff588461dd58221c376a257c1f1b +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c754f5f436fd76b85bd15e45f9673d8bfe61cce6 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 1908eb3466..0afeae6269 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -35,7 +35,7 @@ fixed tool → shared subagent service → product provider → official product ## Codex provider -`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 6d173133a3..c754f5f436 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -35,7 +35,7 @@ fixed tool → shared subagent service → product provider → official product ## Codex 提供方 -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2c1dd2a33a..85114bca5a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1580,7 +1580,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-codex/src/index.ts:29`](../packages/subagent/subagent-codex/src/index.ts) +Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) ## `@deepseek-ai/dsh-subagent-dsh-sdk` diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index b85701557b..98b8f06a56 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -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 -subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2 -subprocess.zh.md: 5befdcdfc9b0e1d2a9adc825b177c90e53269def +# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md +subprocess.md: 9e1a73e0b807347f6c87ab5a589b7bb4b763df0e +subprocess.zh.md: 769d59e6610ffe2114984797b4b84afafc472262 diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 922e7ad0ee..9e1a73e0b8 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -101,10 +101,11 @@ interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 5befdcdfc9..769d59e661 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -101,10 +101,11 @@ interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/docs/module-graph.md b/docs/module-graph.md index fc6cb07cb4..39693da6a7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -343,6 +343,7 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_credentials @@ -1009,6 +1010,7 @@ flowchart TD pkg_subagent_codex --> pkg_session pkg_subagent_codex --> pkg_subagent pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -1124,7 +1126,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1254,7 +1256,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index a40167b79a..97c2b9f705 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-codex/README.md -README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece -README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f +README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f +README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 1dde57e10e..c25ee90edf 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index cb56281d90..10c8fcc47a 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -23,7 +23,7 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 10bf7ee5f4..cc1d016225 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@openai/codex": "0.146.0", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 00fe95d817..09ece5e22e 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { assertPositiveFinite, NO_START_CAPABILITIES, @@ -85,5 +86,10 @@ export function apply(ctx: Context, config: Config): void { 'disposeGraceMs', resolved.disposeGraceMs, ) + if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index f933c1a04b..460eeae877 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -14,22 +14,6 @@ import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' type JsonObject = Record -interface Deferred { - readonly promise: Promise - readonly resolve: (value: T) => void - readonly reject: (reason?: unknown) => void -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((settle, fail) => { - resolve = settle - reject = fail - }) - return { promise, resolve, reject } -} - function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) @@ -98,11 +82,11 @@ async function raceAbort(pending: Promise, signal: AbortSignal): Promise() + private readonly fatal = Promise.withResolvers() private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined - private turnCompleted: Deferred | undefined + private turnCompleted: PromiseWithResolvers | undefined private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject @@ -193,7 +177,7 @@ export class CodexAppServerWire { signal: AbortSignal, cancelled: () => boolean, ): Promise { - const completion = deferred() + const completion = Promise.withResolvers() this.turnCompleted = completion const threadId = this.threadId as string const response = object(await this.guarded(this.transport.request('turn/start', { @@ -340,7 +324,8 @@ export class CodexAppServerWire { private handleNotification(method: string, params: JsonObject): void { if (method === 'turn/started') { - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'turn/started thread id') + if (threadId !== this.threadId) return const turn = object(params.turn, 'turn/started turn') if (this.turnCompleted !== undefined && this.turnId === undefined) { this.observePendingTurnId(string(turn.id, 'turn/started turn id')) @@ -348,7 +333,8 @@ export class CodexAppServerWire { return } if (method === 'item/completed') { - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'item/completed thread id') + if (threadId !== this.threadId) return const id = string(params.turnId, 'item/completed turn id') if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { @@ -373,7 +359,8 @@ export class CodexAppServerWire { return } if (method !== 'turn/completed') return - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'turn/completed thread id') + if (threadId !== this.threadId) return const turn = object(params.turn, 'turn/completed turn') const id = string(turn.id, 'turn/completed turn id') const turnCompleted = this.turnCompleted diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 181ddb919e..01cc28e59e 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -6,6 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, SubprocessOutcome, @@ -294,6 +295,8 @@ describe('task admission and package contracts', () => { await expect(ctx.plugin(codex, { disposeGraceMs })) .rejects.toThrow('disposeGraceMs must be a positive finite number') } + await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) await ctx.fiber.dispose() }) @@ -513,6 +516,16 @@ describe('CodexAppServerWire', () => { } }) + it('fails closed when terminal notification params are not an object', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send({ method: 'turn/completed', params: null }) + await expect(result).rejects.toThrow('invalid turn/completed thread id') + wire.close() + }) + it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal, () => false) diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json index 6034bf5fbe..b9f33967ba 100644 --- a/packages/subagent/subagent-codex/tsconfig.json +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 72ff50c422..871b4cfac6 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -29,11 +29,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 932daa2c59..fda32be42a 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { CollectedOutput, SubprocessCollect, @@ -55,47 +56,6 @@ function sleepTick(): Promise { return sleepMs(15) } -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Schedule a positive finite millisecond delay across as many Node-safe timer - * segments as necessary. Fractional milliseconds round up so a grace never - * expires earlier than configured. - * @param delayMs - positive finite delay in milliseconds. - * @param callback - work to run after the complete delay. - * @returns a handle that cancels the active segment and all future segments. - */ -export function scheduleFiniteTimeout( - delayMs: number, - callback: () => void, -): { cancel(): void } { - let remaining = BigInt(Math.ceil(delayMs)) - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - callback() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - cancel(): void { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - let spillCounter = 0 let defaultSpillDir: string | undefined @@ -339,8 +299,12 @@ function signalTree( * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. * @param internals - test-only spill-directory, platform, and taskkill overrides. * @returns live subprocess handle. + * @throws when `graceMs` cannot be represented by one Node timer. */ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { + if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } const spillDir = internals.spillDir ?? privateSpillDir() const platform = internals.platform ?? process.platform const taskkill = internals.taskkill ?? taskkillProcessTree @@ -382,7 +346,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') const stderrCollector = collectStream(errMode, child.stderr, 'stderr') - let graceTimer: ReturnType | undefined + let graceTimer: ReturnType | undefined let treeExitObserved = false let treeExitObservation: Promise | undefined let settled = false @@ -426,7 +390,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter treeExitObservation ??= (async () => { while (treeAlive()) await sleepTick() treeExitObserved = true - graceTimer?.cancel() + if (graceTimer !== undefined) clearTimeout(graceTimer) graceTimer = undefined })() return treeExitObservation @@ -457,7 +421,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // kill() re-probes tree liveness before force-killing. It stays ref'd: // the pending SIGKILL is a commitment, and a parent exiting before it // fires would orphan a trapped survivor. Self-bounds at graceMs. - graceTimer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) + graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } // The caller owns timeout classification; this layer only reacts to abort. @@ -472,7 +436,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const done = new Promise((resolve, reject) => { - let pipeDrainTimer: ReturnType | undefined + let pipeDrainTimer: ReturnType | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return settled = true @@ -495,15 +459,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // A surviving descendant that inherited a pipe must not hold the // outcome open indefinitely: after exit, the same bounded grace that // governs kills also bounds the close wait. - pipeDrainTimer = scheduleFiniteTimeout(spec.graceMs, () => { + pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) - }) + }, spec.graceMs) }) child.on('close', settle) function cleanup(): void { // graceTimer deliberately NOT cleared: the SIGKILL escalation must be // able to reach tree survivors after the direct child settles. - pipeDrainTimer?.cancel() + if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) spec.signal?.removeEventListener('abort', onAbort) } }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ad2fc0f30a..34157f8fc0 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -5,11 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import { killGroup, OutputCollector, - scheduleFiniteTimeout, spawnSubprocess, taskkillProcessTree, } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -107,31 +107,15 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } -describe('scheduleFiniteTimeout', () => { - it('rounds fractions up, chains Node-safe segments, and cancels idempotently', async () => { - vi.useFakeTimers() - try { - const fired = vi.fn() - const chained = scheduleFiniteTimeout(2_147_483_647.25, fired) - await vi.advanceTimersByTimeAsync(2_147_483_647) - expect(fired).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(fired).toHaveBeenCalledOnce() - chained.cancel() - - const cancelled = vi.fn() - const timer = scheduleFiniteTimeout(0.25, cancelled) - timer.cancel() - timer.cancel() - await vi.advanceTimersByTimeAsync(1) - expect(cancelled).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) -}) - describe('spawnSubprocess', () => { + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1])( + 'rejects an invalid grace before spawning: %s', + (graceMs) => { + expect(() => spawnSubprocess(spec('true', { graceMs }))) + .toThrow(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + }, + ) + it('captures stdout on success', async () => { const result = await finish(spawnSubprocess(spec('echo hello'))) expect(result.exitCode).toBe(0) @@ -194,17 +178,6 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) - it('cancels a larger-than-Node escalation timer once SIGTERM removes the tree', async () => { - const running = spawnSubprocess(spec('echo ready; sleep 60', { - graceMs: Number.MAX_VALUE, - })) - await waitForStdout(running, 'ready\n') - running.terminate() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - await expect(running.waitForExit()).resolves.toBe(true) - }) - it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => { const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`) const graceMs = 160 diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5a8dea211b..5272a4f78d 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 64f64d65ed..dd8908dc7d 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subprocess/subprocess/README.md -README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260 -README.zh.md: dac459a6ed1b92c2354bf0a2cc4e0c23e824154f +README.md: a8061e67a5346fce785f3c9fb27c8f885bf99921 +README.zh.md: 0e6a7e9192eb7028ea951681f5e013f8012a4121 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index c360437bf2..a8061e67a5 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -7,7 +7,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes ## Contract - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. -- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). Grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so the implementation can represent it with one Node timer instead of accepting a value that Node collapses to one millisecond. `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index dac459a6ed..0e6a7e9192 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -7,7 +7,7 @@ ## 契约 - `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 -- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 +- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样实现便可用一个 Node 定时器表示它,而不会接受会被 Node 折叠为 1 毫秒的值。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index fdfc44b3c2..3dbe401b75 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -80,10 +80,11 @@ export interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c10c897164..a817239885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5119,6 +5119,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@openai/codex': specifier: 0.146.0 version: 0.146.0 @@ -5460,6 +5463,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From 075b6b0756c2491e3f5f8bf9f4a7bddb67d4568a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 04:45:11 +0800 Subject: [PATCH 21/27] fix(subagent-codex): drop unused initialize metadata gate --- packages/subagent/subagent-codex/src/wire.ts | 3 +-- packages/subagent/subagent-codex/tests/subagent-codex.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 460eeae877..ca24fdcadf 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -130,7 +130,7 @@ export class CodexAppServerWire { * @param signal - unpublished-start cancellation. */ async initialize(signal: AbortSignal): Promise { - const response = object(await this.guarded(this.transport.request('initialize', { + object(await this.guarded(this.transport.request('initialize', { clientInfo: { name: 'deepseek-harness', title: 'DeepSeek Harness', @@ -141,7 +141,6 @@ export class CodexAppServerWire { requestAttestation: false, }, }, signal), signal), 'initialize response') - string(response.userAgent, 'initialize userAgent') this.transport.notify('initialized') await this.guarded(this.transport.flush(), signal) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 01cc28e59e..52d2f558c3 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -934,8 +934,8 @@ describe('run lifecycle and quiescence', () => { const child = fakeChild() const starting = startCodexRun(request(), runSpec(child)) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: '' }) - await expect(starting).rejects.toThrow('initialize userAgent') + child.peer.respond(initialize, null) + await expect(starting).rejects.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) }) From 0ccd847ef30cd9c09d6179e1eaf58e7113c4d1db Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 05:24:23 +0800 Subject: [PATCH 22/27] Fix timer bounds and ACP teardown ownership --- ...07-27-dispose-ladder-to-consumer.i18n.yaml | 6 +-- .../2026-07-27-dispose-ladder-to-consumer.md | 4 +- ...026-07-27-dispose-ladder-to-consumer.zh.md | 4 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- docs/capability-seams.md | 5 +-- docs/config-catalog.md | 15 +++---- packages/bash/bash-local/README.i18n.yaml | 4 +- packages/bash/bash-local/README.md | 2 +- packages/bash/bash-local/README.zh.md | 2 +- packages/bash/bash-local/src/index.ts | 7 +++- .../bash/bash-local/tests/executor.spec.ts | 3 ++ packages/bash/pwsh-local/README.i18n.yaml | 4 +- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/pwsh-local/src/index.ts | 7 +++- .../bash/pwsh-local/tests/executor.spec.ts | 3 ++ packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 2 +- packages/fs/tool-fs-search/README.zh.md | 2 +- packages/fs/tool-fs-search/package.json | 2 + packages/fs/tool-fs-search/src/index.ts | 6 ++- .../fs/tool-fs-search/tests/tools.spec.ts | 12 ++++++ packages/fs/tool-fs-search/tsconfig.json | 3 ++ .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 8 ++-- packages/subagent/subagent-acp/README.zh.md | 8 ++-- packages/subagent/subagent-acp/package.json | 2 + packages/subagent/subagent-acp/src/index.ts | 12 +++--- packages/subagent/subagent-acp/src/run.ts | 25 +++++------ .../subagent-acp/tests/subagent-acp.spec.ts | 41 +++++++------------ packages/subagent/subagent-acp/tsconfig.json | 3 ++ packages/subagent/subagent-codex/package.json | 1 + pnpm-lock.yaml | 9 ++++ scripts/gen-doc-graphs.ts | 4 +- 36 files changed, 130 insertions(+), 96 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml index bd5964f1a4..ec9558da89 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml @@ -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-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 -2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +2026-07-27-dispose-ladder-to-consumer.md: e9af88e8e7ef962213a74e96a249241cbe8d5994 +2026-07-27-dispose-ladder-to-consumer.zh.md: ed242c4f9fd3b44311e6aae25c176617cdf9e897 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md index 97b551ff50..e9af88e8e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -10,7 +10,7 @@ English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) ## Decision -The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call `terminate()`, whose SIGTERM→spec-grace→SIGKILL escalation already owns the signal timer, and await an unbounded `waitForExit()` for the subprocess owner's whole-tree exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real tree exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. ## Alternatives considered @@ -20,4 +20,4 @@ The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(c ## Consequences -Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy. +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns the termination window and final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false before escalation and an unbounded whole-tree join after it) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md index b6849ad393..ed242c4f9f 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已拥有信号定时器),再无界等待 `waitForExit()`,由子进程责任方证明整棵进程树已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认进程树真正退出所需的停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 ## 曾考虑的替代方案 @@ -20,4 +20,4 @@ Status: implemented ## 后果 -买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止时间窗与最终的整树退出等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 返回假,升级后无界等待整棵进程树退出),而非组合后的策略。 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 0045a33382..d63b100325 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 0afeae6269fcff588461dd58221c376a257c1f1b -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c754f5f436fd76b85bd15e45f9673d8bfe61cce6 +2026-08-04-claude-code-and-codex-subagent-backends.md: fc5e8b6dc5a109fe325530646348ccffaf5458ac +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f68c487ee8494908e5e7748b5881a888af688c82 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 0afeae6269..fc5e8b6dc5 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -39,7 +39,7 @@ fixed tool → shared subagent service → product provider → official product Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index c754f5f436..f68c487ee8 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -39,7 +39,7 @@ fixed tool → shared subagent service → product provider → official product 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index cc32e9057e..bb19ff7cb8 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -104,7 +104,6 @@ flowchart LR pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] pkg_subagent_codex["subagent-codex"] - pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] pkg_pwsh_local["pwsh-local"] @@ -137,6 +136,7 @@ flowchart LR svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] + pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_tool_subagent_control["tool-subagent-control"] pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] @@ -323,7 +323,6 @@ flowchart LR svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp svc_subprocess --> pkg_subagent_codex - svc_subprocess --> pkg_subagent_dsh_sdk svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -383,7 +382,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | - | The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`pwsh-local`](../packages/bash/pwsh-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`bash-env`](../packages/bash/bash-env) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 85114bca5a..ac181d8d78 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -223,7 +223,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } ``` @@ -992,7 +992,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -1550,10 +1550,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent escalates to a signal. + * before the parent escalates to a signal. Must not exceed + * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Termination confirmation window (ms), including forced exit on every platform. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -1561,7 +1562,7 @@ export interface Config { export type PermissionPolicy = 'allow' | 'reject' ``` -Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) +Source: [`packages/subagent/subagent-acp/src/index.ts:27`](../packages/subagent/subagent-acp/src/index.ts) ## `@deepseek-ai/dsh-subagent-codex` @@ -1814,7 +1815,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number @@ -1823,7 +1824,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:72`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:73`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index e72432de87..49d3af2a85 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/bash/bash-local/README.md -README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 -README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17 +README.md: 9b01f1ce9d046062e55037ba13e8f74b95ef1161 +README.zh.md: fde3eb77f72337d54c91c1a40fb87cb3d13652e7 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 694b7a7686..9b01f1ce9d 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -25,7 +25,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). - **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index c56543f269..fde3eb77f7 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -25,7 +25,7 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: - **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。 -- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 - **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 0f5a1b4e4d..09d21f11d0 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -13,7 +13,7 @@ import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' /** * Model-friendly environment overrides: disable colors, pagers, and @@ -47,7 +47,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } @@ -101,6 +101,9 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) + if (this.config.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + } } /** diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 1395b48958..af5566086f 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) @@ -70,6 +71,8 @@ describe('LocalBashExecutor.run', () => { await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) + await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) const { bash } = await setup() expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 1b78ca75f9..d4ac605341 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/bash/pwsh-local/README.md -README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf -README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62 +README.md: 35ff7dba97b8109a99e649051166604fb279c408 +README.zh.md: fdba5baf00b50ce5384d9209c8fa01f42642491d diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index 9deba9c1b6..35ff7dba97 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. - **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. - **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index e45c820e1d..fdba5baf00 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -30,7 +30,7 @@ - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 - **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。 -- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 - **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 316d2c8651..20ecffd969 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -18,7 +18,7 @@ import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolvePwshPath } from './resolve.ts' /* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */ @@ -62,7 +62,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -129,6 +129,9 @@ export class PwshLocalExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) + if (this.config.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + } this.pwshPath = resolvePwshPath(this.config.pwshPath) } diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 4552f2eeec..c7da16c44c 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -19,6 +19,7 @@ import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPa import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SubprocessService from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) @@ -187,6 +188,8 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) + await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) const { bash } = await setup() expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index a8e2222998..374c5e4f05 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/fs/tool-fs-search/README.md -README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a -README.zh.md: 42b123d5c47d8f48bc21b6f9bed4905372ca8625 +README.md: 32fa61e3bb09b2166499003953a5631a93baf73b +README.zh.md: 766ac01dadde34ef1f9ef9b9f6abfb719ba3d0a6 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 78ffa069e5..32fa61e3bb 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -30,7 +30,7 @@ The binary ships with the package on every supported platform (macOS/Linux/Windo | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | | `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | -| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. | +| `graceMs` | `3000` | Positive terminate-escalation grace the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | | `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). | ## Tools diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 42b123d5c4..766ac01dad 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -30,7 +30,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | | `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | -| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 | +| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期须为正值;超过后搜索以 `SEARCH_ABORTED` 失败;该宽限期不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | | `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 | ## 工具 diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 8953aea77a..1884ae913c 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-spill": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -51,6 +52,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 7f8e43cb73..cf0a8db066 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -28,6 +28,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' @@ -82,7 +83,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number @@ -130,6 +131,9 @@ export async function apply(ctx: Context, config: Config): Promise { assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) assertPositiveInteger('graceMs', resolved.graceMs) + if (resolved.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + } assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) applyGlobTool(ctx, { diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 9a8fa991ff..e9ae5588ee 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -18,6 +18,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecution, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { rgPath } from '@vscode/ripgrep' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' @@ -309,6 +310,17 @@ describe('config validation', () => { await ctx.plugin(FakeSubprocess) await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`)) }) + + it('rejects a grace beyond the Node timer range at load', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeSubprocess) + await expect(ctx.plugin(ToolFsSearch, { + ...DEFAULT_CONFIG, + graceMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + }) }) describe('command construction (plain argv)', () => { diff --git a/packages/fs/tool-fs-search/tsconfig.json b/packages/fs/tool-fs-search/tsconfig.json index ad0c703117..76644e4523 100644 --- a/packages/fs/tool-fs-search/tsconfig.json +++ b/packages/fs/tool-fs-search/tsconfig.json @@ -36,6 +36,9 @@ { "path": "../../spill/spill" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index e24b5f2f07..5721ee2d46 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-acp/README.md -README.md: efcc77c442714a83631d009efb712fa7b8f5dfa0 -README.zh.md: 216d66ed5d259c1bf2ea2383356df97be3e5ced1 +README.md: ead942668f19c44f552d3feb556c39c13b656dea +README.zh.md: e1204ff62f23c8586852111687574a60b0bb7302 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index efcc77c442..ead942668f 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -30,8 +30,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. | | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | -| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. | -| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. | +| `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 216d66ed5d..e1204ff62f 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 @@ -30,8 +30,8 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 | | `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | -| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间。 | -| `disposeGraceMs` | `3000` | 终止后的退出确认宽限时间;POSIX 在 SIGTERM 后、SIGKILL 前也会等待同样时长。 | +| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | +| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包(package)没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index b06a5e50ea..a8509abfac 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 8616f7ae94..fa7c031760 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -17,6 +17,7 @@ import type { SubagentProvider, SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' @@ -54,10 +55,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent escalates to a signal. + * before the parent escalates to a signal. Must not exceed + * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Termination confirmation window (ms), including forced exit on every platform. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -72,10 +74,10 @@ export const Config: z = z.object({ disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) -/** A dispose grace must be a positive finite number (it bounds the teardown wait). */ +/** A dispose grace must fit the single Node timer that owns its teardown tier. */ function assertPositiveFinite(name: string, value: number): void { - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`subagent-acp: ${name} must be a positive finite number`) + if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`subagent-acp: ${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index fba0403739..f3e155e649 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -62,9 +62,9 @@ export interface AcpRunSpec { */ disposeEofGraceMs: number /** - * Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after - * `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin - * fills this from its `disposeGraceMs` config. + * Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX + * waits this long after `SIGTERM` before `SIGKILL`, while Windows + * force-terminates directly. The plugin fills it from `disposeGraceMs`. */ disposeGraceMs: number /** @@ -105,14 +105,12 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { +export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise { // A spawn failure has no process to tear down; observe the rejection so // disposal in a finally block cannot surface it as unhandled. if (child.pid <= 0) { @@ -121,13 +119,10 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe } child.stdin?.end() if (await treeExitsWithin(child, eofGraceMs)) return - // terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace - // (this plugin passes disposeGraceMs there), so the bound covers both the - // escalation window and an equal confirmation window after the SIGKILL. + // terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is + // the process owner's exit proof, not a second derived grace that can overflow. child.terminate() - if (!(await treeExitsWithin(child, graceMs * 2))) { - throw new Error('ACP child process tree did not exit within its dispose windows') - } + await child.waitForExit() } /** @@ -235,7 +230,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs)) + const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs)) // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index f2cbeda27b..6c6c238e74 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -7,6 +7,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -147,7 +148,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { const child = bash('read -r line; exit 0') - await disposeAcpChild(child, 5_000, 200) + await disposeAcpChild(child, 5_000) const outcome = await child.done expect(outcome.exitCode).toBe(0) expect(outcome.signal).toBeNull() @@ -155,7 +156,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => { const child = bash('sleep 60') - await disposeAcpChild(child, 100, 5_000) + await disposeAcpChild(child, 100) const outcome = await child.done expect(outcome.signal).toBe('SIGTERM') }) @@ -166,30 +167,11 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', while (!child.collected.stdout!.readFrom(0).text.includes('armed')) { await new Promise(resolve => setTimeout(resolve, 10)) } - await disposeAcpChild(child, 50, 2_000) + await disposeAcpChild(child, 50) const outcome = await child.done expect(outcome.signal).toBe('SIGKILL') }) - it('throws when the tree survives even the escalation window', async () => { - // A handle whose tree never exits (waitForExit only ever aborts): the - // ladder must fail loud instead of resolving over survivors. Built as a - // stub because the ladder composes only public verbs. - const never: Parameters[0] = { - pid: 1, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: {}, - done: new Promise(() => {}), - terminate: () => {}, - waitForExit: (signal?: AbortSignal) => new Promise((resolve) => { - signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) - }), - } - await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) - }) - it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ argv: ['bash', '-c', 'true'], @@ -197,7 +179,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, graceMs: 200, }) - await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined() + await expect(disposeAcpChild(child, 1_000)).resolves.toBeUndefined() await expect(child.done).rejects.toThrow() }) }) @@ -721,13 +703,20 @@ describe('dsh-subagent-acp', () => { } }) - it('rejects a non-positive dispose grace at load', async () => { - for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { + it('rejects a dispose grace outside the Node timer range at load', async () => { + for (const bad of [ + { disposeEofGraceMs: 0 }, + { disposeGraceMs: -1 }, + { disposeEofGraceMs: Number.NaN }, + { disposeGraceMs: Number.POSITIVE_INFINITY }, + { disposeEofGraceMs: MAX_TIMER_DELAY_MS + 1 }, + { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }, + ]) { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) - .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/) + .rejects.toThrow(new RegExp(`subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)) await ctx.fiber.dispose() } }) diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 2d60858d4a..c7966ddc6f 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/loader-smoke" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index cc1d016225..b6c9853227 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -40,6 +40,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a817239885..ee37a62d76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3143,6 +3143,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -5082,6 +5085,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -5092,6 +5098,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 8e2562d8b9..5f68d91a8c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], - note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex'], + note: 'The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', From e2b73d278ceda2b6dedb590002d5deca390ef042 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 05:34:32 +0800 Subject: [PATCH 23/27] Regenerate module graph --- docs/module-graph.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 39693da6a7..6bd69c01c7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -720,6 +720,7 @@ flowchart TD pkg_tool_fs_search --> pkg_spill pkg_tool_fs_search --> pkg_subprocess pkg_tool_fs_search --> pkg_system_prompt + pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_invariants @@ -912,6 +913,7 @@ flowchart TD pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess + pkg_subagent_acp --> pkg_timeout pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -1211,7 +1213,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1242,7 +1244,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | From 13d7318dace1316e82b91a397c338aaff669a5b5 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 06:12:42 +0800 Subject: [PATCH 24/27] Fix provider diagnostics and ownership docs --- packages/subagent/README.i18n.yaml | 4 ++-- packages/subagent/README.md | 2 +- packages/subagent/README.zh.md | 2 +- packages/subagent/subagent-codex/src/index.ts | 8 ++++++- .../tests/subagent-codex.spec.ts | 21 +++++++++++++++++++ packages/subprocess/README.i18n.yaml | 4 ++-- packages/subprocess/README.md | 6 +++--- packages/subprocess/README.zh.md | 6 +++--- 8 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 875a9c93a7..41b9c1ae5f 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/README.md -README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5 -README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd +README.md: 4008bbb2a69c058fc49b33fd3e7b2e2b426d1197 +README.zh.md: 1ef95a5bdfaa68205ddf428b1f3ec177e4dc39f7 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index abe1432d3c..4008bbb2a6 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -17,6 +17,6 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) | -The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other). The out-of-process `subagent-acp` / `subagent-codex` backends spawn through the [`subprocess/`](../subprocess/README.md) seam, which owns credential scrubbing, termination escalation, and whole-tree exit observation; `subagent-dsh-sdk` instead delegates process creation and teardown to the TypeScript SDK client that owns its transport, while reusing the seam's credential scrub. Tests replace only external or nondeterministic product boundaries with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 3df2b6c62d..1ef95a5bdf 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -17,6 +17,6 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) | | `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) | -接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖)。进程外 `subagent-acp` / `subagent-codex` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程,该 seam 拥有凭据清除、终止升级和整棵进程树的退出观测;`subagent-dsh-sdk` 则将进程创建和拆卸委托给拥有自身传输的 TypeScript SDK 客户端,同时复用该 seam 的凭据清除机制。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 09ece5e22e..23077e3b54 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -55,11 +55,17 @@ class CodexProvider implements SubagentProvider { ) {} start(request: ResolvedSubagentStartRequest) { + const parentCwd = request.parent.session.header.cwd + if (parentCwd === undefined) { + throw new Error( + 'subagent-codex: no working directory for the child — delegate from a parent session that has one', + ) + } const spec: CodexRunSpec = { cwd: resolveChildCwd( 'subagent-codex', undefined, - request.parent.session.header.cwd, + parentCwd, ), env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 52d2f558c3..00e3a7e8d6 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -300,6 +300,27 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('requires a parent session cwd without suggesting unsupported config', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + await ctx.plugin(codex, {}) + + await expect(ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'task' }], + parent: { + id: 'parent-without-cwd', + session: { header: {} }, + } as unknown as Agent, + signal: new AbortController().signal, + })).rejects.toThrow( + 'subagent-codex: no working directory for the child — delegate from a parent session that has one', + ) + expect(spawn).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + it('keeps the namespace export shape and package-owned empty invariant', async () => { expect('default' in codex).toBe(false) expect(codex.name).toBe('subagent-codex') diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 5f310fc9b3..7c9640d3c9 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subprocess/README.md -README.md: 64e4740c7ac2706e45bb3517891504bf31a6109b -README.zh.md: 615b492fa7da5b12a7d0cecfe98fc4f1eb94b504 +README.md: ca220a3b715a130c89f8667542b9cd94baa90b3e +README.zh.md: 1d3d58a1d593bce7bb99c38cdc78b5088971f108 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 64e4740c7a..ca220a3b71 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and whole-tree exit observation. Command defaulting, shell semantics, deadlines, cooperative shutdown sequencing, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). | Package | ctx key | Role | |---|---|---| -| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, and whole-tree exit observation | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 615b492fa7..1d3d58a1d5 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -这里集中提供受管子进程树的 spawn 能力:完整指定的 spawn spec,采用 Node 风格、按流划分的 stdio 处置方式(disposition),包括原始管道、inherit、附带 spill 文件的有界尾部保留收集;harness 中所有 spawn 调用方共用的凭据清除机制;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及协作式 dispose(资源释放)阶梯。命令默认值补全、shell 语义、时限、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[subprocess seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +这里集中提供受管子进程树的 spawn 能力:完整指定的 spawn spec,采用 Node 风格、按流划分的 stdio 处置方式(disposition),包括原始管道、inherit、附带 spill 文件的有界尾部保留收集;harness 中所有 spawn 调用方共用的凭据清除机制;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及整棵进程树的退出观测。命令默认值补全、shell 语义、时限、协作式关闭顺序、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[subprocess seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 | 包(package) | ctx 键 | 角色 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | -| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | +| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送,以及整棵进程树的退出观测 | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 From 2e00a93edaa0b3bf846ea0556d30e3f551492c6d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 07:03:04 +0800 Subject: [PATCH 25/27] fix(subagent-codex): support Windows command shims --- ...code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 19 ++++++++++++++++++- .../tests/subagent-codex.spec.ts | 16 +++++++++++++++- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index d63b100325..790f24a0d4 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: fc5e8b6dc5a109fe325530646348ccffaf5458ac -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f68c487ee8494908e5e7748b5881a888af688c82 +2026-08-04-claude-code-and-codex-subagent-backends.md: f0b642d488ce585879deab91bd152b2ca4e68ea6 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f3d0010163b402e79c78188d4686963abfa21569 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index fc5e8b6dc5..f0b642d488 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -39,7 +39,7 @@ fixed tool → shared subagent service → product provider → official product Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index f68c487ee8..f3d0010163 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -39,7 +39,7 @@ fixed tool → shared subagent service → product provider → official product 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 9f52e18f12..ecf71fb2ba 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -24,6 +24,23 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** + * Resolve the fixed app-server command for a platform. + * + * Windows npm and pnpm installs expose `codex.cmd`, which requires `cmd.exe`; + * the argv is constant so no task or configuration text enters the + * shell boundary. + * @param platform - host platform used to select the executable boundary. + * @returns argv for the fixed Codex app-server command. + */ +export function codexAppServerArgv( + platform: NodeJS.Platform = process.platform, +): string[] { + return platform === 'win32' + ? ['cmd.exe', '/d', '/s', '/c', 'codex', 'app-server', '--stdio'] + : ['codex', 'app-server', '--stdio'] +} + /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ @@ -106,7 +123,7 @@ export async function startCodexRun( } const child = spec.spawn({ - argv: ['codex', 'app-server', '--stdio'], + argv: codexAppServerArgv(), cwd: spec.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: spec.disposeGraceMs, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 00e3a7e8d6..37c8649f8b 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -15,6 +15,7 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { + codexAppServerArgv, DEFAULT_DISPOSE_GRACE_MS, disposeCodexChild, startCodexRun, @@ -259,6 +260,19 @@ function turnCompleted( } describe('task admission and package contracts', () => { + it('resolves the fixed app-server command through the Windows npm shim boundary', () => { + expect(codexAppServerArgv('win32')).toEqual([ + 'cmd.exe', + '/d', + '/s', + '/c', + 'codex', + 'app-server', + '--stdio', + ]) + expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio']) + }) + it('accepts one or more text blocks and rejects empty or non-text tasks', () => { expect(textTask([ { type: 'text', text: 'one' }, @@ -868,7 +882,7 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) const run = await starting expect(spawn).toHaveBeenCalledWith({ - argv: ['codex', 'app-server', '--stdio'], + argv: codexAppServerArgv(), cwd: process.cwd(), stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: DEFAULT_DISPOSE_GRACE_MS, From 6d8095825b9bb1ed7703dd011ef0eeb98b7170bc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 07:32:27 +0800 Subject: [PATCH 26/27] fix(subagent-claude-code): tighten provider evidence --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent-claude-code/src/index.ts | 8 +++- .../tests/messages-fixture.ts | 2 +- .../tests/real-product.spec.ts | 47 ++++++++++++++++++- .../tests/subagent-claude-code.spec.ts | 11 +++++ scripts/gen-third-party-notices.spec.ts | 4 +- scripts/gen-third-party-notices.ts | 2 +- 13 files changed, 78 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 80508d16b4..1884c12c93 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 35fe7b7aaf02d9d55012e3285b3f5a58bc76cde8 -2026-06-21-subagent-capability-seam.zh.md: 221335859cec104a55136201e4923d783d616e86 +2026-06-21-subagent-capability-seam.md: 752e639b09ea2ac0ba19841ddfe38b15b44d22e9 +2026-06-21-subagent-capability-seam.zh.md: 6009aeda6773357a4217f8956fb510c2116bbe3f diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 35fe7b7aaf..752e639b09 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -54,7 +54,7 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag ### Child isolation and the parent log -Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. The parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic. +Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log. ### Synchronous collect (first cut) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 221335859c..6009aeda67 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -54,7 +54,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 子 agent 隔离与父日志 -每个 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)——子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,绝不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件在物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 真正与传输方式无关。 +每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用均留在父日志之外。 ### 同步收集(首版) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d6910daedc..96ce7ecd37 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -3,7 +3,7 @@ # Third-Party Notices -DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. +DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 43c08d5394..6bc638bdc4 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-claude-code/README.md -README.md: 5bfc8ff3ba539b6caf891577cee5820d983106cd -README.zh.md: 6db9148228ba30c0f84beb14caa7a84aa0dcd9f5 +README.md: e62f60fceea16749296a91377785b81d94d751ca +README.zh.md: e171524157b2b1696df31753816210d41637a911 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 5bfc8ff3ba..e62f60fcee 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. +Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 6db9148228..e171524157 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。 +生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 1ba7266e0f..e4d6fbac5f 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -60,11 +60,17 @@ class ClaudeCodeProvider implements SubagentProvider { ) {} start(request: ResolvedSubagentStartRequest) { + const parentCwd = request.parent.session.header.cwd + if (parentCwd === undefined) { + throw new Error( + 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', + ) + } const spec: ClaudeCodeRunSpec = { cwd: resolveChildCwd( 'subagent-claude-code', undefined, - request.parent.session.header.cwd, + parentCwd, ), env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts index 6f84fd2395..d8a04cf953 100644 --- a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -99,7 +99,7 @@ export async function startMessagesFixture( request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) request.on('end', () => { const path = request.url ?? '' - if (!path.startsWith('/v1/messages')) { + if (path !== '/v1/messages' && !path.startsWith('/v1/messages?')) { response.writeHead(404, { 'content-type': 'application/json' }) response.end(JSON.stringify({ type: 'error', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index a0d1c1a02d..f76b4038f6 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -10,6 +10,11 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' +import type { + Query, + SDKMessage, + SDKSystemMessage, +} from '@anthropic-ai/claude-agent-sdk' import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -23,6 +28,39 @@ import { type MessagesFixture, } from './messages-fixture.ts' +const observedSdkMessages = vi.hoisted((): SDKMessage[] => []) + +vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@anthropic-ai/claude-agent-sdk') + >() + return { + ...actual, + query(options: Parameters[0]): Query { + const query = actual.query(options) + // Observe the real SDK stream without replacing its protocol or CLI. + return new Proxy(query, { + get(target, property) { + if (property === Symbol.asyncIterator) { + return async function* (): AsyncGenerator { + for await (const message of target) { + observedSdkMessages.push(message) + yield message + } + } + } + const value: unknown = Reflect.get(target, property, target) + if (typeof value === 'function') { + const method = value as (...args: unknown[]) => unknown + return method.bind(target) + } + return value + }, + }) + }, + } +}) + const execFileAsync = promisify(execFile) const sdkRoot = dirname(fileURLToPath( import.meta.resolve('@anthropic-ai/claude-agent-sdk'), @@ -54,6 +92,7 @@ afterEach(async () => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } + observedSdkMessages.length = 0 }) interface RealHarness { @@ -168,10 +207,16 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { }) await run.dispose() + const initMessage = observedSdkMessages.find( + (message): message is SDKSystemMessage => + message.type === 'system' && message.subtype === 'init', + ) + expect(initMessage?.claude_code_version).toBe('2.1.220') + expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! expect(recorded.method).toBe('POST') - expect(recorded.path).toMatch(/^\/v1\/messages(?:\\?|$)/) + expect(recorded.path).toMatch(/^\/v1\/messages(?:\?.*)?$/) expect(recorded.headers['x-api-key']).toBe(fakeKey) expect(recorded.body.model).toBe(settingsModel) expect(Array.isArray(recorded.body.messages)).toBe(true) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 7a2942b084..8c4ac1708d 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -341,6 +341,17 @@ describe('task admission and package contracts', () => { disposeGraceMs: 29, }) + await expect(ctx.subagents.start('claude-code', { + ...request(), + parent: { + id: 'parent-without-cwd', + session: { header: {} }, + } as unknown as Agent, + })).rejects.toThrow( + 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', + ) + expect(queryMock).not.toHaveBeenCalled() + const run = await ctx.subagents.start('claude-code', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index e6e199c21a..d0c427c8f6 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -25,7 +25,9 @@ describe('THIRD_PARTY_NOTICES.md', () => { // Pre-commit regenerates the file whenever a manifest is staged, so reaching // this assertion means the notices were committed without that hook. it('matches what the generator produces from the current manifests', () => { - expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render()) + const generated = render() + expect(generated).toContain('It depends on the third-party software listed below.') + expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) }) }) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 3e2e0917f2..20f3bd04f0 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -694,7 +694,7 @@ export function render(): string { # Third-Party Notices -DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. +DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. From 9e91e206d4ad1662a843956cf73892aa4f3bf94a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 07:36:54 +0800 Subject: [PATCH 27/27] refactor(subagent-codex): centralize cancellation settlement --- packages/subagent/subagent-codex/src/run.ts | 2 +- packages/subagent/subagent-codex/src/wire.ts | 4 -- .../tests/subagent-codex.spec.ts | 44 +++++-------------- 3 files changed, 13 insertions(+), 37 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index ecf71fb2ba..c3ebf4ba19 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -179,7 +179,7 @@ export async function startCodexRun( const collectOutput = (): ContentBlock[] => wire.collectOutput() const result: Promise = settleRunResult({ attempt: () => Promise.race([ - wire.runTurn(texts, runAbort.signal, () => runAbort.signal.aborted), + wire.runTurn(texts, runAbort.signal), processFailure, ]), collectOutput, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index ca24fdcadf..51be212841 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -168,13 +168,11 @@ export class CodexAppServerWire { * terminal notification. * @param texts - already validated task text blocks. * @param signal - local cancellation for the published run. - * @param cancelled - whether local cancellation has already won. * @returns the shared subagent result. */ async runTurn( texts: readonly string[], signal: AbortSignal, - cancelled: () => boolean, ): Promise { const completion = Promise.withResolvers() this.turnCompleted = completion @@ -187,8 +185,6 @@ export class CodexAppServerWire { this.commitTurnId(string(turn.id, 'turn/start turn id')) const completed = await this.guarded(completion.promise, signal) - if (cancelled()) return { output: this.collectOutput(), stopReason: 'aborted' } - const terminal = object(completed.turn, 'turn/completed turn') const status = terminal.status if (isContextWindowExceeded(terminal)) { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 37c8649f8b..de89aa4854 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -393,7 +393,6 @@ describe('CodexAppServerWire', () => { const result = wire.runTurn( ['first', 'second'], new AbortController().signal, - () => false, ) const turnStart = await child.peer.nextMethod('turn/start') expect(turnStart.params).toEqual({ @@ -437,7 +436,7 @@ describe('CodexAppServerWire', () => { it('uses the last nullable-phase answer when no explicit final exists', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send( @@ -454,7 +453,7 @@ describe('CodexAppServerWire', () => { it('maps only an explicit context-window failure to max-tokens', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send( @@ -494,7 +493,7 @@ describe('CodexAppServerWire', () => { } { const { child, wire } = await initializeWire() - const pending = wire.runTurn(['task'], new AbortController().signal, () => false) + const pending = wire.runTurn(['task'], new AbortController().signal) const frame = await child.peer.nextMethod('turn/start') child.peer.respond(frame, { turn: { id: '' } }) await expect(pending).rejects.toThrow('turn/start turn id') @@ -542,7 +541,7 @@ describe('CodexAppServerWire', () => { ] for (const scenario of scenarios) { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send(...scenario.frames) @@ -553,7 +552,7 @@ describe('CodexAppServerWire', () => { it('fails closed when terminal notification params are not an object', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send({ method: 'turn/completed', params: null }) @@ -563,7 +562,7 @@ describe('CodexAppServerWire', () => { it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.send( { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, @@ -575,28 +574,9 @@ describe('CodexAppServerWire', () => { wire.close() }) - it('gives local cancellation precedence over a remote completed turn', async () => { - const { child, wire } = await initializeWire() - let cancelled = false - const result = wire.runTurn( - ['task'], - new AbortController().signal, - () => cancelled, - ) - const turnStart = await child.peer.nextMethod('turn/start') - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - cancelled = true - child.peer.send(agentMessage('late', 'final_answer'), turnCompleted('completed')) - await expect(result).resolves.toEqual({ - output: [{ type: 'text', text: 'late' }], - stopReason: 'aborted', - }) - wire.close() - }) - it('answers all five unattended request classes without granting authority', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.send({ @@ -699,7 +679,7 @@ describe('CodexAppServerWire', () => { }, ]) { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() @@ -713,7 +693,7 @@ describe('CodexAppServerWire', () => { it('rejects conflicting early turn identities before accepting output', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.send({ method: 'turn/started', @@ -738,7 +718,7 @@ describe('CodexAppServerWire', () => { } { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) await child.peer.nextMethod('turn/start') child.peer.send( { @@ -755,7 +735,7 @@ describe('CodexAppServerWire', () => { it('interrupts only an active open turn and contains remote interrupt failure', async () => { const { child, wire } = await initializeWire() wire.interrupt() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() @@ -790,7 +770,7 @@ describe('CodexAppServerWire', () => { ) await nextTask() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask()