@zvada/agent-server 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.13
4
+
5
+ - Add opt-in `subagentMode: "turn"` for Claude Code and Codex app-server.
6
+ Native workers stay within the managed execution's lifetime and existing
7
+ permission broker. Unsupported harnesses reject this mode.
8
+ - Preserve modern Claude Agent metadata and nested text, normalize Codex V1
9
+ task tools, and expose V2 native activity as a typed `SubagentPart`.
10
+ - Require native cleanup before a managed Codex execution releases ownership,
11
+ including cancellation and failed startup. Unconfirmed cleanup prevents
12
+ session reuse. Managed Codex successors resume in a fresh native process.
13
+ Default interactive harness behavior remains unchanged.
14
+
15
+ ## 0.3.12
16
+
17
+ - Report a Claude turn whose API call failed as an error. Claude Code ends it
18
+ as a `success` result flagged `is_error` (a rejected key, an exhausted quota,
19
+ an overloaded provider), with the failure as its text; it was reported as a
20
+ completed `end_turn` whose answer quoted the failure. The failure's text now
21
+ classifies the error (a 401 is `auth`).
22
+
3
23
  ## 0.3.11
4
24
 
5
25
  - Report a Claude turn stopped while a tool runs as `cancelled`. Claude Code
package/docs/harnesses.md CHANGED
@@ -120,6 +120,13 @@ provide the cache breakdown without an additional native usage report.
120
120
 
121
121
  ## Known limitations (roadmap)
122
122
 
123
+ - **Native subagents:** opt into `RunConfig.subagentMode: "turn"` for Claude
124
+ Code or Codex app-server. The parent collects results; native workers settle
125
+ before the engine releases the turn. Claude forwards nested worker text.
126
+ Codex emits task tools and typed `subagent` activity; it does not forward full
127
+ child transcripts. Managed Codex turns close the native process after cleanup
128
+ and resume saved context on the next turn. Default behavior is unchanged.
129
+ See [RFD 0002](rfds/0002-native-subagents.md) for the rehearsal and qualification.
123
130
  - **MCP servers** are supported by Claude and Codex app-server. Codex SDK
124
131
  passthrough remains unsupported. See [MCP configuration](consuming.md#configure-mcp-between-turns)
125
132
  for Codex home ownership, supported transports, and live-update behavior.
@@ -0,0 +1,197 @@
1
+ # RFD 0002 — Native delegation within a managed turn
2
+
3
+ **Status:** implemented; native Claude and Codex qualification passed on 2026-09-29.
4
+ **Date:** 2026-09-29
5
+ **Scope:** Claude Code and Codex app-server in agent-server and AGNT.
6
+ **Baseline:** agent-server `7248c43`; AGNT `39df4fa9`.
7
+
8
+ ## Recommendation
9
+
10
+ Support native delegation inside the existing logical engine turn. The parent
11
+ assigns work, collects results and produces the final answer. The engine retains
12
+ ownership until its native work finishes or is stopped. AGNT keeps using its
13
+ existing turn-scoped credentials, permission bridge, persistence and cancellation.
14
+
15
+ This is a smaller extension of [DESIGN D10](../../../../DESIGN.md#d10--built-in-turns-own-their-logical-session-through-cleanup).
16
+ It does not require a second scheduler, child credential leases, a worker
17
+ database, or a stream that remains active between managed turns. Independently
18
+ running workers across later turns are outside this first contract.
19
+
20
+ ## What already works — observed
21
+
22
+ Deus Machine already configures this engine with `forwardSubagentText: true`
23
+ and renders nested subagent output. At `origin/main` `4e957a26c`, see
24
+ `apps/agent-server/agents/core/engine.ts`,
25
+ `apps/web/src/features/session/ui/blocks/SubagentGroupBlock.tsx` and
26
+ `apps/backend/test/unit/services/event-persistence-integration.test.ts`.
27
+ The last file tests nesting a worker message under its spawning tool. This is
28
+ source evidence, not a new live execution.
29
+
30
+ The engine already carries `ToolPart.subagent` and `parentToolCallId`; its
31
+ `test/server/flows.test.ts` covers nested output. AGNT already round-trips these
32
+ fields. Its two focused message suites passed **37 tests** during this rehearsal.
33
+
34
+ [SessionAgent](../../src/core/agents/session-agent.ts) already reserves a session
35
+ through `executeTurn` cleanup. AGNT's controller waits for its predecessor to
36
+ drain before preparing a new credential and releases credentials in its existing
37
+ `finally`. These are the owners to reuse.
38
+
39
+ AGNT does not globally disable native delegation. Its explicit `toolAllowlist`
40
+ rejects `Agent` and `Task`; QApp also omits them. AGNT's Claude SDK options omit
41
+ Deus Machine's forwarding flag. Saying that the whole stack lacked subagents
42
+ was too broad.
43
+
44
+ ## Pass 1 — the smallest apparent patch
45
+
46
+ 1. Enable Claude child-text forwarding in AGNT. Recognize modern `Agent` as
47
+ well as legacy `Task` in the Claude adapter.
48
+ 2. Normalize Codex's existing native delegation output.
49
+ 3. Permit delegation under AGNT's scoped tool policy.
50
+
51
+ Steps 1–3 alone are insufficient. The following counterexamples determine the
52
+ additional work; they do not justify a general background-worker platform.
53
+
54
+ ## Pass 2 — counterexamples and smallest corrections
55
+
56
+ | Trigger and current path | Wrong result | Smallest correction and proof |
57
+ | --- | --- | --- |
58
+ | Claude returns its parent `result` while background work remains; `ClaudeGeneratorSession` closes the tap. | Late child output is lost or reaches the next tap after the host changes policy. | In the managed mode, enforce native foreground execution and qualify cleanup on the pinned SDK. Test cancellation plus a successor with changed policy. |
59
+ | Codex emits root `turn/completed`; `executeTurn` ends its queue and removes listeners. | Child activity disappears; credentials can be released too early. | Track native descendants for this execution, separate parent completion from execution settlement, and retain the existing session reservation through cleanup. |
60
+ | Merely delay Codex's queue end, leaving `turnEnded = true`. | `forceCancel` returns early, so cancellation can hang or falsely settle. | Make cancellation depend on execution settlement, target active owned native turns, and observe completion. Test cancel after the parent response. |
61
+ | A Codex child requests approval. `bindRequests` currently declines every foreign thread id. | Owned children cannot use the existing permission broker. | Admit only descendants proven by native ownership; route them through the current turn's handler. Test an owned child and an unrelated thread. |
62
+ | Treat `client.close()` or a stop request as proof of teardown. | Current `close()` marks itself closed before the OS process exits. | Await actual native completion or bounded subprocess teardown; qualify descendant tools too. Never report a clean stop from the local boolean alone. |
63
+ | Set foreground behavior globally for all engine consumers. | Existing interactive Deus Machine behavior changes unintentionally. | Use one explicit managed-turn option, include it in native session configuration, and preserve the default for existing consumers. |
64
+ | Wait for workers after the parent has already submitted its answer. | The final answer may never have used their results. | Require the parent to collect results. If it returns with outstanding work, stop that work and report incomplete execution; do not silently run another model turn. |
65
+
66
+ The earlier synthetic probes reproduced omitted adapter events and late-tap
67
+ routing, not a live permission escape. Artifacts remain under
68
+ `.context/subagents-research/` and QApp's `.context/delegation-audit/`.
69
+
70
+ ## Pass 3 — revised implementation
71
+
72
+ ### Engine option and lifetime
73
+
74
+ The opt-in `RunConfig.subagentMode: "turn"` preserves existing native behavior
75
+ when omitted. Unsupported
76
+ harnesses reject the request. Thread it through
77
+ `src/protocol/config.ts`, `src/core/agents/base.ts`,
78
+ `src/core/runtime/agent-runtime.ts` and native session configuration.
79
+ A warm process must not silently retain an incompatible mode.
80
+
81
+ The normal path remains one run: native workers execute, the parent receives
82
+ their results, and the engine finishes after native cleanup. Multiple native
83
+ workers may run concurrently where the pinned harness supports it; qualify that
84
+ behavior rather than promising it from an SDK option.
85
+
86
+ The adapter needs an execution-local record of owned native work, not a durable
87
+ child-session service. Track starts, terminal outcomes and active native turn
88
+ identifiers, including descendants. A spawn acknowledgement is not completion.
89
+ On early parent exit or cancellation, stop remaining owned work before releasing
90
+ ownership. Failure to establish cleanup must remain a failed/unconfirmed
91
+ execution and prevent unsafe session reuse. Do not add automatic retries or
92
+ background parent wake-ups.
93
+
94
+ ### Claude
95
+
96
+ Use the native foreground control in managed mode and forward child text.
97
+ The current [Claude documentation](https://code.claude.com/docs/en/sub-agents#run-subagents-in-foreground-or-background)
98
+ documents `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1`; verify the behavior against
99
+ the pinned SDK **0.3.220**, including explicit background requests and
100
+ cancellation. Reuse the existing message nesting.
101
+
102
+ Changes belong in `claude-code/options.ts`, `claude-agent.ts`,
103
+ `generator-session.ts` as required by that qualification, and the
104
+ `subagentFromInput` branch in `claude-code/adapter.ts`. Do not build a separate
105
+ Claude scheduler. If the native foreground control does not contain the pinned
106
+ runtime, keep the managed capability disabled until that specific gap is fixed.
107
+
108
+ ### Codex
109
+
110
+ The pinned CLI **0.153.4** emits V1 `collabAgentToolCall` and V2
111
+ `subAgentActivity`. Both currently disappear in the adapters. V2 activity may
112
+ arrive after its originating parent response. The
113
+ [pinned app-server contract](https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/app-server/README.md)
114
+ supports `turn/interrupt` for parent-owned V2 children too: use each child's
115
+ active native turn, then observe its terminal outcome. Interrupting an already
116
+ completed root is insufficient. Native background terminals have a separate
117
+ cleanup operation; include child-owned terminals in cancellation qualification.
118
+
119
+ Implement ownership, notification selection and drain behavior in
120
+ `codex-app-server/codex-app-server-agent.ts`; add awaited process-exit fallback
121
+ in `client.ts`. Use native parent links/read APIs to settle notification-order
122
+ races, never permissive thread matching. Child approval handling stays on the
123
+ existing broker.
124
+
125
+ Normalize V1 calls in `codex-app-server/adapter.ts` and `codex-items.ts`.
126
+ Preserve real native ids, results and task metadata. V2 is an activity, not a
127
+ public tool invocation: add the small typed activity representation it needs to
128
+ the existing Part/adapter/reducer path rather than inventing a tool call.
129
+ Carry its native activity id, child thread id and observed kind. This is a
130
+ persisted message part, not a new independently managed session. Full child
131
+ transcript parity is not a prerequisite; unavailable transcripts stay explicit.
132
+
133
+ ### AGNT and review consumers
134
+
135
+ AGNT enables the qualified managed mode in `AgentEngine.prepareTurn` and adds
136
+ `forwardSubagentText`. Its existing controller and credential cleanup remain
137
+ the owners. Extend existing part persistence only for the activity shape the
138
+ engine actually emits; no new worker table is needed.
139
+
140
+ Relax `Agent`/`Task` rejection only when child tools demonstrably obey the same
141
+ Claude allowlist, including bypass mode. Gate this admission on an updated
142
+ sidecar version. Codex's existing lack of custom `toolAllowlist` enforcement
143
+ remains a separate limitation; this work must not claim to solve it.
144
+
145
+ The parent decides what to test through skills, gives each worker the necessary
146
+ context, waits for results and submits one report. Device workers share no
147
+ simulator concurrently unless separate devices exist. QApp's evidence still
148
+ comes through its trusted capture path. No review roles belong in the engine.
149
+
150
+ ## Checks that establish the feature
151
+
152
+ - Adapter fixtures cover modern Claude `Agent`, legacy `Task`, Codex V1 and
153
+ V2, interleaved workers, native ids and honest outcomes.
154
+ - Runtime tests cover early parent completion, child failure, cancellation while
155
+ waiting, owned/unrelated approvals, actual teardown, and a successor with
156
+ different credentials/MCP/policy. No old child reaches that successor.
157
+ - Existing reducer, replay and AGNT JSON-part round trips retain nesting and
158
+ activity without a separate lifecycle database.
159
+ - Live pinned Claude and Codex runs each delegate two tasks and incorporate both
160
+ results. Repeat through AGNT's real proxy/MCP path, including a denied child
161
+ tool for Claude. Prove stop behavior and absence of continued child execution.
162
+ - Existing consumers without the managed option keep their native behavior.
163
+ A QApp review demonstrates worker evidence reaching the final report and
164
+ visible partial coverage on failure.
165
+
166
+ ## Implementation qualification
167
+
168
+ The deterministic engine suite passes (907 tests). The opt-in live suite
169
+ `test/core/live/subagents.live.test.ts` passes four real-model cases: Claude and
170
+ Codex each delegate to two workers and incorporate both unique file values;
171
+ each cancels a worker running a real shell command, confirms that PID stopped,
172
+ and successfully runs a successor. Proof is retained in `.context/proof/subagents`.
173
+ No simulator was used in these engine tests.
174
+
175
+ Live testing found that Claude can return `error_during_execution` with
176
+ `stop_reason: tool_use` on an acknowledged cancellation. The adapter now uses
177
+ the confirmed interrupt marker to distinguish that from an execution failure.
178
+
179
+ Codex can register a background terminal AFTER its thread becomes idle. An
180
+ interrupt response and empty terminal list therefore cannot prove cleanup.
181
+ Managed Codex turns close the app-server through stdin EOF after reconciliation,
182
+ which invokes native thread/tool shutdown. Root SIGTERM alone left a real child
183
+ command running on macOS. Forced exits or native cleanup warnings are unconfirmed
184
+ and quarantine the logical session. A managed successor resumes persisted context
185
+ in a fresh process; the default interactive mode retains its warm process.
186
+
187
+ This contract covers native managed work. It does not turn arbitrary shell
188
+ commands into an OS containment boundary; sandbox custody remains with the host.
189
+ AGNT and QApp integration qualification is recorded in their own design documents.
190
+
191
+ Repeated cancellation also consults persistent unconfirmed native cleanup after the wrapper turn has ended. The idle response of another harness must not erase that result in the host. AGNT cloud qualification additionally verified real child PID cleanup and a successful successor for both harnesses.
192
+
193
+ Releasing a quarantined Codex session also refuses unconfirmed cleanup. A host
194
+ must propagate that release failure before resetting the session or switching
195
+ harnesses; otherwise a successor could bypass the same-harness admission guard.
196
+ Deterministic regressions cover failure after normal parent completion, repeated
197
+ release attempts, and successful release after confirmed cleanup.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvada/agent-server",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,6 +54,7 @@ export interface AgentExecuteOptions {
54
54
  thinkingLevel?: ThinkingLevel;
55
55
  permissionMode?: PermissionMode;
56
56
  maxTurns?: number;
57
+ subagentMode?: "turn";
57
58
  systemPromptAppend?: string;
58
59
  resumeSessionId?: string;
59
60
  resumeSessionAt?: string;
@@ -97,6 +97,8 @@ type ClaudeMessage =
97
97
  turn_cost_usd?: number | null;
98
98
  stop_reason?: string | null;
99
99
  is_error?: boolean;
100
+ /** On an `is_error` success, the API failure's text. */
101
+ result?: string;
100
102
  };
101
103
 
102
104
  type BlockKind = "text" | "reasoning" | "tool";
@@ -194,7 +196,7 @@ function toolResultContent(content: unknown): ToolResultContent[] | undefined {
194
196
  /**
195
197
  * SubagentMetadata from a spawning tool's input, shared by the streamed and
196
198
  * non-streaming paths. `subagent_type` in the input identifies a spawn on any
197
- * tool name; `Task` is Claude's own spawn tool and counts even before its
199
+ * tool name; Claude's `Agent` (formerly `Task`) also counts before its
198
200
  * input parses into anything useful (presence of `subagent` is what promotes
199
201
  * the part to `kind: "task"` — see protocol §3.5).
200
202
  */
@@ -205,7 +207,7 @@ function subagentFromInput(
205
207
  const str = (key: string): string | undefined =>
206
208
  typeof input[key] === "string" ? (input[key] as string) : undefined;
207
209
  const type = str("subagent_type");
208
- if (type === undefined && toolName !== "Task") return undefined;
210
+ if (type === undefined && toolName !== "Task" && toolName !== "Agent") return undefined;
209
211
  const description = str("description");
210
212
  const model = str("model");
211
213
  return {
@@ -505,7 +507,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
505
507
  }
506
508
 
507
509
  private captureResult(msg: Extract<ClaudeMessage, { type: "result" }>): AdapterEvent[] {
508
- this.succeeded = msg.subtype === "success";
510
+ this.succeeded = msg.subtype === "success" && !msg.is_error;
509
511
  if (msg.usage) {
510
512
  const creation = msg.usage.cache_creation;
511
513
  this.usage = {
@@ -538,6 +540,11 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
538
540
  } else {
539
541
  this.error = `Claude turn ended: ${msg.subtype}`;
540
542
  }
543
+ } else if (msg.is_error) {
544
+ // A failed API call (a rejected key, an exhausted quota, an overloaded
545
+ // provider) ends the turn as a "success" whose text is the error. The
546
+ // text names the failure, so the runtime classifies it (401 -> auth).
547
+ this.error = msg.result || "Claude turn ended with an API error";
541
548
  }
542
549
  // Final authoritative gauge: the result knows the context-window size.
543
550
  if (!msg.usage) return [];
@@ -25,6 +25,7 @@ const CAPABILITIES: AgentCapabilities = {
25
25
  images: true,
26
26
  mcpServers: true,
27
27
  permissionRequests: true,
28
+ turnScopedSubagents: true,
28
29
  };
29
30
 
30
31
  /** Convert our normalized input into Claude's message content. */
@@ -68,6 +69,7 @@ function sessionConfigFrom(
68
69
  thinkingLevel: options.thinkingLevel,
69
70
  permissionMode: options.permissionMode,
70
71
  maxTurns: options.maxTurns,
72
+ subagentMode: options.subagentMode,
71
73
  systemPromptAppend: options.systemPromptAppend,
72
74
  resumeSessionId: options.resumeSessionId,
73
75
  resumeSessionAt: options.resumeSessionAt,
@@ -44,6 +44,7 @@ export interface ClaudeSessionConfig {
44
44
  thinkingLevel?: ThinkingLevel;
45
45
  permissionMode?: PermissionMode;
46
46
  maxTurns?: number;
47
+ subagentMode?: "turn";
47
48
  systemPromptAppend?: string;
48
49
  resumeSessionId?: string;
49
50
  resumeSessionAt?: string;
@@ -95,6 +96,7 @@ function buildEnv(config: ClaudeSessionConfig): Record<string, string | undefine
95
96
  ...process.env,
96
97
  ...(config.apiKey ? { ANTHROPIC_API_KEY: config.apiKey } : {}),
97
98
  ...config.env,
99
+ ...(config.subagentMode === "turn" && { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" }),
98
100
  };
99
101
  }
100
102
 
@@ -156,6 +158,7 @@ export function buildClaudeOptions(
156
158
  // this function returns, so those can never be clobbered (and the
157
159
  // ClaudeSdkOptionOverrides type excludes them anyway).
158
160
  if (overrides) Object.assign(options, overrides);
161
+ if (config.subagentMode === "turn") options.forwardSubagentText = true;
159
162
 
160
163
  // Fable-class models withhold thinking plaintext by default: blocks stream
161
164
  // structure + token estimates only, so every reasoning part arrives with
@@ -42,6 +42,7 @@ export function claudeSessionNeedsRestart(
42
42
  (prev.maxTurns ?? 100) !== (next.maxTurns ?? 100) ||
43
43
  (prev.idleTimeoutMs ?? 5 * 60_000) !== (next.idleTimeoutMs ?? 5 * 60_000) ||
44
44
  Boolean(prev.disableTools) !== Boolean(next.disableTools) ||
45
+ prev.subagentMode !== next.subagentMode ||
45
46
  configFingerprint(prev.env) !== configFingerprint(next.env)
46
47
  // mcpServers deliberately absent: MCP changes hot-swap on the live
47
48
  // session (`setMcpServers`) instead of restarting the subprocess.
@@ -4,6 +4,7 @@ import {
4
4
  type ReasoningPart,
5
5
  type StopReason,
6
6
  type StreamContext,
7
+ type SubagentPart,
7
8
  type TextPart,
8
9
  type TokenUsage,
9
10
  type ToolPart,
@@ -11,6 +12,8 @@ import {
11
12
  createReasoningPart,
12
13
  createTextPart,
13
14
  createToolPart,
15
+ generateUUIDv7,
16
+ setToolMeta,
14
17
  } from "../../../protocol/index.ts";
15
18
  import {
16
19
  type CodexToolDescriptor,
@@ -31,6 +34,9 @@ import type { AdapterEvent, EventTransformer, TransformResult } from "../types.t
31
34
  interface CodexItem extends CodexToolItem {
32
35
  type: string;
33
36
  text?: string;
37
+ agentThreadId?: string;
38
+ agentPath?: string;
39
+ kind?: SubagentPart["activity"];
34
40
  }
35
41
  interface Notification {
36
42
  method: string;
@@ -119,6 +125,8 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
119
125
 
120
126
  private openItem(item: CodexItem): AdapterEvent[] {
121
127
  switch (item.type) {
128
+ case "subAgentActivity":
129
+ return this.activity(item);
122
130
  case "agentMessage": {
123
131
  const part = createTextPart(this.ctx, item.text ?? "", true);
124
132
  this.partByItem.set(item.id, part);
@@ -142,6 +150,8 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
142
150
  private completeItem(item: CodexItem): AdapterEvent[] {
143
151
  const part = this.partByItem.get(item.id);
144
152
  switch (item.type) {
153
+ case "subAgentActivity":
154
+ return this.activity(item);
145
155
  case "agentMessage":
146
156
  case "reasoning": {
147
157
  const p = (part ??
@@ -161,12 +171,34 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
161
171
  const d = codexToolDescriptor(item.type);
162
172
  if (!d) return [];
163
173
  const tool = this.ensureTool(item, d);
174
+ setToolMeta(tool, d.meta(item));
164
175
  completeToolPart(tool, d.result(item));
165
176
  return [{ kind: "part-update", part: tool }];
166
177
  }
167
178
  }
168
179
  }
169
180
 
181
+ private activity(item: CodexItem): AdapterEvent[] {
182
+ if (
183
+ !item.agentThreadId ||
184
+ !item.kind ||
185
+ !["started", "interacted", "interrupted", "completed"].includes(item.kind)
186
+ )
187
+ return [];
188
+ const existing = this.partByItem.get(item.id);
189
+ const part: SubagentPart = {
190
+ ...this.ctx,
191
+ id: existing?.id ?? generateUUIDv7(),
192
+ type: "subagent",
193
+ nativeActivityId: item.id,
194
+ nativeThreadId: item.agentThreadId,
195
+ ...(item.agentPath && { path: item.agentPath }),
196
+ activity: item.kind,
197
+ };
198
+ this.partByItem.set(item.id, part);
199
+ return [{ kind: existing ? "part-update" : "part-open", part }];
200
+ }
201
+
170
202
  private textDelta(
171
203
  itemId: string | undefined,
172
204
  delta: string | undefined,
@@ -51,6 +51,7 @@ export interface CodexAppServerClientOptions {
51
51
  */
52
52
  export class CodexAppServerClient {
53
53
  private proc?: ChildProcess;
54
+ private shutdown?: Promise<boolean>;
54
55
  private nextId = 1;
55
56
  private buffer = "";
56
57
  private readonly decoder = new StringDecoder("utf8");
@@ -62,6 +63,7 @@ export class CodexAppServerClient {
62
63
  /** Bounded rolling tail of the subprocess's stderr — folded into the exit
63
64
  * error so failures carry the CLI's own explanation. */
64
65
  private stderrTail = "";
66
+ private nativeShutdownFailed = false;
65
67
  /** Chunk-boundary-safe UTF-8 decoding for the stderr tail; reset per spawn. */
66
68
  private stderrDecoder = new StringDecoder("utf8");
67
69
 
@@ -122,10 +124,13 @@ export class CodexAppServerClient {
122
124
  }
123
125
 
124
126
  close(): void {
127
+ if (this.shutdown) return;
125
128
  const proc = this.proc;
126
129
  const wasOpen = !this.exited;
127
130
  this.proc = undefined;
128
131
  this.exited = true;
132
+ // Forced process exit cannot attest that native child commands stopped.
133
+ this.shutdown = Promise.resolve(!proc);
129
134
  for (const [id, p] of this.pending) {
130
135
  if (p.timer) clearTimeout(p.timer);
131
136
  p.reject(new Error(`codex app-server closed before '${p.method}'`));
@@ -135,6 +140,46 @@ export class CodexAppServerClient {
135
140
  if (wasOpen) this.notifyClosed();
136
141
  }
137
142
 
143
+ /** EOF invokes native thread/tool cleanup. Killing the server alone proves nothing about workers. */
144
+ closeAndWait(timeoutMs = 2_000): Promise<boolean> {
145
+ if (this.shutdown) return this.shutdown;
146
+ const proc = this.proc;
147
+ if (!proc) return Promise.resolve(true);
148
+ if (this.exited || proc.exitCode !== null || proc.signalCode !== null) {
149
+ this.shutdown = Promise.resolve(false);
150
+ return this.shutdown;
151
+ }
152
+ this.exited = true;
153
+ this.shutdown = new Promise<boolean>((resolve) => {
154
+ let forced = false;
155
+ const finish = (confirmed: boolean) => {
156
+ clearTimeout(killTimer);
157
+ clearTimeout(deadline);
158
+ proc.off("close", exited);
159
+ this.proc = undefined;
160
+ resolve(confirmed);
161
+ };
162
+ const exited = (code: number | null, signal: NodeJS.Signals | null) =>
163
+ finish(!forced && !this.nativeShutdownFailed && code === 0 && signal === null);
164
+ proc.once("close", exited);
165
+ const killTimer = setTimeout(() => {
166
+ forced = true;
167
+ proc.kill("SIGKILL");
168
+ }, timeoutMs);
169
+ const deadline = setTimeout(() => finish(false), timeoutMs * 2);
170
+ // In stdio mode SIGTERM bypasses Codex's async shutdown. EOF drains
171
+ // connection tasks and awaits shutdown_threads before a clean exit.
172
+ proc.stdin?.end();
173
+ });
174
+ for (const [id, p] of this.pending) {
175
+ if (p.timer) clearTimeout(p.timer);
176
+ p.reject(new Error(`codex app-server closed before '${p.method}'`));
177
+ this.pending.delete(id);
178
+ }
179
+ this.notifyClosed();
180
+ return this.shutdown;
181
+ }
182
+
138
183
  private notifyClosed(error?: Error): void {
139
184
  for (const handler of this.closeHandlers) handler(error);
140
185
  this.closeHandlers.clear();
@@ -143,6 +188,7 @@ export class CodexAppServerClient {
143
188
  private start(): void {
144
189
  if (this.proc && !this.exited) return;
145
190
  this.exited = false;
191
+ this.shutdown = undefined;
146
192
  const spawnProcess = this.opts.spawnProcess ?? nodeSpawn;
147
193
  const proc = spawnProcess(this.opts.codexPath ?? "codex", ["app-server"], {
148
194
  cwd: this.opts.cwd,
@@ -151,6 +197,7 @@ export class CodexAppServerClient {
151
197
  });
152
198
  this.proc = proc;
153
199
  this.stderrTail = "";
200
+ this.nativeShutdownFailed = false;
154
201
  this.stderrDecoder = new StringDecoder("utf8");
155
202
  proc.stdout?.on("data", (chunk: Buffer) => this.onStdout(chunk));
156
203
  proc.stdin?.on("error", (err) => this.onExit(err));
@@ -161,7 +208,13 @@ export class CodexAppServerClient {
161
208
  // from a crash for the integrator staring at the turn error. Decoded
162
209
  // via StringDecoder so a UTF-8 sequence split across chunks survives.
163
210
  proc.stderr?.on("data", (chunk: Buffer) => {
164
- this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-STDERR_TAIL_MAX);
211
+ const text = this.stderrTail + this.stderrDecoder.write(chunk);
212
+ // The pinned native server logs these cleanup failures but still exits 0.
213
+ if (
214
+ /failed to submit Shutdown|timed out waiting for .*shut down|shutdown timed out/.test(text)
215
+ )
216
+ this.nativeShutdownFailed = true;
217
+ this.stderrTail = text.slice(-STDERR_TAIL_MAX);
165
218
  });
166
219
  proc.on("error", (err) => this.onExit(err));
167
220
  // Build the exit error on CLOSE, not exit: `exit` can fire before the
@@ -30,6 +30,7 @@ import {
30
30
  codexMcpServers,
31
31
  writeCodexMcpServers,
32
32
  } from "./mcp.ts";
33
+ import { CodexTurnSubagents } from "./subagents.ts";
33
34
 
34
35
  const CAPABILITIES: AgentCapabilities = {
35
36
  multiTurn: true,
@@ -40,6 +41,7 @@ const CAPABILITIES: AgentCapabilities = {
40
41
  images: true,
41
42
  mcpServers: true,
42
43
  permissionRequests: true,
44
+ turnScopedSubagents: true,
43
45
  };
44
46
 
45
47
  export interface AppServerSession {
@@ -49,6 +51,7 @@ export interface AppServerSession {
49
51
  approvalPolicy: string;
50
52
  sandbox: string;
51
53
  systemPromptAppend?: string;
54
+ subagentMode?: "turn";
52
55
  envFingerprint: string;
53
56
  /** Native model connections retain the identity used at their first handshake. */
54
57
  authFingerprint?: string;
@@ -63,6 +66,7 @@ interface AppServerTurn {
63
66
  auth?: CodexAppServerAuth;
64
67
  permissionHandler?: PermissionRequestHandler;
65
68
  signal: AbortSignal;
69
+ subagents?: CodexTurnSubagents;
66
70
  }
67
71
 
68
72
  export function appServerSessionCompatible(
@@ -76,6 +80,7 @@ export function appServerSessionCompatible(
76
80
  existing.approvalPolicy === approvalPolicyFor(options) &&
77
81
  existing.sandbox === mapSandbox(options.permissionMode) &&
78
82
  (existing.systemPromptAppend ?? "") === (options.systemPromptAppend ?? "") &&
83
+ existing.subagentMode === options.subagentMode &&
79
84
  existing.envFingerprint === configFingerprint(options.env) &&
80
85
  (!options.resumeSessionId || options.resumeSessionId === existing.threadId),
81
86
  );
@@ -231,6 +236,7 @@ export class CodexAppServerAgent extends SessionAgent {
231
236
  readonly capabilities = CAPABILITIES;
232
237
  private readonly sessions = new SessionStore<AppServerSession>();
233
238
  private readonly invalidatedClients = new WeakSet<CodexAppServerClient>();
239
+ private readonly unsettledSessions = new Map<string, CodexAppServerClient>();
234
240
  /** Home ownership outlives idle process eviction and failed preparation. */
235
241
  private readonly mcpHomes = new Map<string, string>();
236
242
  /** A cancel acknowledgement belongs to one invocation, even after it drains. */
@@ -288,7 +294,9 @@ export class CodexAppServerAgent extends SessionAgent {
288
294
  (await this.agentOptions.resolveCliPath?.()) ?? process.env.CODEX_CLI_PATH ?? "codex",
289
295
  cwd: options.cwd,
290
296
  env: options.env ? ({ ...process.env, ...options.env } as NodeJS.ProcessEnv) : process.env,
291
- ...(turn.auth?.type === "chatgptAuthTokens" && { experimentalApi: true }),
297
+ ...((turn.auth?.type === "chatgptAuthTokens" || options.subagentMode === "turn") && {
298
+ experimentalApi: true,
299
+ }),
292
300
  };
293
301
  signal.throwIfAborted();
294
302
  const client =
@@ -305,6 +313,7 @@ export class CodexAppServerAgent extends SessionAgent {
305
313
  approvalPolicy,
306
314
  sandbox,
307
315
  systemPromptAppend: options.systemPromptAppend,
316
+ subagentMode: options.subagentMode,
308
317
  envFingerprint: configFingerprint(options.env),
309
318
  authFingerprint: identity,
310
319
  turn,
@@ -327,6 +336,15 @@ export class CodexAppServerAgent extends SessionAgent {
327
336
  if (options.model) startParams.model = options.model;
328
337
  if (options.systemPromptAppend)
329
338
  startParams.developerInstructions = options.systemPromptAppend;
339
+ if (options.subagentMode === "turn") {
340
+ startParams.config = { "features.multi_agent": true };
341
+ startParams.developerInstructions = [
342
+ options.systemPromptAppend,
343
+ "Collect the results of every subagent before ending your turn. Stop any worker you no longer need. Delegated work cannot continue after this task completes.",
344
+ ]
345
+ .filter(Boolean)
346
+ .join("\n\n");
347
+ }
330
348
 
331
349
  const method = resumeThreadId ? "thread/resume" : "thread/start";
332
350
  const result = (await client
@@ -466,7 +484,14 @@ export class CodexAppServerAgent extends SessionAgent {
466
484
  const requestThread = params.threadId ?? params.conversationId;
467
485
  const v2 = method.startsWith("item/");
468
486
  const decline = v2 ? { decision: "decline" } : { decision: "denied" };
469
- if (requestThread && requestThread !== session.threadId) return decline;
487
+ if (requestThread && requestThread !== session.threadId) {
488
+ if (
489
+ typeof requestThread !== "string" ||
490
+ !turn?.subagents ||
491
+ !(await turn.subagents.owns(requestThread).catch(() => false))
492
+ )
493
+ return decline;
494
+ }
470
495
  const handler = turn?.permissionHandler;
471
496
  if (!handler || turn?.signal.aborted) return decline;
472
497
  const decision = await handler(approvalToolCall(method, params));
@@ -511,8 +536,16 @@ export class CodexAppServerAgent extends SessionAgent {
511
536
  const unsubscribe: Array<() => void> = [];
512
537
  let session: AppServerSession | undefined;
513
538
  let turnEnded = false;
539
+ let parentEnded = false;
540
+ let managedCompletion: Promise<boolean> | undefined;
514
541
  try {
515
542
  controller.signal.throwIfAborted();
543
+ const unsettled = this.unsettledSessions.get(options.sessionId);
544
+ if (unsettled) {
545
+ if (!(await unsettled.closeAndWait()))
546
+ throw new Error("Previous Codex execution has not stopped");
547
+ this.unsettledSessions.delete(options.sessionId);
548
+ }
516
549
  const requestedMcp = codexMcpServers(snapshotMcpServers(options.mcpServers));
517
550
  const mcpServers = this.managedMcpHome(options, Object.keys(requestedMcp).length > 0)
518
551
  ? requestedMcp
@@ -527,7 +560,17 @@ export class CodexAppServerAgent extends SessionAgent {
527
560
  });
528
561
  const threadId = session.threadId;
529
562
  const client = session.client;
530
- const abortPreparation = () => client.close();
563
+ if (options.subagentMode === "turn") {
564
+ turn.subagents = new CodexTurnSubagents(
565
+ client,
566
+ threadId,
567
+ this.agentOptions.cancelGraceMs ?? 2_000,
568
+ );
569
+ }
570
+ const abortPreparation = () => {
571
+ if (turn.subagents) void client.closeAndWait();
572
+ else client.close();
573
+ };
531
574
  controller.signal.addEventListener("abort", abortPreparation, { once: true });
532
575
  try {
533
576
  controller.signal.throwIfAborted();
@@ -545,6 +588,47 @@ export class CodexAppServerAgent extends SessionAgent {
545
588
  }
546
589
 
547
590
  let turnId: string | undefined;
591
+ const finishManaged = (): Promise<boolean> => {
592
+ if (managedCompletion) return managedCompletion;
593
+ managedCompletion = (async () => {
594
+ let confirmed = true;
595
+ let failure: unknown;
596
+ const graceMs = this.agentOptions.cancelGraceMs ?? 2_000;
597
+ const deadline = setTimeout(() => {
598
+ void client.closeAndWait(graceMs);
599
+ }, graceMs * 5);
600
+ try {
601
+ if (!parentEnded) {
602
+ // A startup interrupt only acknowledges submission, not native settlement.
603
+ if (!turnId) throw new Error("Codex cancelled before turn admission was observed");
604
+ await client.request("turn/interrupt", { threadId, turnId }, graceMs);
605
+ }
606
+ const unfinished = await turn.subagents!.settle();
607
+ // Native terminal registration can land after turn/completed and
608
+ // after an empty terminal inventory. EOF awaits native thread/tool
609
+ // shutdown; an idle snapshot alone cannot release a managed turn.
610
+ confirmed = await client.closeAndWait(graceMs);
611
+ if (!confirmed) throw new Error("Codex native cleanup was not confirmed");
612
+ if (unfinished && !controller.signal.aborted) {
613
+ failure = new Error(
614
+ "Parent finished with outstanding subagent work; workers were stopped and the result is incomplete",
615
+ );
616
+ }
617
+ } catch (error) {
618
+ failure = error;
619
+ confirmed = await client.closeAndWait(graceMs);
620
+ if (!confirmed) this.unsettledSessions.set(options.sessionId, client);
621
+ } finally {
622
+ clearTimeout(deadline);
623
+ }
624
+ turnEnded = true;
625
+ if (controller.signal.aborted) queue.fail(new Error("turn aborted"));
626
+ else if (failure) queue.fail(failure);
627
+ else queue.end();
628
+ return confirmed;
629
+ })();
630
+ return managedCompletion;
631
+ };
548
632
  const forceCancel = () => {
549
633
  if (turnEnded) return;
550
634
  queue.fail(new Error("turn aborted"));
@@ -553,6 +637,10 @@ export class CodexAppServerAgent extends SessionAgent {
553
637
  const onAbort = () => {
554
638
  clearStall();
555
639
  if (interruptState.acknowledgement) return;
640
+ if (turn.subagents) {
641
+ interruptState.acknowledgement = finishManaged();
642
+ return;
643
+ }
556
644
  if (!turnId) {
557
645
  interruptState.acknowledgement = Promise.resolve(false);
558
646
  forceCancel();
@@ -574,6 +662,7 @@ export class CodexAppServerAgent extends SessionAgent {
574
662
 
575
663
  unsubscribe.push(
576
664
  client.onNotification((n) => {
665
+ turn.subagents?.observe(n);
577
666
  if (n.params.threadId && n.params.threadId !== threadId) return;
578
667
  if (n.method === "turn/started") {
579
668
  const turn = n.params.turn as { id?: string } | undefined;
@@ -581,6 +670,12 @@ export class CodexAppServerAgent extends SessionAgent {
581
670
  turnId = turn.id;
582
671
  }
583
672
  }
673
+ if (appServerNotificationEndsTurn(n) && turn.subagents) {
674
+ parentEnded = n.method === "turn/completed";
675
+ queue.push(n);
676
+ void finishManaged();
677
+ return;
678
+ }
584
679
  if (appServerNotificationEndsTurn(n)) turnEnded = true;
585
680
  if (turnEnded && controller.signal.aborted) {
586
681
  queue.fail(new Error("turn aborted"));
@@ -602,13 +697,14 @@ export class CodexAppServerAgent extends SessionAgent {
602
697
  );
603
698
  // If the subprocess dies mid-turn, fail the turn instead of hanging.
604
699
  unsubscribe.push(
605
- client.onClose((err) =>
700
+ client.onClose((err) => {
701
+ if (managedCompletion) return;
606
702
  queue.fail(
607
703
  controller.signal.aborted
608
704
  ? new Error("turn aborted")
609
705
  : (err ?? new Error("codex app-server exited mid-turn")),
610
- ),
611
- ),
706
+ );
707
+ }),
612
708
  );
613
709
 
614
710
  controller.signal.addEventListener("abort", onAbort, { once: true });
@@ -628,7 +724,8 @@ export class CodexAppServerAgent extends SessionAgent {
628
724
  }
629
725
  client.request("turn/start", turnParams).catch((err: unknown) => {
630
726
  if (controller.signal.aborted) {
631
- forceCancel();
727
+ if (turn.subagents) void finishManaged();
728
+ else forceCancel();
632
729
  return;
633
730
  }
634
731
  queue.push({ method: "error", params: { message: String(err) } });
@@ -641,6 +738,11 @@ export class CodexAppServerAgent extends SessionAgent {
641
738
  } catch (error) {
642
739
  throw controller.signal.aborted ? new Error("turn aborted") : error;
643
740
  } finally {
741
+ if (managedCompletion) await managedCompletion;
742
+ // Every exit owns cleanup, including turn/start failure and iterator.return().
743
+ if (options.subagentMode === "turn" && session && !(await session.client.closeAndWait())) {
744
+ this.unsettledSessions.set(options.sessionId, session.client);
745
+ }
644
746
  turnEnded = true;
645
747
  clearStall();
646
748
  if (cancelTimer) clearTimeout(cancelTimer);
@@ -664,7 +766,13 @@ export class CodexAppServerAgent extends SessionAgent {
664
766
  // set on a later microtask, so reading afterwards would race that drain.
665
767
  const controllers = [...(this.inflight.get(sessionId) ?? [])];
666
768
  const base = await super.cancel(sessionId);
667
- if (!base.hadTurn) return base;
769
+ if (!base.hadTurn) {
770
+ const unsettled = this.unsettledSessions.get(sessionId);
771
+ if (!unsettled) return base;
772
+ const confirmed = await unsettled.closeAndWait();
773
+ if (confirmed) this.unsettledSessions.delete(sessionId);
774
+ return { confirmed, hadTurn: false };
775
+ }
668
776
  const acknowledgements = await Promise.all(
669
777
  controllers.map(
670
778
  (controller) => this.turnInterruptState.get(controller)?.acknowledgement ?? false,
@@ -691,6 +799,13 @@ export class CodexAppServerAgent extends SessionAgent {
691
799
  }
692
800
 
693
801
  override async release(sessionId: string): Promise<void> {
802
+ // A host may release this harness before switching providers. Do not let
803
+ // that path discard ownership while managed child cleanup is unconfirmed.
804
+ const managed =
805
+ this.unsettledSessions.has(sessionId) ||
806
+ this.sessions.get(sessionId)?.subagentMode === "turn";
807
+ if (managed && !(await this.cancel(sessionId)).confirmed)
808
+ throw new Error("Codex native cleanup was not confirmed; cannot release the session");
694
809
  await super.release(sessionId);
695
810
  this.sessions.close(sessionId);
696
811
  for (const [home, owner] of this.mcpHomes) {
@@ -0,0 +1,201 @@
1
+ import type { CodexAppServerClient, CodexNotification } from "./client.ts";
2
+
3
+ interface NativeThread {
4
+ id: string;
5
+ parentThreadId?: string | null;
6
+ status?: { type: string };
7
+ source?: { subagent?: { thread_spawn?: { parent_thread_id?: string } } } | string;
8
+ turns?: Array<{ id: string; status: string }>;
9
+ }
10
+
11
+ function parentOf(thread: NativeThread): string | undefined {
12
+ return (
13
+ thread.parentThreadId ??
14
+ (typeof thread.source === "object"
15
+ ? thread.source.subagent?.thread_spawn?.parent_thread_id
16
+ : undefined)
17
+ );
18
+ }
19
+
20
+ /** Native ownership and cleanup for ONE execution; scheduling stays in Codex. */
21
+ export class CodexTurnSubagents {
22
+ private readonly parents = new Map<string, string>();
23
+ private readonly candidates = new Set<string>();
24
+ private readonly active = new Set<string>();
25
+ private activeAtParentEnd = new Set<string>();
26
+
27
+ constructor(
28
+ private readonly client: CodexAppServerClient,
29
+ private readonly root: string,
30
+ private readonly timeoutMs: number,
31
+ ) {}
32
+
33
+ observe(notification: CodexNotification): void {
34
+ const id = notification.params.threadId;
35
+ if (id && id !== this.root) {
36
+ if (notification.method === "turn/started") this.active.add(id);
37
+ if (notification.method === "turn/completed") this.active.delete(id);
38
+ if (notification.method === "thread/status/changed") {
39
+ const status = notification.params.status as { type?: string } | undefined;
40
+ if (status?.type === "active") this.active.add(id);
41
+ else if (status?.type) this.active.delete(id);
42
+ }
43
+ }
44
+ if (id === this.root && notification.method === "turn/completed")
45
+ this.activeAtParentEnd = new Set(this.active);
46
+ if (notification.method === "thread/started") {
47
+ const thread = notification.params.thread as NativeThread | undefined;
48
+ const parent = thread && parentOf(thread);
49
+ if (thread?.id && parent) {
50
+ this.parents.set(thread.id, parent);
51
+ this.candidates.add(thread.id);
52
+ if (thread.status?.type === "active") this.active.add(thread.id);
53
+ }
54
+ }
55
+ if (notification.params.threadId !== this.root) return;
56
+ const item = notification.params.item as
57
+ | {
58
+ type?: string;
59
+ agentThreadId?: string;
60
+ receiverThreadIds?: string[];
61
+ agentsStates?: Record<string, { status: string }>;
62
+ }
63
+ | undefined;
64
+ if (item?.type === "subAgentActivity" && item.agentThreadId)
65
+ this.candidates.add(item.agentThreadId);
66
+ if (item?.type === "collabAgentToolCall") {
67
+ for (const id of item.receiverThreadIds ?? []) this.candidates.add(id);
68
+ for (const [id, state] of Object.entries(item.agentsStates ?? {})) {
69
+ if (state.status === "running" || state.status === "pendingInit") this.active.add(id);
70
+ else this.active.delete(id);
71
+ }
72
+ }
73
+ }
74
+
75
+ private async read(id: string): Promise<NativeThread> {
76
+ const result = (await this.client.request(
77
+ "thread/read",
78
+ { threadId: id, includeTurns: true },
79
+ this.timeoutMs,
80
+ )) as { thread?: NativeThread };
81
+ if (result.thread?.id !== id)
82
+ throw new Error("Codex did not confirm the requested child thread");
83
+ const parent = parentOf(result.thread);
84
+ if (parent) this.parents.set(id, parent);
85
+ return result.thread;
86
+ }
87
+
88
+ async owns(id: string): Promise<boolean> {
89
+ const visited = new Set<string>();
90
+ let current = id;
91
+ while (current !== this.root) {
92
+ if (visited.has(current) || visited.size >= 32) return false;
93
+ visited.add(current);
94
+ const parent = this.parents.get(current) ?? parentOf(await this.read(current));
95
+ if (!parent) return false;
96
+ current = parent;
97
+ }
98
+ if (id !== this.root) this.candidates.add(id);
99
+ return true;
100
+ }
101
+
102
+ private async descendants(): Promise<NativeThread[]> {
103
+ // The native inventory closes missed/start-notification races, including nested workers.
104
+ let cursor: string | undefined;
105
+ const cursors = new Set<string>();
106
+ do {
107
+ const page = (await this.client.request(
108
+ "thread/loaded/list",
109
+ {
110
+ limit: 100,
111
+ ...(cursor && { cursor }),
112
+ },
113
+ this.timeoutMs,
114
+ )) as { data?: string[]; nextCursor?: string | null };
115
+ if (!Array.isArray(page.data)) throw new Error("Codex did not return its child inventory");
116
+ for (const id of page.data) this.candidates.add(id);
117
+ cursor = page.nextCursor ?? undefined;
118
+ if (cursor && cursors.has(cursor)) throw new Error("Codex repeated a child inventory cursor");
119
+ if (cursor) cursors.add(cursor);
120
+ if (this.candidates.size > 512)
121
+ throw new Error("Codex child inventory exceeds the managed turn limit");
122
+ } while (cursor);
123
+ const threads: NativeThread[] = [];
124
+ for (const id of this.candidates) {
125
+ if (id !== this.root && (await this.owns(id))) threads.push(await this.read(id));
126
+ }
127
+ return threads;
128
+ }
129
+
130
+ /** Root must have stopped first. Returns whether it left unfinished delegated work. */
131
+ async settle(): Promise<boolean> {
132
+ let unfinished = false;
133
+ for (const id of this.activeAtParentEnd) {
134
+ if (await this.owns(id)) unfinished = true;
135
+ }
136
+ let idleInventory: string | undefined;
137
+ for (let pass = 0; pass < 8; pass++) {
138
+ let stoppedWork = false;
139
+ const descendants = await this.descendants();
140
+ for (const thread of descendants) {
141
+ if (!thread.status) throw new Error("Codex child liveness is unknown");
142
+ if (thread.status.type === "active") {
143
+ unfinished = true;
144
+ stoppedWork = true;
145
+ const active = thread.turns
146
+ ?.slice()
147
+ .reverse()
148
+ .find((turn) => turn.status === "inProgress");
149
+ // Empty IDs are native startup interrupts, acknowledged on submission.
150
+ // The read below and final EOF cleanup still have to establish settlement.
151
+ // A worker can finish naturally between the inventory and the interrupt.
152
+ await this.client
153
+ .request(
154
+ "turn/interrupt",
155
+ { threadId: thread.id, turnId: active?.id ?? "" },
156
+ this.timeoutMs,
157
+ )
158
+ .catch(async (error) => {
159
+ if ((await this.read(thread.id)).status?.type !== "idle") throw error;
160
+ });
161
+ const stopped = await this.read(thread.id);
162
+ if (!stopped.status || stopped.status.type === "active")
163
+ throw new Error("Codex child interrupt was not confirmed");
164
+ }
165
+ if (thread.status.type === "notLoaded") continue;
166
+ const terminals = (await this.client.request(
167
+ "thread/backgroundTerminals/list",
168
+ { threadId: thread.id },
169
+ this.timeoutMs,
170
+ )) as { data?: unknown[] };
171
+ if (!Array.isArray(terminals.data))
172
+ throw new Error("Codex child terminal liveness is unknown");
173
+ if (terminals.data.length) {
174
+ unfinished = true;
175
+ stoppedWork = true;
176
+ await this.client.request(
177
+ "thread/backgroundTerminals/clean",
178
+ { threadId: thread.id },
179
+ this.timeoutMs,
180
+ );
181
+ const remaining = (await this.client.request(
182
+ "thread/backgroundTerminals/list",
183
+ { threadId: thread.id },
184
+ this.timeoutMs,
185
+ )) as { data?: unknown[] };
186
+ if (!Array.isArray(remaining.data) || remaining.data.length)
187
+ throw new Error("Codex child terminals did not stop");
188
+ }
189
+ }
190
+ // Interrupting a worker may race its final spawn. Reconcile the native
191
+ // inventory after stops, while the root can no longer create new work.
192
+ const inventory = descendants
193
+ .map((thread) => thread.id)
194
+ .sort()
195
+ .join("\n");
196
+ if (!stoppedWork && inventory === idleInventory) return unfinished;
197
+ idleInventory = stoppedWork ? undefined : inventory;
198
+ }
199
+ throw new Error("Codex descendants did not settle within the managed cleanup bound");
200
+ }
201
+ }
@@ -23,6 +23,10 @@ export interface CodexToolItem {
23
23
  result?: unknown;
24
24
  error?: { message?: string } | string;
25
25
  query?: string;
26
+ receiverThreadIds?: string[];
27
+ prompt?: string | null;
28
+ model?: string | null;
29
+ agentsStates?: Record<string, { status: string; message?: string | null }>;
26
30
  }
27
31
 
28
32
  export interface CodexToolDescriptor {
@@ -60,6 +64,27 @@ function stringifyMcpResult(result: unknown): string {
60
64
  const failed = (item: CodexToolItem) => item.status === "failed";
61
65
 
62
66
  export const CODEX_TOOL_ITEMS: Record<string, CodexToolDescriptor> = {
67
+ collabAgentToolCall: {
68
+ toolName: (item) => item.tool ?? "collabAgentToolCall",
69
+ input: (item) => ({
70
+ ...(item.prompt != null && { prompt: item.prompt }),
71
+ ...(item.model != null && { model: item.model }),
72
+ receiverThreadIds: item.receiverThreadIds ?? [],
73
+ }),
74
+ meta: (item) => ({
75
+ kind: "task",
76
+ ...(item.tool === "spawnAgent" && {
77
+ subagent: {
78
+ ...(item.model && { model: item.model }),
79
+ ...(item.receiverThreadIds?.[0] && { agentId: item.receiverThreadIds[0] }),
80
+ },
81
+ }),
82
+ }),
83
+ result: (item) => ({
84
+ output: JSON.stringify(item.agentsStates ?? {}),
85
+ isError: failed(item) || item.status === "interrupted",
86
+ }),
87
+ },
63
88
  commandExecution: {
64
89
  toolName: () => "shell",
65
90
  input: (item) => ({ command: item.command ?? "" }),
@@ -104,6 +129,8 @@ export const CODEX_TOOL_ITEMS: Record<string, CodexToolDescriptor> = {
104
129
  /** Map either protocol's item-type spelling onto a descriptor. */
105
130
  export function codexToolDescriptor(itemType: string): CodexToolDescriptor | undefined {
106
131
  switch (itemType) {
132
+ case "collabAgentToolCall":
133
+ return CODEX_TOOL_ITEMS.collabAgentToolCall;
107
134
  case "command_execution":
108
135
  case "commandExecution":
109
136
  return CODEX_TOOL_ITEMS.commandExecution;
@@ -21,6 +21,9 @@ export abstract class SessionAgent extends BaseAgent {
21
21
  ): AsyncIterableIterator<RawAgentEvent> {
22
22
  const { sessionId } = options;
23
23
  if (this.terminated) throw new Error("Agent has been terminated");
24
+ if (options.subagentMode && !this.capabilities.turnScopedSubagents) {
25
+ throw new Error(`${this.harness} does not support turn-scoped subagents`);
26
+ }
24
27
  if (this.activeSessions.has(sessionId)) throw new TurnActiveError(sessionId);
25
28
  this.activeSessions.add(sessionId);
26
29
  const controller = this.trackTurn(sessionId, options.signal);
@@ -396,6 +396,7 @@ export class AgentRuntime {
396
396
  thinkingLevel: config.thinkingLevel,
397
397
  permissionMode: config.permissionMode,
398
398
  maxTurns: config.maxTurns,
399
+ subagentMode: config.subagentMode,
399
400
  systemPromptAppend: config.systemPromptAppend,
400
401
  resumeSessionId: config.resumeSessionId,
401
402
  resumeSessionAt: config.resumeSessionAt,
@@ -57,6 +57,8 @@ export const RunConfigSchema = z.object({
57
57
  additionalDirectories: z.array(z.string()).optional(),
58
58
  systemPromptAppend: z.string().optional(),
59
59
  maxTurns: z.number().int().positive().optional(),
60
+ /** Native workers must settle before this managed execution releases its context. */
61
+ subagentMode: z.literal("turn").optional(),
60
62
  permissionMode: PermissionModeSchema.optional(),
61
63
  /**
62
64
  * Native session/thread id to resume (from a prior `session.created`). When
@@ -14,7 +14,7 @@
14
14
  import type { DecodedLifecycleEvent, UnknownEvent } from "./lifecycle.ts";
15
15
  import type { Part, UnknownPart } from "./parts.ts";
16
16
 
17
- export const PART_TYPES = ["text", "reasoning", "tool", "image", "file"] as const;
17
+ export const PART_TYPES = ["text", "reasoning", "tool", "subagent", "image", "file"] as const;
18
18
 
19
19
  export function isKnownPartType(type: unknown): type is (typeof PART_TYPES)[number] {
20
20
  return typeof type === "string" && (PART_TYPES as readonly string[]).includes(type);
@@ -46,5 +46,7 @@ export const AgentCapabilitiesSchema = z.object({
46
46
  mcpServers: z.boolean(),
47
47
  /** Can raise interactive `permission.requested` round-trips mid-turn. */
48
48
  permissionRequests: z.boolean(),
49
+ /** Supports native delegation contained within the current execution. */
50
+ turnScopedSubagents: z.boolean().optional(),
49
51
  });
50
52
  export type AgentCapabilities = z.infer<typeof AgentCapabilitiesSchema>;
@@ -81,6 +81,16 @@ export const ToolPartSchema = PartBase.extend({
81
81
  });
82
82
  export type ToolPart = z.infer<typeof ToolPartSchema>;
83
83
 
84
+ /** Native worker activity, including harnesses without a public spawning tool call. */
85
+ export const SubagentPartSchema = PartBase.extend({
86
+ type: z.literal("subagent"),
87
+ nativeActivityId: z.string(),
88
+ nativeThreadId: z.string(),
89
+ path: z.string().optional(),
90
+ activity: z.enum(["started", "interacted", "interrupted", "completed"]),
91
+ });
92
+ export type SubagentPart = z.infer<typeof SubagentPartSchema>;
93
+
84
94
  /**
85
95
  * Exactly one of `data` (base64) | `url` — ENFORCED, not merely documented: a
86
96
  * payload-less blob part renders as a broken image, and one carrying both
@@ -112,6 +122,7 @@ export const PartSchema = z.discriminatedUnion("type", [
112
122
  TextPartSchema,
113
123
  ReasoningPartSchema,
114
124
  ToolPartSchema,
125
+ SubagentPartSchema,
115
126
  ImagePartSchema,
116
127
  FilePartSchema,
117
128
  ]);