@khorsheed/dsh-local-agent-codex 0.1.0-rc.6

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.
@@ -0,0 +1,1225 @@
1
+ /**
2
+ * One-shot Codex CLI subagent lifecycle: spawn `codex exec "<task>"` through
3
+ * the subprocess seam under the harness's scoped home, capture its printed
4
+ * response as the run output, and dispose to whole-tree quiescence. Mirrors
5
+ * the kimi-cli provider: every accepted run is a fresh process and a fresh
6
+ * codex session; there is no continuation across runs.
7
+ *
8
+ * The provider name `codex-local` avoids colliding with the official
9
+ * `subagent-codex` package's provider name `codex` — a composition that
10
+ * mounts both would fail loud with DUPLICATE_PROVIDER. The tool row in this
11
+ * bundle's patch takes the official model-facing name `subagent_codex`
12
+ * instead: the official preset row ships disabled, and the patch disables
13
+ * it too (a deliberate user re-enable conflicts loud, by design).
14
+ * @module @khorsheed/dsh-local-agent-codex/codex-cli-provider
15
+ */
16
+ import { randomUUID } from 'node:crypto';
17
+ import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
18
+ import { SessionId } from '@deepseek-ai/dsh-session';
19
+ import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
20
+ import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, persistChildSession, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
21
+ import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
22
+ import { LiveChannelUnavailableError } from './live-driver.js';
23
+ import { readCodexBaseUrl } from './provision.js';
24
+ import { codexRolloutRoundFacts, usageFromCodex } from './records.js';
25
+ /** Quote one TOML basic string for the `-c` config override. */
26
+ function tomlString(value) {
27
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
28
+ }
29
+ /** Default POSIX grace between subprocess termination tiers. */
30
+ export const DEFAULT_DISPOSE_GRACE_MS = 3_000;
31
+ /**
32
+ * Auth-shaped failure signatures in codex's stderr or event stream: the
33
+ * endpoint rejected the credential. Narrow on purpose — matched against
34
+ * provider-controlled error output only.
35
+ */
36
+ export const CODEX_AUTH_FAILURE = /401 unauthorized|unauthorized|authentication failed|not logged in|access token/i;
37
+ /** Register one member run with the channel; see {@link CodexMemberRunHandle}. */
38
+ export function registerCodexMemberRun(ctx, childSessionId, parentSessionId) {
39
+ const registry = ctx.localAgent;
40
+ if (typeof registry.registerMemberRun !== 'function'
41
+ || typeof registry.memberBridgeSocketPath !== 'function'
42
+ || typeof registry.memberBridgeCommand !== 'function')
43
+ return undefined;
44
+ const token = registry.registerMemberRun({ childSessionId, parentSessionId, provider: 'codex-local' });
45
+ // Dashed server names are valid TOML bare keys (spike-verified parse).
46
+ const serverName = `dsh-member-${token.slice(0, 8)}`;
47
+ const bridge = registry.memberBridgeCommand();
48
+ const configOverride = `mcp_servers.${serverName}={`
49
+ + `command=${tomlString(bridge.command)},`
50
+ + `args=[${bridge.args.map(tomlString).join(',')}],`
51
+ + `env={${MEMBER_BRIDGE_SOCKET_ENV}=${tomlString(registry.memberBridgeSocketPath())},${MEMBER_BRIDGE_TOKEN_ENV}=${tomlString(token)}},`
52
+ + `default_tools_approval_mode=${tomlString('approve')}`
53
+ + `}`;
54
+ let released = false;
55
+ return {
56
+ token,
57
+ configOverride,
58
+ release: () => {
59
+ if (released)
60
+ return;
61
+ released = true;
62
+ registry.unregisterMemberRun(token);
63
+ },
64
+ };
65
+ }
66
+ /**
67
+ * One-shot and resumable Codex CLI subagent provider: every accepted FRESH run
68
+ * starts a `codex exec` process in the delegating Session's workspace, under
69
+ * the harness scoped home; a resume round (the family tool's staged resume
70
+ * intent) continues the SAME thread with `codex exec --json resume <thread_id>`
71
+ * inside the SAME dsh child session. With the live driver configured
72
+ * (`live: true`), rounds instead go to the resident app-server process (see
73
+ * live-driver.ts); the exec path below stays the fallback.
74
+ */
75
+ /**
76
+ * Mark the harness credential verified after a completed round, degrading
77
+ * silently on a core that predates the mark (the family's companion-pair
78
+ * rule: a provider paired with an older core loses the grade, never the run).
79
+ * @param ctx - host context carrying the family registry.
80
+ */
81
+ function markCredentialVerified(ctx, scope) {
82
+ const registry = ctx.get('localAgent');
83
+ if (registry === undefined || typeof registry.reportAuthSuccess !== 'function')
84
+ return;
85
+ // The grade belongs to the scope the round ran against: each scoped home
86
+ // holds its own account, so a verified credential in one says nothing about
87
+ // another's.
88
+ registry.reportAuthSuccess('codex', scope);
89
+ }
90
+ /**
91
+ * The codex build `codex --version` reports, probed through the shared
92
+ * subprocess seam inside the SCOPED home (never the user's own installation)
93
+ * and cached by the family probe against the executable's identity. Both the
94
+ * effective-settings snapshot and a settled round's read-back use it — the
95
+ * round prefers its own rollout head, which names the build that actually ran.
96
+ * @param ctx - host context carrying the subprocess seam.
97
+ * @param homeDir - the harness's scoped home.
98
+ * @returns the version, or undefined when the CLI cannot be asked.
99
+ */
100
+ export function codexCliVersion(ctx, homeDir) {
101
+ // Degrade, don't explode: a composition without the subprocess seam simply
102
+ // reports no version, exactly as an unaskable CLI does.
103
+ const subprocess = ctx.get('subprocess');
104
+ if (subprocess === undefined)
105
+ return Promise.resolve(undefined);
106
+ return probeCliVersion({
107
+ argv: ['codex', '--version'],
108
+ cwd: homeDir,
109
+ spawn: spec => subprocess.spawn(spec),
110
+ env: delegationEnv({ CODEX_HOME: homeDir }),
111
+ });
112
+ }
113
+ export class CodexCliProvider {
114
+ ctx;
115
+ sandbox;
116
+ live;
117
+ model;
118
+ overrides;
119
+ name = 'codex-local';
120
+ capabilities = NO_START_CAPABILITIES;
121
+ inheritsParentContext = false;
122
+ /**
123
+ * @param live - the live driver, or a resolver returning the current
124
+ * generation's driver per member (the settings toggle swaps generations;
125
+ * a resolver may return undefined to steer one member's round to exec
126
+ * while a retiring generation still hosts it).
127
+ * @param model - resolver for the configured model, read PER ROUND so a
128
+ * settings-card write takes effect on the next delegation without a
129
+ * reload. Undefined (the resolver absent, or returning undefined) leaves
130
+ * the argv exactly as it was before the key existed — the scoped
131
+ * config.toml's own `model` then decides, as it always did.
132
+ * @param overrides - resolver for the member's session-level model override
133
+ * (the composer picker, owned by the model broker). It outranks BOTH the
134
+ * delegation's recorded model and the configured one: on the exec path it
135
+ * re-points the next round's `-m`, and on the live path it is the member's
136
+ * start model — a resident runtime bound to a different model is retired
137
+ * so the round respawns onto the override.
138
+ */
139
+ constructor(ctx, sandbox = 'workspace-write', live, model, overrides) {
140
+ this.ctx = ctx;
141
+ this.sandbox = sandbox;
142
+ this.live = live;
143
+ this.model = model;
144
+ this.overrides = overrides;
145
+ }
146
+ /** Resolve the live driver for one round's member, if live is on for it. */
147
+ liveDriver(childSessionId) {
148
+ const live = this.live;
149
+ if (live === undefined)
150
+ return undefined;
151
+ return typeof live === 'function' ? live(childSessionId) : live;
152
+ }
153
+ /** Per-round member-channel registration for the exec path (see {@link registerCodexMemberRun}). */
154
+ memberRun(childSessionId, parentSessionId) {
155
+ return registerCodexMemberRun(this.ctx, childSessionId, parentSessionId);
156
+ }
157
+ async start(request) {
158
+ // The family tool stages exactly one intent per delegation call; the
159
+ // provider consumes exactly one per start. A resume intent continues the
160
+ // recorded thread inside the existing child session.
161
+ const intent = this.ctx.localAgent.takeDelegationIntent(request.parent.session.id, this.name);
162
+ // The scoped home this round runs against: the staged intent's scope
163
+ // names a sibling directory under the homes root, absent means the
164
+ // default one — the directory every round used before scopes existed.
165
+ const scope = intent?.scope;
166
+ const homeDir = this.ctx.localAgent.homeDir('codex', scope);
167
+ // The effective cwd: the caller's override (the staged intent's `cwd`,
168
+ // riding DelegationCallOptions.cwd) when present, else the parent
169
+ // session's workspace — the behavior before overrides existed.
170
+ const cwd = resolveChildCwd(request.parent.session.header.cwd, intent?.cwd);
171
+ if (cwd === undefined) {
172
+ throw new Error('subagent-codex: the parent session has no working directory to run the CLI in');
173
+ }
174
+ // Container exec target: the same round, spawned through `docker exec`
175
+ // inside a unit the caller acquired. Validated here so a target missing
176
+ // the in-container scoped home fails before any session record is made.
177
+ const exec = intent?.exec;
178
+ if (exec !== undefined)
179
+ containerScopedHome(exec, 'CODEX_HOME', 'subagent-codex');
180
+ if (intent !== undefined && intent.kind === 'resume') {
181
+ // A CLI session continues in the directory its first round ran in; a
182
+ // round resolving elsewhere is rejected before any process spawns.
183
+ const record = this.ctx.localAgent.getDelegation(intent.childSessionId);
184
+ assertResumeCwdUnchanged(record, cwd, 'subagent-codex');
185
+ // …and in the scoped home its first round ran in: a resume naming
186
+ // another scope would continue the thread under another account.
187
+ assertResumeScopeUnchanged(record, scope, 'subagent-codex');
188
+ // A resume re-requests the model the FIRST round recorded — the caller
189
+ // cannot name one (the facade refuses it), and a record without one is
190
+ // a delegation that named none, which this round repeats.
191
+ if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function')
192
+ return this.startCodexResume(request, intent, cwd, homeDir, exec, scope, record?.model);
193
+ return this.ctx.localAgent.withMemberConfigurationRound({
194
+ childSessionId: intent.childSessionId, provider: this.name, parentSessionId: request.parent.session.id, cwd,
195
+ ...scope === undefined ? {} : { scope },
196
+ ...record?.model === undefined ? {} : { model: record.model },
197
+ ...record?.effort === undefined ? {} : { effort: record.effort },
198
+ ...record?.configurationLock === undefined ? {} : { configurationLock: record.configurationLock },
199
+ }, configuration => this.startCodexResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
200
+ }
201
+ const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
202
+ if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function') {
203
+ if (intent?.effort !== undefined)
204
+ throw new Error('Codex effort requires the configuration admission core');
205
+ return this.startCodexFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
206
+ }
207
+ return this.ctx.localAgent.withMemberConfigurationRound({
208
+ childSessionId, provider: this.name, parentSessionId: request.parent.session.id, cwd,
209
+ ...scope === undefined ? {} : { scope },
210
+ ...intent?.model === undefined ? {} : { model: intent.model },
211
+ ...intent?.effort === undefined ? {} : { effort: intent.effort },
212
+ ...intent?.configurationLock === undefined ? {} : { configurationLock: intent.configurationLock },
213
+ }, configuration => this.startCodexFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId, configuration), request.signal, intent?.onAdmitted);
214
+ }
215
+ /** Fresh round: record the child session, spawn `codex exec`, append after settle. */
216
+ async startCodexFresh(request, cwd, homeDir, exec, scope,
217
+ /** The model this DELEGATION requested, when the caller named one. */
218
+ requestedModel, runId, configuration) {
219
+ let childSession;
220
+ try {
221
+ const sessions = this.ctx.get('sessions');
222
+ if (sessions === undefined) {
223
+ throw new Error('the sessions service is not mounted');
224
+ }
225
+ childSession = sessions.get(runId) ?? sessions.create(runId, {
226
+ meta: {
227
+ cwd,
228
+ parentSession: request.parent.session.id,
229
+ origin: 'subagent',
230
+ delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1,
231
+ },
232
+ });
233
+ const harness = this.ctx.localAgent.get('codex');
234
+ const label = subagentDelegationLabel(harness?.displayName ?? 'codex', request.descriptor.label);
235
+ childSession.append('subagent/descriptor', {
236
+ ...request.descriptor,
237
+ label,
238
+ });
239
+ // The official runtime appends the parent-side subagent/catalog
240
+ // discovery row only for in-process children (run.localAgent); this run
241
+ // is remote, so the provider appends it here — once per child, where
242
+ // the descriptor lands. A failure degrades to the warn below; a missing
243
+ // row never blocks the delegation.
244
+ establishSubagentCatalogChild(request.parent.session, childSession.header, label);
245
+ // No persistence create here: the stored session is materialized by the
246
+ // first persistChildSession sync through the CORE's cached write handle.
247
+ // A fire-and-forget create from the provider used to hold (and leak) a
248
+ // second write handle, which blocks the core's claim with
249
+ // SessionAlreadyOwnedError.
250
+ }
251
+ catch (error) {
252
+ this.ctx.logger.warn(`subagent-codex: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
253
+ }
254
+ // Resolve the effective custom endpoint from the scoped config.toml for
255
+ // diagnostics: codex reads it directly (a user manually editing the
256
+ // config to route through a custom provider is authoritative). Logged
257
+ // BEFORE the drive-mode branch so live rounds report their endpoint too.
258
+ const baseUrl = await readCodexBaseUrl(homeDir).catch(() => undefined);
259
+ this.ctx.logger.info(`subagent-codex: delegating via ${baseUrl ?? 'codex default endpoint'}`);
260
+ // Live driver: the round goes to the resident app-server process (one per
261
+ // member). A channel that fails at spawn/handshake falls through to the
262
+ // exec one-shot below — and stays there until the breaker cools down.
263
+ // A container target always takes the exec one-shot: the live driver runs
264
+ // a resident app-server on the HOST, which is the transport the target
265
+ // exists to replace.
266
+ const live = exec === undefined ? this.liveDriver(runId) : undefined;
267
+ if (live !== undefined && childSession !== undefined && !live.disabled) {
268
+ // The member-bound live driver receives this exact scoped home.
269
+ // A round that names its own model is NOT refused: it becomes the
270
+ // member's start model, bound at the runtime spawn (a runtime bound to
271
+ // a different model is retired first, so the fresh thread spawns onto
272
+ // the asked-for model). The session-level override outranks even this.
273
+ const startModel = configuration === undefined ? this.overrides?.(runId) ?? requestedModel : configuration.resolved.model;
274
+ try {
275
+ return await live.startRound(request, {
276
+ cwd,
277
+ homeDir,
278
+ childSession,
279
+ parentSessionId: request.parent.session.id,
280
+ ...startModel === undefined ? {} : { startModel },
281
+ ...configuration === undefined ? {} : { configuration: configuration.resolved },
282
+ // The thread id arrives with thread/start (server-assigned), far
283
+ // earlier than the exec path's settle-time parse.
284
+ onThreadId: (threadId) => {
285
+ this.ctx.localAgent.recordDelegation({
286
+ childSessionId: runId,
287
+ provider: this.name,
288
+ parentSessionId: request.parent.session.id,
289
+ cliSessionId: threadId,
290
+ // The round's resolved working directory anchors the
291
+ // resume-consistency check.
292
+ cwd,
293
+ // …and its scoped home anchors the resume-scope check.
294
+ ...scope === undefined ? {} : { scope },
295
+ // The model this delegation asked for: every resume round reads
296
+ // it back from here instead of the caller restating it.
297
+ ...requestedModel === undefined ? {} : { model: requestedModel },
298
+ });
299
+ },
300
+ });
301
+ }
302
+ catch (error) {
303
+ if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted)
304
+ throw error;
305
+ this.ctx.logger.warn(`subagent-codex: live driver unavailable, using the exec one-shot: ${error.message}`);
306
+ }
307
+ }
308
+ // Member channel: register this run and carry the bridge declaration on
309
+ // the spawn argv, so the CLI session starts with member_message available.
310
+ // The member bridge is a host unix socket the container cannot reach, and
311
+ // its MCP declaration names a host node path — a containerized round
312
+ // therefore runs WITHOUT the member channel rather than with a broken one.
313
+ const member = exec === undefined ? this.memberRun(runId, request.parent.session.id) : undefined;
314
+ try {
315
+ const run = await startCodexCliRun(request, {
316
+ cwd,
317
+ env: delegationEnv({ CODEX_HOME: homeDir }),
318
+ ...exec === undefined ? {} : { exec },
319
+ endpointLabel: baseUrl,
320
+ sandbox: this.sandbox,
321
+ ...(configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, this.model)),
322
+ disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
323
+ spawn: spec => this.ctx.subprocess.spawn(spec),
324
+ onError: (error, stopReason) => {
325
+ this.ctx.logger.warn(`subagent-codex: child run failed (${stopReason}) via ${baseUrl ?? 'codex default endpoint'}: ${error instanceof Error ? error.message : String(error)}`);
326
+ },
327
+ onAuthFailure: (detail) => { this.ctx.localAgent.reportAuthFailure('codex', detail, scope); },
328
+ onAuthSuccess: () => { markCredentialVerified(this.ctx, scope); },
329
+ cliVersion: () => codexCliVersion(this.ctx, homeDir),
330
+ ...member === undefined ? {} : { member: { configOverride: member.configOverride } },
331
+ childSession,
332
+ ctx: this.ctx,
333
+ // The first round records the thread id so a later resume round can
334
+ // continue it.
335
+ onThreadId: (threadId) => {
336
+ if (threadId === undefined)
337
+ return;
338
+ this.ctx.localAgent.recordDelegation({
339
+ childSessionId: runId,
340
+ provider: this.name,
341
+ parentSessionId: request.parent.session.id,
342
+ cliSessionId: threadId,
343
+ // The round's resolved working directory anchors the
344
+ // resume-consistency check.
345
+ cwd,
346
+ // …and its scoped home anchors the resume-scope check.
347
+ ...scope === undefined ? {} : { scope },
348
+ // The model this delegation asked for (see the live branch).
349
+ ...requestedModel === undefined ? {} : { model: requestedModel },
350
+ });
351
+ },
352
+ // Every settled round reports its observed model and usage through the
353
+ // registry's observation channel (record merge + `settled` event).
354
+ onRoundSettled: (round) => {
355
+ this.ctx.localAgent.recordRoundSettled(runId, round);
356
+ },
357
+ });
358
+ // The member-channel token dies with the run, whatever its stop reason.
359
+ if (member !== undefined)
360
+ void run.result.then(member.release, member.release);
361
+ return run;
362
+ }
363
+ catch (error) {
364
+ member?.release();
365
+ throw error;
366
+ }
367
+ }
368
+ /** Resume round: continue the recorded thread inside the existing child session. */
369
+ async startCodexResume(request, intent, cwd, homeDir, exec, scope,
370
+ /** The model the delegation's FIRST round recorded, re-requested here. */
371
+ requestedModel, configuration) {
372
+ // One in-flight resume per child session: a second resume of the same
373
+ // child fails loud instead of racing the first process. The lock releases
374
+ // on every settle path (the run.result handler below) and on the error
375
+ // path, so a deadlock never strands a later resume.
376
+ if (!this.ctx.localAgent.acquireResumeLock(intent.childSessionId)) {
377
+ throw new Error(`subagent-codex: 该子会话有进行中的委派,等其完成后再追问 (child session ${intent.childSessionId})`);
378
+ }
379
+ try {
380
+ const sessions = this.ctx.get('sessions');
381
+ const childSession = sessions?.get(SessionId(intent.childSessionId));
382
+ if (childSession === undefined) {
383
+ throw new Error(`subagent-codex: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
384
+ }
385
+ const nextTurn = childSession.snapshotEvents().filter(event => event.type === 'turn/start').length + 1;
386
+ const baseUrl = await readCodexBaseUrl(homeDir).catch(() => undefined);
387
+ // Logged BEFORE the drive-mode branch so live rounds report their endpoint too.
388
+ this.ctx.logger.info(`subagent-codex: resuming via ${baseUrl ?? 'codex default endpoint'}`);
389
+ // Live driver: continue the member's resident app-server thread. Channel
390
+ // spawn/handshake failure falls through to the exec one-shot below.
391
+ // See the fresh path: a container target is exec-only.
392
+ const live = exec === undefined ? this.liveDriver(intent.childSessionId) : undefined;
393
+ if (live !== undefined && !live.disabled) {
394
+ // Resume retains the recorded scope and its native session.
395
+ // The resume re-requests its recorded model as the member's start
396
+ // model (the session-level override outranks it): a runtime bound to
397
+ // a different model is retired so the thread respawns onto this one.
398
+ const startModel = configuration === undefined ? this.overrides?.(intent.childSessionId) ?? requestedModel : configuration.resolved.model;
399
+ try {
400
+ const liveRun = await live.startRound(request, {
401
+ cwd,
402
+ homeDir,
403
+ childSession,
404
+ parentSessionId: request.parent.session.id,
405
+ resume: { cliSessionId: intent.cliSessionId, turn: nextTurn },
406
+ ...startModel === undefined ? {} : { startModel },
407
+ ...configuration === undefined ? {} : { configuration: configuration.resolved },
408
+ });
409
+ void liveRun.result.then(() => { this.ctx.localAgent.releaseResumeLock(intent.childSessionId); }, () => { this.ctx.localAgent.releaseResumeLock(intent.childSessionId); });
410
+ return liveRun;
411
+ }
412
+ catch (error) {
413
+ if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted)
414
+ throw error;
415
+ this.ctx.logger.warn(`subagent-codex: live driver unavailable, using the exec one-shot: ${error.message}`);
416
+ }
417
+ }
418
+ // Member channel: register the resume round (same child session, fresh
419
+ // per-run token) before the spawn.
420
+ // See the fresh path: no member channel across the container boundary.
421
+ const member = exec === undefined ? this.memberRun(intent.childSessionId, request.parent.session.id) : undefined;
422
+ let run;
423
+ try {
424
+ run = await startCodexCliRun(request, {
425
+ cwd,
426
+ env: delegationEnv({ CODEX_HOME: homeDir }),
427
+ ...exec === undefined ? {} : { exec },
428
+ endpointLabel: baseUrl,
429
+ sandbox: this.sandbox,
430
+ ...(configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, this.model)),
431
+ disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
432
+ spawn: spec => this.ctx.subprocess.spawn(spec),
433
+ onError: (error, stopReason) => {
434
+ this.ctx.logger.warn(`subagent-codex: child run failed (${stopReason}) via ${baseUrl ?? 'codex default endpoint'}: ${error instanceof Error ? error.message : String(error)}`);
435
+ },
436
+ onAuthFailure: (detail) => { this.ctx.localAgent.reportAuthFailure('codex', detail, scope); },
437
+ onAuthSuccess: () => { markCredentialVerified(this.ctx, scope); },
438
+ cliVersion: () => codexCliVersion(this.ctx, homeDir),
439
+ ...member === undefined ? {} : { member: { configOverride: member.configOverride } },
440
+ childSession,
441
+ ctx: this.ctx,
442
+ resume: { cliSessionId: intent.cliSessionId, turn: nextTurn },
443
+ // Every settled round reports its observed model and usage through
444
+ // the registry's observation channel.
445
+ onRoundSettled: (round) => {
446
+ this.ctx.localAgent.recordRoundSettled(intent.childSessionId, round);
447
+ },
448
+ });
449
+ }
450
+ catch (error) {
451
+ member?.release();
452
+ throw error;
453
+ }
454
+ void run.result.then(() => {
455
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
456
+ member?.release();
457
+ }, () => {
458
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
459
+ member?.release();
460
+ });
461
+ return run;
462
+ }
463
+ catch (error) {
464
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
465
+ throw error;
466
+ }
467
+ }
468
+ }
469
+ function thrown(value) {
470
+ /* v8 ignore next -- typed subprocess failures reject with Error. */
471
+ return value instanceof Error ? value : new Error(String(value));
472
+ }
473
+ /**
474
+ * Validate and join the one-shot task before crossing the process boundary.
475
+ * @param prompt - task content accepted from the shared subagent service.
476
+ * @returns the joined non-empty text.
477
+ */
478
+ export function textTask(prompt) {
479
+ if (prompt.length === 0) {
480
+ throw new Error('subagent-codex: the one-shot task must contain only text blocks');
481
+ }
482
+ const texts = [];
483
+ for (const block of prompt) {
484
+ if (block.type !== 'text') {
485
+ throw new Error('subagent-codex: the one-shot task must contain only text blocks');
486
+ }
487
+ texts.push(block.text);
488
+ }
489
+ if (texts.every(text => text.trim().length === 0)) {
490
+ throw new Error('subagent-codex: the one-shot task must not be empty');
491
+ }
492
+ return texts.join('\n');
493
+ }
494
+ /** Count one tool call under the CLI's own name for the item. */
495
+ function countToolCall(state, name) {
496
+ state.toolCalls.set(name, (state.toolCalls.get(name) ?? 0) + 1);
497
+ }
498
+ /**
499
+ * Fold a tool-call counter into the settled shape: `{count, byName}` with the
500
+ * per-name values summing to `count`. An empty map yields undefined — a
501
+ * stream that named no tool call reports nothing rather than a zero it did
502
+ * not observe.
503
+ * @param counts - the fold's per-name tally.
504
+ * @returns the settled accounting, or undefined when nothing was counted.
505
+ */
506
+ export function toolCallsOf(counts) {
507
+ if (counts.size === 0)
508
+ return undefined;
509
+ let count = 0;
510
+ const byName = {};
511
+ for (const [name, value] of counts) {
512
+ count += value;
513
+ byName[name] = value;
514
+ }
515
+ return { count, byName };
516
+ }
517
+ /**
518
+ * Fold one NDJSON line into the stream state. Shared by
519
+ * {@link parseCodexJsonStream} (settle-time whole-stream parse) and
520
+ * {@link CodexStreamParser} (live incremental parse) so the two paths cannot
521
+ * drift.
522
+ */
523
+ function foldCodexStreamLine(state, raw) {
524
+ const line = raw.trim();
525
+ if (line === '')
526
+ return;
527
+ let event;
528
+ try {
529
+ event = JSON.parse(line);
530
+ }
531
+ catch {
532
+ return;
533
+ }
534
+ if (event.type === 'thread.started' && typeof event.thread_id === 'string') {
535
+ state.threadId = event.thread_id;
536
+ if (typeof event.model === 'string' && event.model !== '')
537
+ state.model = event.model;
538
+ return;
539
+ }
540
+ if (event.type === 'turn.completed') {
541
+ state.completed = true;
542
+ if (event.usage !== undefined)
543
+ state.usage = usageFromCodex(event.usage);
544
+ if (typeof event.model === 'string' && event.model !== '')
545
+ state.model = event.model;
546
+ return;
547
+ }
548
+ if (event.type !== 'item.completed' || event.item === undefined)
549
+ return;
550
+ const item = event.item;
551
+ if (item.type === 'reasoning' && typeof item.text === 'string' && item.text.trim() !== '') {
552
+ state.lines.push({ kind: 'think', text: item.text });
553
+ }
554
+ else if (item.type === 'agent_message' && typeof item.text === 'string') {
555
+ state.lines.push({ kind: 'text', text: item.text });
556
+ state.text = item.text;
557
+ }
558
+ else if (item.type === 'command_execution') {
559
+ // Counted before the fold's own "did it carry anything to show" filter: a
560
+ // command with neither text nor output is still a call the CLI made.
561
+ countToolCall(state, 'command_execution');
562
+ const command = typeof item.command === 'string' ? item.command : undefined;
563
+ const output = typeof item.aggregated_output === 'string' && item.aggregated_output.trim() !== ''
564
+ ? item.aggregated_output
565
+ : undefined;
566
+ if (command !== undefined || output !== undefined) {
567
+ state.lines.push({
568
+ kind: 'tool',
569
+ id: typeof item.id === 'string' ? item.id : `codex-tool-${state.lines.length}`,
570
+ name: 'Bash',
571
+ ...command === undefined ? {} : { args: command },
572
+ ...output === undefined ? {} : { result: output },
573
+ });
574
+ }
575
+ }
576
+ else if (item.type === 'web_search_call') {
577
+ countToolCall(state, 'web_search_call');
578
+ state.lines.push({
579
+ kind: 'tool',
580
+ id: typeof item.id === 'string' ? item.id : `codex-tool-${state.lines.length}`,
581
+ name: 'WebSearch',
582
+ });
583
+ }
584
+ else if (item.type === 'file_change') {
585
+ // A patch application ({changes: [{path, kind}]}): without this case
586
+ // codex's file edits were invisible in the mirror.
587
+ countToolCall(state, 'file_change');
588
+ const args = (item.changes ?? [])
589
+ .map(change => `${change.kind ?? 'update'}: ${change.path ?? '?'}`)
590
+ .join('\n');
591
+ if (args !== '') {
592
+ state.lines.push({
593
+ kind: 'tool',
594
+ id: typeof item.id === 'string' ? item.id : `codex-tool-${state.lines.length}`,
595
+ name: 'ApplyPatch',
596
+ args,
597
+ });
598
+ }
599
+ }
600
+ else if (item.type === 'function_call') {
601
+ // Non-shell calls (e.g. the multi-agent wait): the result rides the
602
+ // function_call_output item, which attaches to this pending line.
603
+ countToolCall(state, 'function_call');
604
+ state.lines.push({
605
+ kind: 'tool',
606
+ id: typeof item.id === 'string' ? item.id : `codex-tool-${state.lines.length}`,
607
+ name: item.name ?? 'function',
608
+ ...typeof item.arguments === 'string' && item.arguments !== '' ? { args: item.arguments } : {},
609
+ });
610
+ }
611
+ else if (item.type === 'function_call_output') {
612
+ // A function/command result; attach to the previous tool line when one
613
+ // is pending (web search or command output).
614
+ const output = typeof item.output === 'string' ? item.output : undefined;
615
+ if (output !== undefined && output.trim() !== '') {
616
+ const last = state.lines[state.lines.length - 1];
617
+ if (last !== undefined && last.kind === 'tool') {
618
+ state.lines[state.lines.length - 1] = {
619
+ ...last,
620
+ result: last.result === undefined ? output : `${last.result}\n${output}`,
621
+ };
622
+ }
623
+ }
624
+ }
625
+ }
626
+ /**
627
+ * Incremental `codex exec --json` parser: feed stdout chunks as they arrive
628
+ * and the folded transcript accumulates line by line. The last line is
629
+ * volatile until the terminal `turn.completed` — a `function_call_output`
630
+ * merges into a pending tool line — so live consumers hold it back (see the
631
+ * run's live mirror).
632
+ */
633
+ export class CodexStreamParser {
634
+ buffer = '';
635
+ lines = [];
636
+ text;
637
+ usage;
638
+ threadId;
639
+ model;
640
+ completed = false;
641
+ toolCalls = new Map();
642
+ /** Fold every complete NDJSON line in the chunk; the tail stays buffered. */
643
+ push(chunk) {
644
+ this.buffer += chunk;
645
+ const parts = this.buffer.split('\n');
646
+ this.buffer = parts.pop() ?? '';
647
+ for (const raw of parts)
648
+ foldCodexStreamLine(this, raw);
649
+ }
650
+ }
651
+ /**
652
+ * Parse a `codex exec --json` NDJSON event stream into an ordered transcript
653
+ * (thinking, agent text, tool/command activity in event order), plus the final
654
+ * answer text, token usage, the thread id a later resume round continues, and
655
+ * the model identifier the stream named (usually absent — codex 0.144.0 does
656
+ * not put one on the wire; the settle path then reads the rollout's
657
+ * turn_context). Item types covered: `reasoning` (thinking), `agent_message`
658
+ * (reply text), `command_execution` (shell command with aggregated output),
659
+ * `web_search_call`, and `function_call_output`. The last `agent_message`
660
+ * wins as the run output (the final answer), and `turn.completed` carries the
661
+ * turn's usage. Malformed lines are skipped.
662
+ * @param stream - the collected stdout NDJSON text.
663
+ * @returns the ordered transcript, final answer text, usage, thread id, and
664
+ * any stream-named model.
665
+ */
666
+ export function parseCodexJsonStream(stream) {
667
+ const state = { lines: [], text: undefined, usage: undefined, threadId: undefined, model: undefined, completed: false, toolCalls: new Map() };
668
+ for (const raw of stream.split('\n'))
669
+ foldCodexStreamLine(state, raw);
670
+ const toolCalls = toolCallsOf(state.toolCalls);
671
+ return {
672
+ lines: state.lines,
673
+ ...state.text === undefined ? {} : { text: state.text },
674
+ ...state.usage === undefined ? {} : { usage: state.usage },
675
+ ...state.threadId === undefined ? {} : { threadId: state.threadId },
676
+ ...state.model === undefined ? {} : { model: state.model },
677
+ ...toolCalls === undefined ? {} : { toolCalls },
678
+ };
679
+ }
680
+ /**
681
+ * Start the real `codex exec` child and publish its run. The codex
682
+ * reply arrives as an NDJSON event stream on stdout (`--json`): the final
683
+ * `agent_message` item is the run output and the `turn.completed` usage is
684
+ * the token accounting. stderr is piped for diagnostics and never folded
685
+ * into the run output. A resume round spawns `codex exec --json resume
686
+ * <thread_id>` instead, continuing the same thread. The run id is the child
687
+ * session id for a session-backed run.
688
+ * @param request - resolved shared subagent request.
689
+ * @param spec - workspace, environment, process service, and diagnostic policy.
690
+ * @returns the published run after the child starts.
691
+ */
692
+ export function startCodexCliRun(request, spec) {
693
+ const task = textTask(request.prompt);
694
+ if (request.signal.aborted) {
695
+ throw new Error('subagent-codex: request was aborted before the CLI started');
696
+ }
697
+ const turn = spec.resume?.turn ?? 1;
698
+ // The member bridge rides a per-process `-c` override placed right after
699
+ // `exec` (the spike-verified position; `codex exec resume` accepts it too).
700
+ const memberArgv = spec.member === undefined ? [] : ['-c', spec.member.configOverride];
701
+ // `--skip-git-repo-check` rides every exec argv. Codex refuses to start
702
+ // outside a Git repository ("Not inside a trusted directory and
703
+ // --skip-git-repo-check was not specified") and exits before producing a
704
+ // single stream event, so the run fails with no diagnosable output. The
705
+ // delegation cwd is the CALLER's choice — a scratch directory, an eval
706
+ // cell, any path the `cwd` option names — and none of those are required
707
+ // to be repositories. The check is codex's own guard for interactive use
708
+ // in a stray directory; a delegation has already been directed at its
709
+ // workspace by the caller, so the guard can only reject work the caller
710
+ // asked for. Sandboxing stays with `--sandbox`, which this does not touch.
711
+ // The configured model rides `-m`, an option of `codex exec` itself, so the
712
+ // SAME position serves the fresh and the resume argv (the `resume`
713
+ // subcommand declares no `-m` of its own — verified against codex-cli
714
+ // 0.144.0). Nothing configured appends nothing: the argv below is then
715
+ // byte-for-byte the shape that shipped before the key existed.
716
+ const modelArgv = [
717
+ ...spec.model === undefined ? [] : ['-m', spec.model],
718
+ ...spec.effort === undefined ? [] : ['-c', `model_reasoning_effort=${JSON.stringify(spec.effort)}`],
719
+ ];
720
+ const argv = spec.resume === undefined
721
+ ? ['codex', 'exec', ...memberArgv, ...modelArgv, '--sandbox', spec.sandbox, '--skip-git-repo-check', '--json', task]
722
+ : ['codex', 'exec', ...memberArgv, ...modelArgv, '--sandbox', spec.sandbox, '--skip-git-repo-check', '--json', 'resume', spec.resume.cliSessionId, task];
723
+ // Container target: the same argv, wrapped in `docker exec`. The host cwd
724
+ // still applies — it is the docker CLIENT's working directory now, while
725
+ // the CLI's own is the target's in-container workdir.
726
+ const launch = spec.exec === undefined
727
+ ? { argv, env: spec.env }
728
+ : containerExecSpawn(spec.exec, { argv, env: spec.env }, 'subagent-codex');
729
+ const child = spec.spawn({
730
+ argv: launch.argv,
731
+ cwd: spec.cwd,
732
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
733
+ graceMs: spec.disposeGraceMs,
734
+ env: launch.env,
735
+ });
736
+ // The spawn moment anchors the rollout-locator time window: the run's
737
+ // rollout file starts around here (thread creation ≈ turn start ≈ spawn),
738
+ // so the usage fallback can find it even when a kill truncated the stream
739
+ // before `thread.started` ever reached stdout.
740
+ const startedAtMs = Date.now();
741
+ // The turn opens at the real spawn moment so the timing projection
742
+ // measures actual CLI runtime, not the post-hoc append time.
743
+ spec.childSession?.append('turn/start', { turn });
744
+ // Live mirror: fold the NDJSON stream as chunks arrive so the child session
745
+ // shows the run's progress before settle; the settle mirror below resumes
746
+ // from the live counters and stays a no-op when live mirroring kept up.
747
+ const liveMirror = spec.childSession !== undefined && spec.ctx !== undefined
748
+ ? createCodexLiveMirror(spec, task, turn)
749
+ : undefined;
750
+ let output = '';
751
+ child.stdout?.on('data', (chunk) => {
752
+ const text = chunk.toString();
753
+ output += text;
754
+ liveMirror?.push(text);
755
+ });
756
+ let stderr = '';
757
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
758
+ const disposeProcess = async () => {
759
+ // Host 0.1.5 hides the child pid (the managed range owns termination):
760
+ // terminate() is an idempotent no-op once the range is gone, so a failed
761
+ // spawn needs no guard; done rejects there, which dispose must swallow.
762
+ child.terminate();
763
+ await child.waitForExit().catch(() => false);
764
+ await child.done.catch(() => { });
765
+ };
766
+ const runAbort = new AbortController();
767
+ const requestCancel = () => {
768
+ if (runAbort.signal.aborted)
769
+ return;
770
+ runAbort.abort(new Error('subagent-codex: run cancelled locally'));
771
+ };
772
+ const onAbort = () => { requestCancel(); };
773
+ request.signal.addEventListener('abort', onAbort, { once: true });
774
+ // Abort branch: the moment the run is cancelled locally, the attempt settles
775
+ // immediately — settleRunResult observes `cancelled()` and resolves the
776
+ // result with 'aborted' WITHOUT waiting for the child to exit. The actual
777
+ // process kill is dispose's job (SIGTERM → grace → SIGKILL teardown ladder),
778
+ // per the subprocessRunHandle contract: requestCancel settles the result,
779
+ // teardown reaps the process. Without this branch the result would only
780
+ // settle after the child exits, and dispose (which runs after the result)
781
+ // would never fire — a dead wait on a long-running CLI.
782
+ const abortBranch = new Promise((_, reject) => {
783
+ runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-codex: run cancelled locally')), { once: true });
784
+ });
785
+ const processFailure = child.done.then(outcome => Promise.reject(new Error('subagent-codex: CLI exited before the run settled '
786
+ + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
787
+ // A normal post-result dispose also closes the process; keep the expected
788
+ // late rejection observed after the result race has already settled.
789
+ processFailure.catch(() => { });
790
+ const collectOutput = () => {
791
+ // Parse fresh at call time: stdout 'data' events may still be flushing
792
+ // when the settle callback computes its first output, and the consumer
793
+ // may poll output again later. Post-exit the seam's collected buffer is
794
+ // authoritative — a fast-exiting process can settle `done` before the
795
+ // streamed data events land.
796
+ const drained = child.collected.stdout?.readFrom(0).text;
797
+ const text = parseCodexJsonStream(drained !== undefined && drained !== '' ? drained : output).text?.trim();
798
+ return text === undefined || text === '' ? [] : [{ type: 'text', text }];
799
+ };
800
+ // Captured from the child's exit so the error turn/end can name the real
801
+ // code without the settle chain re-deriving it.
802
+ let exitCode = null;
803
+ /** The flattened failure's own message, snapshotted for the diagnostic. */
804
+ let settleFailure;
805
+ const result = settleRunResult({
806
+ attempt: () => Promise.race([
807
+ child.done.then((outcome) => {
808
+ if (outcome.exitCode !== 0) {
809
+ const via = spec.endpointLabel ?? 'codex default endpoint';
810
+ exitCode = outcome.exitCode;
811
+ throw new Error(`subagent-codex: codex exec exited with code ${String(outcome.exitCode)} via ${via}`);
812
+ }
813
+ // A zero exit with no parsed answer is a silent failure, not a
814
+ // success: the NDJSON stream yielded no agent_message (internal
815
+ // error, truncated stdout, or a schema drift — the exec --json event
816
+ // field names are not contract-backed). Throwing here settles 'error'
817
+ // through the seam instead of reporting an empty 'completed' (which
818
+ // the upstream settleRunResult does not re-check). The abort path is
819
+ // untouched: it settles 'aborted' through abortBranch before this
820
+ // branch is ever reached.
821
+ const parsed = parseCodexJsonStream(output);
822
+ if (parsed.lines.length === 0) {
823
+ // Format-drift early warning: a normally-exited stream with no
824
+ // item.completed at all means the parser and the CLI disagreed on
825
+ // the event shape. Warn once per run (not per line) without
826
+ // changing the settle semantics — the empty output above still
827
+ // reports 'error' either way.
828
+ spec.onError?.(new Error('subagent-codex: exited 0 but parsed no codex events — check the codex --json event schema'), 'error');
829
+ throw new Error('subagent-codex: codex exec exited 0 but produced no answer');
830
+ }
831
+ const answer = collectOutput();
832
+ if (answer.length === 0) {
833
+ throw new Error('subagent-codex: codex exec exited 0 but produced no answer');
834
+ }
835
+ return { output: answer, stopReason: 'completed' };
836
+ }),
837
+ processFailure,
838
+ abortBranch,
839
+ ]),
840
+ collectOutput,
841
+ // The seam surfaces this as `SubagentResult.diagnostic` (bounded): the
842
+ // failure's own message, the stderr tail, and where the run's rollout
843
+ // file lands — "subagent run failed" alone sent every caller hunting.
844
+ collectDiagnostic: () => {
845
+ const detail = [];
846
+ if (settleFailure !== undefined)
847
+ detail.push(settleFailure);
848
+ const stderrTail = (child.collected.stderr?.readFrom(0).text || stderr).trim();
849
+ if (stderrTail !== '')
850
+ detail.push(`stderr: ${stderrTail.slice(-500)}`);
851
+ const homeDir = spec.env['CODEX_HOME'];
852
+ if (homeDir !== undefined && homeDir !== '') {
853
+ const threadId = parseCodexJsonStream(output).threadId;
854
+ const when = new Date(startedAtMs);
855
+ const dir = `${homeDir}/sessions/${String(when.getFullYear())}/${String(when.getMonth() + 1).padStart(2, '0')}/${String(when.getDate()).padStart(2, '0')}`;
856
+ detail.push(threadId === undefined ? `rollout 目录: ${dir}` : `rollout: ${dir}/rollout-*-${threadId}.jsonl`);
857
+ }
858
+ return detail.length === 0 ? undefined : detail.join('\n');
859
+ },
860
+ cancelled: () => runAbort.signal.aborted,
861
+ onError: (error, stopReason) => {
862
+ settleFailure = error instanceof Error ? error.message : String(error);
863
+ spec.onError?.(error instanceof Error ? error : new Error(String(error)), stopReason);
864
+ },
865
+ signal: request.signal,
866
+ onAbort,
867
+ }).then((settled) => {
868
+ // Every terminal path closes the turn so the timing window never stays
869
+ // open on a failed or cancelled run: success settles 'completed', a
870
+ // non-zero exit 'error', and a locally cancelled run 'aborted'. The
871
+ // turn/end timestamp is the real settle moment.
872
+ // A completed round proves the scoped credential is live — the endpoint
873
+ // answered. Reported here rather than from the mirror so it lands whether
874
+ // or not the round is session-backed.
875
+ if (settled.stopReason === 'completed')
876
+ spec.onAuthSuccess?.();
877
+ if (spec.childSession !== undefined) {
878
+ if (settled.stopReason === 'completed') {
879
+ spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
880
+ }
881
+ else if (settled.stopReason === 'aborted') {
882
+ spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
883
+ }
884
+ else {
885
+ spec.childSession.append('turn/end', {
886
+ turn,
887
+ reason: { kind: 'error', error: { message: `codex exec exited with code ${String(exitCode)}`, code: 'UNKNOWN' } },
888
+ });
889
+ }
890
+ }
891
+ return settled;
892
+ });
893
+ // After the child EXITS — however it ended (completed, killed by dispose, or
894
+ // crashed) — mirror whatever the NDJSON stream already produced into the dsh
895
+ // subagent session, so a cancelled round still preserves its partial work
896
+ // (reasoning, commands, replies) and real token usage. The thread id is
897
+ // recorded even on abort so the partial thread stays resumable. Waits for
898
+ // the settle chain first (so turn/end is already appended) AND for the
899
+ // process to actually exit (so stdout is drained before parsing).
900
+ void result.then(() => child.done).then(() => {
901
+ if (spec.onAuthFailure !== undefined) {
902
+ // The seam's collected buffers are complete at process exit; the
903
+ // streamed variables can lag `done` by a tick.
904
+ const drainedStderr = child.collected.stderr?.readFrom(0).text || stderr;
905
+ const drainedStdout = child.collected.stdout?.readFrom(0).text || output;
906
+ const authDetail = `${drainedStderr}\n${drainedStdout}`;
907
+ if (CODEX_AUTH_FAILURE.test(authDetail)) {
908
+ spec.onAuthFailure(authDetail.split('\n').find(line => line.trim() !== '') ?? 'auth failure');
909
+ }
910
+ }
911
+ return mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
912
+ }, () => { });
913
+ return Promise.resolve(subprocessRunHandle({
914
+ // A session-backed run's id is the child session id (the seam's local-run
915
+ // contract), so the tool's resume self-description names the same handle
916
+ // the delegation registry records.
917
+ id: spec.childSession?.id ?? SessionId(randomUUID()),
918
+ result,
919
+ signal: request.signal,
920
+ onAbort,
921
+ requestCancel,
922
+ teardown: disposeProcess,
923
+ }));
924
+ }
925
+ /** One assistant-role message event, attributed to the codex route. */
926
+ export function codexAssistantEvent(blocks) {
927
+ return createAssistantMessage({
928
+ content: blocks,
929
+ source: { provider: 'codex-local', model: 'codex' },
930
+ });
931
+ }
932
+ /**
933
+ * Fold one transcript line into the child session as one assistant step,
934
+ * wrapped in the step/start–step/end boundary pair the live conversation
935
+ * assembler requires: without a boundary the step never enters the location
936
+ * index, the line's assistant message resolves to a turn-level location, and
937
+ * the real-time view renders nothing (only a full rebuild, which mints step
938
+ * drafts from the explicit coordinates, recovers it). Tool cards escape that
939
+ * fate only because their definition does not consult the step location.
940
+ * @returns whether the line folded (false when the options skipped it).
941
+ */
942
+ export function appendCodexTranscriptLine(childSession, turn, step, line, usage) {
943
+ childSession.append('step/start', { turn, step });
944
+ if (line.kind === 'tool') {
945
+ // Native tool card: the call event now, the result event when the stream
946
+ // already carries it. Tool lines never carry the round's usage — the
947
+ // callers attach usage to the round's final line, which is an agent
948
+ // message on every well-formed stream.
949
+ const call = childSession.append('tool/call', {
950
+ turn,
951
+ step,
952
+ callId: line.id,
953
+ name: line.name,
954
+ arguments: line.args ?? '',
955
+ });
956
+ if (line.result !== undefined) {
957
+ childSession.append('tool/result', {
958
+ turn,
959
+ step,
960
+ message: createToolResultMessage({
961
+ callId: line.id,
962
+ content: [{ type: 'text', text: line.result }],
963
+ isError: false,
964
+ }),
965
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
966
+ }
967
+ childSession.append('step/end', { turn, step });
968
+ return true;
969
+ }
970
+ const blocks = line.kind === 'think'
971
+ ? [{ type: 'reasoning', text: line.text }]
972
+ : [{ type: 'text', text: line.text }];
973
+ childSession.append('assistant/message', {
974
+ turn,
975
+ step,
976
+ message: codexAssistantEvent(blocks),
977
+ stream: [],
978
+ ...usage === undefined ? {} : { usage },
979
+ }, { surfaceOp: 'append' });
980
+ childSession.append('step/end', { turn, step });
981
+ return true;
982
+ }
983
+ /** Fold one transcript line into the run's child session as one assistant step. */
984
+ function appendCodexLine(spec, turn, step, line, usage) {
985
+ if (spec.childSession === undefined)
986
+ return;
987
+ appendCodexTranscriptLine(spec.childSession, turn, step, line, usage);
988
+ }
989
+ /** The delta-progress text for one transcript line. */
990
+ export function codexLineText(line) {
991
+ return line.kind === 'tool'
992
+ ? `[工具 ${line.name}]${line.args !== undefined ? ` ${line.args}` : ''}${line.result !== undefined ? `\n${line.result}` : ''}`
993
+ : line.text;
994
+ }
995
+ /**
996
+ * Persist the child session's mirrored events. Thin wrapper over the core's
997
+ * {@link persistChildSession}: a live session syncs through the core's cached
998
+ * per-child write handle (host 0.1.5's live write-behind never checkpoints a
999
+ * mirror session — it has no agent loop — so production child logs stayed
1000
+ * header-only until the explicit sync), a standalone one through a one-shot
1001
+ * handle flow. Kept under the historical name for the live driver.
1002
+ */
1003
+ export async function persistIfStandalone(ctx, childSession) {
1004
+ await persistChildSession(ctx, childSession);
1005
+ }
1006
+ /** Create the per-run live mirror over the incremental parser. */
1007
+ function createCodexLiveMirror(spec, task, turn) {
1008
+ const parser = new CodexStreamParser();
1009
+ const childSession = spec.childSession;
1010
+ const ctx = spec.ctx;
1011
+ let mirrored = 0;
1012
+ let userMirrored = false;
1013
+ let queue = Promise.resolve();
1014
+ const mirror = {
1015
+ get mirroredLines() { return mirrored; },
1016
+ get userMirrored() { return userMirrored; },
1017
+ enqueue(task) {
1018
+ queue = queue.then(task);
1019
+ return queue;
1020
+ },
1021
+ push(chunk) {
1022
+ parser.push(chunk);
1023
+ const upto = parser.completed ? parser.lines.length : parser.lines.length - 1;
1024
+ if (upto <= mirrored)
1025
+ return;
1026
+ void mirror.enqueue(async () => {
1027
+ try {
1028
+ const localAgent = ctx.get('localAgent');
1029
+ if (!userMirrored) {
1030
+ userMirrored = true;
1031
+ childSession.append('user/message', createUserMessage({
1032
+ content: [{ type: 'text', text: task }],
1033
+ source: { kind: 'user' },
1034
+ }), { surfaceOp: 'append' });
1035
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
1036
+ }
1037
+ // The usage rides the last NON-tool line (tool events carry no
1038
+ // usage slot); a stream ending on a tool line would otherwise drop
1039
+ // the round's accounting. When that carrier was mirrored in an
1040
+ // earlier flush (before the usage was knowable), the accounting is
1041
+ // lost — host 0.1.5 has no usage-backfill event.
1042
+ let carrier = -1;
1043
+ if (parser.completed && parser.usage !== undefined) {
1044
+ for (let scan = 0; scan < parser.lines.length; scan += 1) {
1045
+ if (parser.lines[scan]?.kind !== 'tool')
1046
+ carrier = scan;
1047
+ }
1048
+ }
1049
+ for (let index = mirrored; index < upto; index += 1) {
1050
+ const line = parser.lines[index];
1051
+ if (line === undefined)
1052
+ continue;
1053
+ const usage = index === carrier ? parser.usage : undefined;
1054
+ appendCodexLine(spec, turn, index + 1, line, usage);
1055
+ mirrored = index + 1;
1056
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: codexLineText(line) });
1057
+ }
1058
+ await persistChildSession(ctx, childSession);
1059
+ localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: mirrored });
1060
+ }
1061
+ catch (error) {
1062
+ ctx.logger.warn(`subagent-codex: live mirror failed: ${thrown(error).message}`);
1063
+ }
1064
+ });
1065
+ },
1066
+ };
1067
+ return mirror;
1068
+ }
1069
+ /**
1070
+ * Mirror the delegation's user prompt and the codex event transcript into the
1071
+ * dsh subagent session, then persist. Runs detached from the settle race — the
1072
+ * run result settles with the child exit, and a failure here is
1073
+ * diagnostic-only (the subagent record degrades to a final-text-only view).
1074
+ * The transcript's reasoning folds to `reasoning` blocks, agent text and tool
1075
+ * activity to text blocks (tool lines render `[工具 Bash] <command> → output`),
1076
+ * and the final assistant message carries the round's usage when codex
1077
+ * reported it, so the tokenUsage projection counts the delegation.
1078
+ * @param spec - the run spec carrying the child session and host context.
1079
+ * @param task - the one-shot task text (the user prompt).
1080
+ * @param turn - the round's turn number (1 for a fresh round, incremented on resume).
1081
+ * @param parsed - the parsed NDJSON transcript, final output, and usage.
1082
+ * @param fromLines - transcript lines the live mirror already appended.
1083
+ * @param userMirrored - whether the live mirror already appended the prompt.
1084
+ */
1085
+ async function appendCodexResponse(spec, task, turn, parsed, fromLines = 0, userMirrored = false) {
1086
+ if (spec.childSession === undefined || spec.ctx === undefined)
1087
+ return;
1088
+ const childSession = spec.childSession;
1089
+ const localAgent = spec.ctx.get('localAgent');
1090
+ if (!userMirrored) {
1091
+ childSession.append('user/message', createUserMessage({
1092
+ content: [{ type: 'text', text: task }],
1093
+ source: { kind: 'user' },
1094
+ }), { surfaceOp: 'append' });
1095
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
1096
+ }
1097
+ // The round's usage rides the last NON-tool line: tool activity folds to
1098
+ // `tool/call`/`tool/result` events (which carry no usage slot), and a kill
1099
+ // mid-command ends the transcript with a tool line — exactly the case the
1100
+ // rollout-usage recovery exists for.
1101
+ let usageIndex = -1;
1102
+ for (let index = 0; index < parsed.lines.length; index += 1) {
1103
+ if (parsed.lines[index]?.kind !== 'tool')
1104
+ usageIndex = index;
1105
+ }
1106
+ let step = fromLines + 1;
1107
+ for (const line of parsed.lines.slice(fromLines)) {
1108
+ appendCodexLine(spec, turn, step, line, step - 1 === usageIndex ? parsed.usage : undefined);
1109
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: codexLineText(line) });
1110
+ step += 1;
1111
+ }
1112
+ if (parsed.usage !== undefined && usageIndex !== -1 && usageIndex < fromLines) {
1113
+ // The carrier line went out through the live mirror before the usage was
1114
+ // knowable (a killed run recovers it from the rollout at settle). Host
1115
+ // 0.1.5 has no usage-backfill event (the per-chunk event is gone and
1116
+ // appended messages are immutable), so the round's accounting is lost in
1117
+ // this race — log it instead of dropping it silently.
1118
+ spec.ctx?.logger.warn(`subagent-codex: round ${turn} usage arrived after its carrier line was mirrored; the accounting is dropped`);
1119
+ }
1120
+ await persistChildSession(spec.ctx, childSession);
1121
+ localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: parsed.lines.length });
1122
+ }
1123
+ /**
1124
+ * Mirror the codex NDJSON stream into the child session AFTER the CLI process
1125
+ * has exited, whatever its stop reason. A cancelled or failed round still
1126
+ * preserves the events the stream already emitted (reasoning, commands,
1127
+ * replies, usage) instead of leaving the child blank — the run result settles
1128
+ * 'aborted'/'error' at the cancel moment, but codex may have produced content
1129
+ * before the kill landed. Also records the thread id (fresh rounds) so a
1130
+ * later resume can continue the partial thread.
1131
+ *
1132
+ * **Usage recovery**: a non-completed terminal state never emits
1133
+ * `turn.completed`, so the parsed stream carries no usage even though codex
1134
+ * wrote the run's real token spend to its rollout file. When the stream's
1135
+ * usage is absent, the mirror falls back to the run's rollout file — located
1136
+ * by the thread id (the `session_meta` head id) or by the spawn-time window —
1137
+ * and attaches the file's LAST `token_count` entry (same
1138
+ * `input − cached` caliber as `turn.completed`, via the shared
1139
+ * {@link usageFromCodex}), so a killed or failed run still books its tokens.
1140
+ * The fallback is best-effort and silent: no rollout file, an unreadable
1141
+ * home, or a hard kill that wrote no token_count leaves the child without
1142
+ * usage, exactly as before.
1143
+ *
1144
+ * **Model observation**: the exec --json wire carries no model field (codex
1145
+ * 0.144.0), so the run's model identifier is read from the located rollout
1146
+ * file's LAST `turn_context` line whose timestamp falls inside this run's
1147
+ * window — the turn codex actually started for this round. When the stream
1148
+ * itself names a model (a future CLI), the stream wins. Absent on any miss;
1149
+ * the observation rides {@link CodexCliRunSpec.onRoundSettled} along with the
1150
+ * round's usage.
1151
+ * @param spec - the run spec carrying the child session and host context.
1152
+ * @param task - the one-shot task text (the user prompt).
1153
+ * @param turn - the round's turn number.
1154
+ * @param output - the collected NDJSON stdout.
1155
+ * @param startedAtMs - the spawn moment, anchoring the rollout time window.
1156
+ */
1157
+ async function mirrorCodexAfterExit(spec, task, turn, output, live, startedAtMs) {
1158
+ if (spec.childSession === undefined || spec.ctx === undefined)
1159
+ return;
1160
+ const work = async () => {
1161
+ const parsed = parseCodexJsonStream(output);
1162
+ if (spec.resume === undefined)
1163
+ spec.onThreadId?.(parsed.threadId);
1164
+ const fromLines = live?.mirroredLines ?? 0;
1165
+ const userMirrored = live?.userMirrored ?? false;
1166
+ // Non-completed terminal states (aborted/error) never emit turn.completed,
1167
+ // so parsed.usage is absent; the exec wire never names a model at all
1168
+ // (codex 0.144.0). Both come back from the run's own rollout file —
1169
+ // located by thread id, and under concurrency disambiguated by the run's
1170
+ // cwd, since several cells' files share one time window. The head of the
1171
+ // same file names the codex build that served the round, which is a
1172
+ // stronger answer than a later `--version` probe of the executable: it is
1173
+ // what actually ran.
1174
+ let usage = parsed.usage;
1175
+ let observedModel = parsed.model;
1176
+ let cliVersion;
1177
+ const homeDir = spec.env['CODEX_HOME'];
1178
+ if (homeDir !== undefined && homeDir !== '') {
1179
+ const facts = await codexRolloutRoundFacts(homeDir, {
1180
+ threadId: parsed.threadId,
1181
+ windowStart: startedAtMs,
1182
+ cwd: spec.cwd,
1183
+ });
1184
+ usage ??= facts.usage;
1185
+ observedModel ??= facts.model;
1186
+ cliVersion = facts.cliVersion;
1187
+ }
1188
+ // A rollout that named no build still reports one: the probe of the
1189
+ // executable this round spawned. Absent only when neither channel answers.
1190
+ cliVersion ??= await spec.cliVersion?.();
1191
+ // The round's settled observation rides out even when nothing streamed:
1192
+ // an empty stream is still a settled round, and the rollout may already
1193
+ // name the model and the spend.
1194
+ spec.onRoundSettled?.({
1195
+ ...observedModel === undefined ? {} : { observedModel },
1196
+ ...cliVersion === undefined ? {} : { cliVersion },
1197
+ ...usage === undefined ? {} : { usage },
1198
+ ...parsed.toolCalls === undefined ? {} : { toolCalls: parsed.toolCalls },
1199
+ });
1200
+ // Nothing streamed at all (e.g. the CLI died before the first item): keep
1201
+ // the pre-live-mirror behavior of recording nothing.
1202
+ if (parsed.lines.length === 0 && !userMirrored)
1203
+ return;
1204
+ await appendCodexResponse(spec, task, turn, {
1205
+ lines: parsed.lines,
1206
+ output: collectOutputBlocks(parsed.text),
1207
+ ...usage === undefined ? {} : { usage },
1208
+ }, fromLines, userMirrored);
1209
+ };
1210
+ try {
1211
+ // Behind the live mirror's queue so a flush in flight cannot interleave.
1212
+ if (live === undefined)
1213
+ await work();
1214
+ else
1215
+ await live.enqueue(work);
1216
+ }
1217
+ catch (error) {
1218
+ spec.onError?.(thrown(error), 'error');
1219
+ }
1220
+ }
1221
+ /** Build the run-output blocks from the final codex answer text. */
1222
+ function collectOutputBlocks(text) {
1223
+ const trimmed = text?.trim();
1224
+ return trimmed === undefined || trimmed === '' ? [] : [{ type: 'text', text: trimmed }];
1225
+ }