@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.
- package/README.en.md +136 -0
- package/README.i18n.yaml +6 -0
- package/README.md +136 -0
- package/cordis.patch.yml +42 -0
- package/lib/client.js +1066 -0
- package/lib/index.js +3550 -0
- package/lib/invariant.js +31 -0
- package/lib/types/client/SettingsCard.d.ts +76 -0
- package/lib/types/client/SettingsCard.js +200 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +43 -0
- package/lib/types/client/index.js +88 -0
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/locales.js +55 -0
- package/lib/types/codex-cli-provider.d.ts +347 -0
- package/lib/types/codex-cli-provider.js +1225 -0
- package/lib/types/index.d.ts +70 -0
- package/lib/types/index.js +272 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +33 -0
- package/lib/types/live-driver.d.ts +229 -0
- package/lib/types/live-driver.js +1279 -0
- package/lib/types/live-switch.d.ts +70 -0
- package/lib/types/live-switch.js +90 -0
- package/lib/types/model-broker.d.ts +102 -0
- package/lib/types/model-broker.js +187 -0
- package/lib/types/model-catalog.d.ts +44 -0
- package/lib/types/model-catalog.js +220 -0
- package/lib/types/provision.d.ts +80 -0
- package/lib/types/provision.js +213 -0
- package/lib/types/records.d.ts +120 -0
- package/lib/types/records.js +505 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +106 -0
|
@@ -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
|
+
}
|