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,