@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,3550 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, modelDirectoryContextKey, persistChildSession, probeCliVersion, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol } from "@khorsheed/dsh-local-agent";
|
|
3
|
+
import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV, endpointHost } from "@khorsheed/dsh-local-agent/types";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
6
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
7
|
+
import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
|
|
8
|
+
import { StringDecoder } from "node:string_decoder";
|
|
9
|
+
import { open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
//#region lib/types/records.js
|
|
12
|
+
/**
|
|
13
|
+
* Codex session records adapter: scans the scoped home's `sessions/`
|
|
14
|
+
* rollout files (one JSONL per session under `sessions/YYYY/MM/DD/`).
|
|
15
|
+
* The LISTING reads only the head of each file — the first `session_meta`
|
|
16
|
+
* line carries the id, cwd, and started-at timestamp it needs. The READ-BACK
|
|
17
|
+
* path ({@link codexRolloutRoundFacts}) instead locates one run's file (by
|
|
18
|
+
* thread id, working directory, or a time window) and reads that round's
|
|
19
|
+
* usage, model, and codex build out of it — the facts the exec `--json` wire
|
|
20
|
+
* either omits entirely (the model) or drops on a non-completed round (the
|
|
21
|
+
* usage). Files are treated as append-only with torn-tail tolerance, and
|
|
22
|
+
* contents are always untrusted input.
|
|
23
|
+
* @module @khorsheed/dsh-local-agent-codex/records
|
|
24
|
+
*/
|
|
25
|
+
/** Rollout files live under this directory inside the scoped home. */
|
|
26
|
+
const SESSIONS_ROOT = "sessions";
|
|
27
|
+
/** Bound on the head prefix read from one rollout file. */
|
|
28
|
+
const HEAD_BYTES = 65536;
|
|
29
|
+
/** Bound on the tail read from one rollout file for the usage scan. */
|
|
30
|
+
const TAIL_BYTES = 65536;
|
|
31
|
+
/** Bound on the full-file fallback read when the tail scan finds no token_count. */
|
|
32
|
+
const FULL_BYTES = 8388608;
|
|
33
|
+
/**
|
|
34
|
+
* How far back a candidate file's start may precede the run's start and
|
|
35
|
+
* still count as "this run" (clock skew + pre-spawn session creation slack).
|
|
36
|
+
*/
|
|
37
|
+
const ROLLOUT_WINDOW_SLACK_MS = 3e5;
|
|
38
|
+
/** Extra future slack for the window end (file timestamps may skew ahead). */
|
|
39
|
+
const ROLLOUT_WINDOW_FUTURE_MS = 6e4;
|
|
40
|
+
/**
|
|
41
|
+
* Whether the scoped home holds usable Codex credentials: the `auth.json`
|
|
42
|
+
* file exists. Absent means the device-code flow has not completed.
|
|
43
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
44
|
+
* @returns true when credentials are present.
|
|
45
|
+
*/
|
|
46
|
+
async function codexAuthenticated(homeDir) {
|
|
47
|
+
try {
|
|
48
|
+
await readFile(join(homeDir, "auth.json"));
|
|
49
|
+
return true;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code === "ENOENT") return false;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse the first line of a rollout file for the listing fields. Unknown or
|
|
57
|
+
* malformed values yield an undefined record rather than failing the list.
|
|
58
|
+
* @param text - the raw rollout file content (only the head is read).
|
|
59
|
+
* @returns the record, or undefined when the head is not a session_meta line.
|
|
60
|
+
*/
|
|
61
|
+
function rolloutRecord(text) {
|
|
62
|
+
const line = text.split("\n")[0];
|
|
63
|
+
if (line === void 0) return void 0;
|
|
64
|
+
let event;
|
|
65
|
+
try {
|
|
66
|
+
event = JSON.parse(line);
|
|
67
|
+
} catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (event.type !== "session_meta" || typeof event.payload !== "object" || event.payload === null) return;
|
|
71
|
+
const meta = event.payload;
|
|
72
|
+
const sessionId = typeof meta.id === "string" && meta.id !== "" ? meta.id : typeof meta.session_id === "string" && meta.session_id !== "" ? meta.session_id : void 0;
|
|
73
|
+
if (sessionId === void 0) return void 0;
|
|
74
|
+
if (typeof meta.cwd !== "string") return void 0;
|
|
75
|
+
const record = {
|
|
76
|
+
id: sessionId,
|
|
77
|
+
workDir: meta.cwd
|
|
78
|
+
};
|
|
79
|
+
const startedAt = typeof meta.timestamp === "string" ? Date.parse(meta.timestamp) : void 0;
|
|
80
|
+
if (startedAt !== void 0 && Number.isFinite(startedAt)) record.startedAt = startedAt;
|
|
81
|
+
return record;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read the scoped home's rollout files. The listing walks
|
|
85
|
+
* `sessions/YYYY/MM/DD/` and reads each `rollout-*.jsonl` head; a torn or
|
|
86
|
+
* malformed file is skipped rather than failing the whole list.
|
|
87
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
88
|
+
* @returns the parsed records, empty when no sessions exist yet.
|
|
89
|
+
*/
|
|
90
|
+
async function listCodexSessions(homeDir) {
|
|
91
|
+
const records = [];
|
|
92
|
+
let years;
|
|
93
|
+
try {
|
|
94
|
+
years = await readdir(join(homeDir, SESSIONS_ROOT));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error.code === "ENOENT") return [];
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
for (const year of years) {
|
|
100
|
+
if (!/^\d{4}$/.test(year)) continue;
|
|
101
|
+
let months;
|
|
102
|
+
try {
|
|
103
|
+
months = await readdir(join(homeDir, SESSIONS_ROOT, year));
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const month of months) {
|
|
108
|
+
if (!/^\d{2}$/.test(month)) continue;
|
|
109
|
+
let days;
|
|
110
|
+
try {
|
|
111
|
+
days = await readdir(join(homeDir, SESSIONS_ROOT, year, month));
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
for (const day of days) {
|
|
116
|
+
if (!/^\d{2}$/.test(day)) continue;
|
|
117
|
+
let files;
|
|
118
|
+
try {
|
|
119
|
+
files = await readdir(join(homeDir, SESSIONS_ROOT, year, month, day));
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (const name of files) {
|
|
124
|
+
if (!name.startsWith("rollout-") || !name.endsWith(".jsonl")) continue;
|
|
125
|
+
try {
|
|
126
|
+
const handle = await open(join(homeDir, SESSIONS_ROOT, year, month, day, name), "r");
|
|
127
|
+
let text;
|
|
128
|
+
try {
|
|
129
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
130
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
131
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
132
|
+
} finally {
|
|
133
|
+
await handle.close();
|
|
134
|
+
}
|
|
135
|
+
const record = rolloutRecord(text);
|
|
136
|
+
if (record !== void 0) records.push(record);
|
|
137
|
+
} catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return records;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Map codex's token-count payload onto the shared usage contract — the
|
|
148
|
+
* SINGLE caliber for both the exec `turn.completed` usage and the rollout
|
|
149
|
+
* `token_count` recovery, so the two sources can never drift. Codex's
|
|
150
|
+
* `input_tokens` is the TOTAL input including cache hits (OpenAI-style
|
|
151
|
+
* accounting, confirmed against `total_tokens` in the rollout token_count),
|
|
152
|
+
* and `cached_input_tokens` is the cache-read subset — so the uncached
|
|
153
|
+
* bucket subtracts the cached portion to avoid double counting. There is no
|
|
154
|
+
* cache-write concept, so that bucket is omitted (the shared four-bucket
|
|
155
|
+
* contract: uncached input, output, cache read, cache write — codex fills
|
|
156
|
+
* the first three).
|
|
157
|
+
* @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
|
|
158
|
+
* rollout `token_count` `info.last_token_usage`/`total_token_usage`).
|
|
159
|
+
* @returns the shared usage record.
|
|
160
|
+
*/
|
|
161
|
+
function usageFromCodex(usage) {
|
|
162
|
+
const raw = usage;
|
|
163
|
+
const input = Number(raw.input_tokens);
|
|
164
|
+
const cached = Number(raw.cached_input_tokens);
|
|
165
|
+
const output = Number(raw.output_tokens);
|
|
166
|
+
const usageRecord = {
|
|
167
|
+
inputTokens: Number.isFinite(input) && Number.isFinite(cached) ? Math.max(0, input - cached) : Number.isFinite(input) ? input : 0,
|
|
168
|
+
outputTokens: Number.isFinite(output) ? output : 0
|
|
169
|
+
};
|
|
170
|
+
if (Number.isFinite(cached) && cached > 0) usageRecord.cacheReadTokens = cached;
|
|
171
|
+
return usageRecord;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Scan rollout-file text for the LAST `token_count` event and map its usage
|
|
175
|
+
* onto the shared contract. A token_count entry rides an
|
|
176
|
+
* `{"type":"event_msg","payload":{"type":"token_count","info":{…}}}` line
|
|
177
|
+
* (verified against codex-cli 0.144.0 rollout files); the per-turn
|
|
178
|
+
* `info.last_token_usage` is the same caliber as `turn.completed`'s usage,
|
|
179
|
+
* with the session-wide `total_token_usage` as fallback. Torn or malformed
|
|
180
|
+
* lines are skipped; an absent event yields undefined.
|
|
181
|
+
* @param text - raw rollout file content (the tail scan reads a bounded suffix).
|
|
182
|
+
* @returns the mapped usage of the last token_count event, or undefined.
|
|
183
|
+
*/
|
|
184
|
+
function codexRolloutTokenUsage(text) {
|
|
185
|
+
let info;
|
|
186
|
+
for (const raw of text.split("\n")) {
|
|
187
|
+
const line = raw.trim();
|
|
188
|
+
if (line === "") continue;
|
|
189
|
+
let event;
|
|
190
|
+
try {
|
|
191
|
+
event = JSON.parse(line);
|
|
192
|
+
} catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (event.type !== "event_msg" || typeof event.payload !== "object" || event.payload === null) continue;
|
|
196
|
+
const payload = event.payload;
|
|
197
|
+
if (payload.type !== "token_count") continue;
|
|
198
|
+
if (typeof payload.info === "object" && payload.info !== null) info = payload.info;
|
|
199
|
+
}
|
|
200
|
+
if (info === void 0) return void 0;
|
|
201
|
+
const entry = info;
|
|
202
|
+
const usage = entry.last_token_usage ?? entry.total_token_usage;
|
|
203
|
+
return usage === void 0 ? void 0 : usageFromCodex(usage);
|
|
204
|
+
}
|
|
205
|
+
/** Parse the ISO timestamp embedded in a `rollout-<ISO>-<uuid>.jsonl` name. */
|
|
206
|
+
function rolloutFilenameTimestamp(name) {
|
|
207
|
+
const match = /^rollout-(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})/.exec(name);
|
|
208
|
+
if (match === null) return void 0;
|
|
209
|
+
const parsed = Date.parse(`${match[1]}:${match[2]}:${match[3]}`);
|
|
210
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The codex build that wrote one rollout file, from its `session_meta` head
|
|
214
|
+
* (`payload.cli_version`, present since codex 0.14x). This is the AUTHORITY
|
|
215
|
+
* for the version a delegation round actually ran: it is codex's own record of
|
|
216
|
+
* itself, written by the process that served the round, so it cannot drift
|
|
217
|
+
* from the binary the way a later `--version` probe can.
|
|
218
|
+
* @param text - the raw rollout head.
|
|
219
|
+
* @returns the version, or undefined when the head names none.
|
|
220
|
+
*/
|
|
221
|
+
function rolloutCliVersion(text) {
|
|
222
|
+
const line = text.split("\n")[0];
|
|
223
|
+
if (line === void 0) return void 0;
|
|
224
|
+
let event;
|
|
225
|
+
try {
|
|
226
|
+
event = JSON.parse(line);
|
|
227
|
+
} catch {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (event.type !== "session_meta" || typeof event.payload !== "object" || event.payload === null) return;
|
|
231
|
+
const version = event.payload.cli_version;
|
|
232
|
+
return typeof version === "string" && version !== "" ? version : void 0;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* How far before the run's spawn moment a `turn_context` timestamp may sit and
|
|
236
|
+
* still count as THIS run's turn (sub-second ordering between the host's
|
|
237
|
+
* spawn clock and the CLI's own stamps).
|
|
238
|
+
*/
|
|
239
|
+
const ROLLOUT_CONTEXT_SLACK_MS = 2e3;
|
|
240
|
+
/**
|
|
241
|
+
* Locate ONE run's rollout file. Walks the scoped home's `sessions/YYYY/MM/DD/`
|
|
242
|
+
* tree reading each file's head (the same bounded walk as
|
|
243
|
+
* {@link listCodexSessions}), then prefers the file whose session id equals
|
|
244
|
+
* `locator.threadId`; otherwise the file whose start falls in the window
|
|
245
|
+
* around `locator.windowStart`, newest first.
|
|
246
|
+
*
|
|
247
|
+
* **Concurrency**: the window alone cannot separate two runs started seconds
|
|
248
|
+
* apart, so when the locator names a `cwd` the window set is restricted to
|
|
249
|
+
* files whose head records that same directory. A window that holds candidates
|
|
250
|
+
* but none in the run's directory resolves to NOTHING rather than to a
|
|
251
|
+
* neighbour's file — unless the window holds exactly one candidate, where
|
|
252
|
+
* there is no other run to confuse it with and the mismatch is path skew (a
|
|
253
|
+
* symlinked temp root) rather than ambiguity. A misattributed model is a
|
|
254
|
+
* worse answer than an absent one: the evaluation fails a run loud on
|
|
255
|
+
* declared ≠ observed.
|
|
256
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
257
|
+
* @param locator - the thread id, working directory, and/or start-time window.
|
|
258
|
+
* @returns the located candidate, or undefined when nothing locates.
|
|
259
|
+
*/
|
|
260
|
+
async function locateCodexRollout(homeDir, locator) {
|
|
261
|
+
const candidates = [];
|
|
262
|
+
let years;
|
|
263
|
+
try {
|
|
264
|
+
years = await readdir(join(homeDir, SESSIONS_ROOT));
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error.code === "ENOENT") return void 0;
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
for (const year of years) {
|
|
270
|
+
if (!/^\d{4}$/.test(year)) continue;
|
|
271
|
+
let months;
|
|
272
|
+
try {
|
|
273
|
+
months = await readdir(join(homeDir, SESSIONS_ROOT, year));
|
|
274
|
+
} catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
for (const month of months) {
|
|
278
|
+
if (!/^\d{2}$/.test(month)) continue;
|
|
279
|
+
let days;
|
|
280
|
+
try {
|
|
281
|
+
days = await readdir(join(homeDir, SESSIONS_ROOT, year, month));
|
|
282
|
+
} catch {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
for (const day of days) {
|
|
286
|
+
if (!/^\d{2}$/.test(day)) continue;
|
|
287
|
+
let files;
|
|
288
|
+
try {
|
|
289
|
+
files = await readdir(join(homeDir, SESSIONS_ROOT, year, month, day));
|
|
290
|
+
} catch {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
for (const name of files) {
|
|
294
|
+
if (!name.startsWith("rollout-") || !name.endsWith(".jsonl")) continue;
|
|
295
|
+
const path = join(homeDir, SESSIONS_ROOT, year, month, day, name);
|
|
296
|
+
try {
|
|
297
|
+
const handle = await open(path, "r");
|
|
298
|
+
let text;
|
|
299
|
+
try {
|
|
300
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
301
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
302
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
303
|
+
} finally {
|
|
304
|
+
await handle.close();
|
|
305
|
+
}
|
|
306
|
+
const record = rolloutRecord(text);
|
|
307
|
+
candidates.push({
|
|
308
|
+
path,
|
|
309
|
+
id: record?.id,
|
|
310
|
+
startedAt: record?.startedAt ?? rolloutFilenameTimestamp(name),
|
|
311
|
+
workDir: record?.workDir,
|
|
312
|
+
cliVersion: rolloutCliVersion(text)
|
|
313
|
+
});
|
|
314
|
+
} catch {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (candidates.length === 0) return void 0;
|
|
322
|
+
const threadMatch = locator.threadId === void 0 ? void 0 : candidates.find((candidate) => candidate.id === locator.threadId);
|
|
323
|
+
if (threadMatch !== void 0) return threadMatch;
|
|
324
|
+
if (locator.windowStart === void 0) return void 0;
|
|
325
|
+
const windowStart = locator.windowStart - ROLLOUT_WINDOW_SLACK_MS;
|
|
326
|
+
const windowEnd = Date.now() + ROLLOUT_WINDOW_FUTURE_MS;
|
|
327
|
+
const inWindow = candidates.filter((candidate) => candidate.startedAt !== void 0 && candidate.startedAt >= windowStart && candidate.startedAt <= windowEnd).sort((left, right) => (right.startedAt ?? 0) - (left.startedAt ?? 0));
|
|
328
|
+
if (locator.cwd === void 0) return inWindow[0];
|
|
329
|
+
const wanted = resolve(locator.cwd);
|
|
330
|
+
const sameCwd = inWindow.filter((candidate) => candidate.workDir !== void 0 && resolve(candidate.workDir) === wanted);
|
|
331
|
+
if (sameCwd.length > 0) return sameCwd[0];
|
|
332
|
+
return inWindow.length === 1 ? inWindow[0] : void 0;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Scan rollout text for this run's two read-back facts: the LAST `token_count`
|
|
336
|
+
* usage, and the model of the LAST `turn_context` whose top-level `timestamp`
|
|
337
|
+
* falls inside the run's window (`windowStart` onward) — the turn codex
|
|
338
|
+
* actually started for this round, so a resumed thread's earlier rounds are
|
|
339
|
+
* never misread and a round killed mid-turn still observes the model it
|
|
340
|
+
* started with. A torn line is skipped.
|
|
341
|
+
*/
|
|
342
|
+
function scanRolloutText(text, windowStart) {
|
|
343
|
+
const usage = codexRolloutTokenUsage(text);
|
|
344
|
+
const scan = usage === void 0 ? {} : { usage };
|
|
345
|
+
for (const raw of text.split("\n")) {
|
|
346
|
+
const line = raw.trim();
|
|
347
|
+
if (line === "") continue;
|
|
348
|
+
let event;
|
|
349
|
+
try {
|
|
350
|
+
event = JSON.parse(line);
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (event.type !== "turn_context" || typeof event.payload !== "object" || event.payload === null) continue;
|
|
355
|
+
if (windowStart !== void 0) {
|
|
356
|
+
const stamped = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : void 0;
|
|
357
|
+
if (stamped === void 0 || !Number.isFinite(stamped) || stamped < windowStart) continue;
|
|
358
|
+
}
|
|
359
|
+
const candidate = event.payload.model;
|
|
360
|
+
if (typeof candidate === "string" && candidate !== "") scan.model = candidate;
|
|
361
|
+
}
|
|
362
|
+
return scan;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Read one located rollout file for the round's usage and model.
|
|
366
|
+
*
|
|
367
|
+
* The tail is read first because it is where `token_count` lives (codex writes
|
|
368
|
+
* one per turn boundary, so the last is at the end). `turn_context` is the
|
|
369
|
+
* opposite: codex writes it when the turn STARTS, so a round that then
|
|
370
|
+
* produces more than {@link TAIL_BYTES} of events pushes it out of the tail
|
|
371
|
+
* entirely — which is exactly how a real evaluation run reported a null model
|
|
372
|
+
* against rollout files that named it plainly, while a short smoke round (a
|
|
373
|
+
* whole file smaller than the tail window) read back fine. So whenever the
|
|
374
|
+
* tail leaves either fact missing, a bounded full read from offset 0 fills it
|
|
375
|
+
* in.
|
|
376
|
+
*/
|
|
377
|
+
async function readRolloutFacts(path, windowStart) {
|
|
378
|
+
const handle = await open(path, "r");
|
|
379
|
+
try {
|
|
380
|
+
const { size } = await handle.stat();
|
|
381
|
+
if (size === 0) return {};
|
|
382
|
+
const tailLength = Math.min(TAIL_BYTES, size);
|
|
383
|
+
const tail = Buffer.alloc(tailLength);
|
|
384
|
+
const tailRead = await handle.read(tail, 0, tailLength, size - tailLength);
|
|
385
|
+
const scan = scanRolloutText(tail.subarray(0, tailRead.bytesRead).toString("utf8"), windowStart);
|
|
386
|
+
if (scan.usage !== void 0 && scan.model !== void 0 || size <= TAIL_BYTES) return scan;
|
|
387
|
+
const full = Buffer.alloc(Math.min(FULL_BYTES, size));
|
|
388
|
+
const fullRead = await handle.read(full, 0, full.length, 0);
|
|
389
|
+
const wide = scanRolloutText(full.subarray(0, fullRead.bytesRead).toString("utf8"), windowStart);
|
|
390
|
+
const usage = scan.usage ?? wide.usage;
|
|
391
|
+
const model = scan.model ?? wide.model;
|
|
392
|
+
return {
|
|
393
|
+
...usage === void 0 ? {} : { usage },
|
|
394
|
+
...model === void 0 ? {} : { model }
|
|
395
|
+
};
|
|
396
|
+
} finally {
|
|
397
|
+
await handle.close();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Read ONE run's facts back from its rollout file: the token usage a
|
|
402
|
+
* non-completed round never streamed, the model the exec `--json` wire does
|
|
403
|
+
* not carry at all (codex 0.144.0), and the codex build that served it. One
|
|
404
|
+
* locate and one file open serve all three.
|
|
405
|
+
*
|
|
406
|
+
* Every field is absent on a miss — no rollout file, an unreadable home, a
|
|
407
|
+
* hard kill before any turn boundary, a window with no turn_context. Absence
|
|
408
|
+
* is recorded, never guessed.
|
|
409
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
410
|
+
* @param locator - the thread id, working directory, and/or start-time window.
|
|
411
|
+
* @returns the round's read-back facts.
|
|
412
|
+
*/
|
|
413
|
+
async function codexRolloutRoundFacts(homeDir, locator) {
|
|
414
|
+
let located;
|
|
415
|
+
try {
|
|
416
|
+
located = await locateCodexRollout(homeDir, locator);
|
|
417
|
+
} catch {
|
|
418
|
+
return {};
|
|
419
|
+
}
|
|
420
|
+
if (located === void 0) return {};
|
|
421
|
+
const windowStart = locator.windowStart === void 0 ? void 0 : locator.windowStart - ROLLOUT_CONTEXT_SLACK_MS;
|
|
422
|
+
let scan = {};
|
|
423
|
+
try {
|
|
424
|
+
scan = await readRolloutFacts(located.path, windowStart);
|
|
425
|
+
} catch {}
|
|
426
|
+
return {
|
|
427
|
+
...scan.usage === void 0 ? {} : { usage: scan.usage },
|
|
428
|
+
...scan.model === void 0 ? {} : { model: scan.model },
|
|
429
|
+
...located.cliVersion === void 0 ? {} : { cliVersion: located.cliVersion }
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region lib/types/live-driver.js
|
|
434
|
+
/**
|
|
435
|
+
* The codex provider's live driver: one resident `codex app-server --stdio`
|
|
436
|
+
* process per member (child session), driven over the vendor's experimental
|
|
437
|
+
* JSON-RPC app-server wire (protocol-probed against codex-cli 0.144.0's
|
|
438
|
+
* `app-server generate-json-schema`; the harness `subagent-codex` package is
|
|
439
|
+
* the reference implementation). A delegation round is a `turn/start` on the
|
|
440
|
+
* member's resident thread instead of a fresh `codex exec` process, which buys
|
|
441
|
+
* what the process boundary could not: runtime-level graceful interrupt
|
|
442
|
+
* (`turn/interrupt`; the process survives and the thread stays continuable)
|
|
443
|
+
* and push-mode mirroring (`item/completed` + delta notifications instead of
|
|
444
|
+
* folding a polled stdout stream).
|
|
445
|
+
*
|
|
446
|
+
* Lifecycle mirrors the dsh live driver's discipline (M1): lazy spawn with an
|
|
447
|
+
* initialize handshake, one in-flight spawn per member, idle-timeout reclaim
|
|
448
|
+
* (stdin EOF → grace → SIGTERM ladder — the app-server wire has no shutdown
|
|
449
|
+
* method), crash re-spawn with `thread/resume` of the recorded thread id, a
|
|
450
|
+
* channel breaker with cooldown, and `disposeAll` on plugin unload. The wire
|
|
451
|
+
* adapter is self-contained (no new dependency): line framing with a
|
|
452
|
+
* StringDecoder for split UTF-8 tails, request correlation, and unattended
|
|
453
|
+
* auto-answers for the server→client approval requests (the proposal's
|
|
454
|
+
* default permission policy: answer exactly as the unattended exec mode does).
|
|
455
|
+
* @module @khorsheed/dsh-local-agent-codex/live-driver
|
|
456
|
+
*/
|
|
457
|
+
/** Default idle lifetime of an unused resident runtime before reclaim. */
|
|
458
|
+
const DEFAULT_LIVE_IDLE_MS = 18e5;
|
|
459
|
+
/** Default timeout for a wire request acknowledgement. */
|
|
460
|
+
const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 3e4;
|
|
461
|
+
/** Default timeout for the app-server handshake (a cold boot can be slow). */
|
|
462
|
+
const DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS = 6e4;
|
|
463
|
+
/** Bounded wait for an interrupted turn to converge while a run disposes. */
|
|
464
|
+
const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5e3;
|
|
465
|
+
/** How long a tripped channel breaker stays on the exec fallback. */
|
|
466
|
+
const DEFAULT_LIVE_CHANNEL_RETRY_MS = 3e5;
|
|
467
|
+
/** Grace between stdin EOF and SIGTERM when reclaiming an app-server. */
|
|
468
|
+
const RECLAIM_EOF_GRACE_MS = 1e3;
|
|
469
|
+
/** Default minimum interval between one streaming item's snapshot messages. */
|
|
470
|
+
const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
|
|
471
|
+
/**
|
|
472
|
+
* The app-server channel could not come up (spawn failure or handshake
|
|
473
|
+
* timeout/mismatch). The provider catches exactly this and falls back to the
|
|
474
|
+
* exec one-shot; any other error is a real round failure.
|
|
475
|
+
*/
|
|
476
|
+
var LiveChannelUnavailableError = class extends Error {
|
|
477
|
+
constructor(message) {
|
|
478
|
+
super(message);
|
|
479
|
+
this.name = "LiveChannelUnavailableError";
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
const DEFAULT_TIMEOUTS = {
|
|
483
|
+
initializeMs: DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS,
|
|
484
|
+
requestMs: DEFAULT_LIVE_REQUEST_TIMEOUT_MS,
|
|
485
|
+
convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
|
|
486
|
+
channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS
|
|
487
|
+
};
|
|
488
|
+
function thrown$1(value) {
|
|
489
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Auth-shaped failure signatures in codex's error output: the endpoint
|
|
493
|
+
* rejected the credential. Mirrors the exec path's detector
|
|
494
|
+
* (CODEX_AUTH_FAILURE in codex-cli-provider.ts); the live path matches
|
|
495
|
+
* against turn-failure messages, which is where a mid-run 401 surfaces when
|
|
496
|
+
* the process does not exit.
|
|
497
|
+
*/
|
|
498
|
+
const CODEX_LIVE_AUTH_FAILURE = /401 unauthorized|unauthorized|authentication failed|not logged in|access token/i;
|
|
499
|
+
function delay(ms) {
|
|
500
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Map one app-server `item/completed` ThreadItem onto the shared transcript
|
|
504
|
+
* line shape — the live transport's entry into the SAME fold the exec NDJSON
|
|
505
|
+
* parser owns (`foldCodexStreamLine`); both produce `CodexTranscriptLine`s
|
|
506
|
+
* and append through `appendCodexTranscriptLine`, so the two drivers' final
|
|
507
|
+
* child transcripts cannot drift.
|
|
508
|
+
*/
|
|
509
|
+
function codexAppServerItemToLine(item) {
|
|
510
|
+
const itemId = typeof item["id"] === "string" ? item["id"] : void 0;
|
|
511
|
+
switch (item["type"]) {
|
|
512
|
+
case "reasoning": {
|
|
513
|
+
const parts = [item["summary"], item["content"]].filter(Array.isArray).flat().filter((entry) => typeof entry === "string" && entry.trim() !== "");
|
|
514
|
+
return parts.length === 0 ? void 0 : {
|
|
515
|
+
kind: "think",
|
|
516
|
+
text: parts.join("\n"),
|
|
517
|
+
...itemId === void 0 ? {} : { itemId }
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
case "agentMessage": return typeof item["text"] === "string" ? {
|
|
521
|
+
kind: "text",
|
|
522
|
+
text: item["text"],
|
|
523
|
+
...itemId === void 0 ? {} : { itemId }
|
|
524
|
+
} : void 0;
|
|
525
|
+
case "commandExecution": {
|
|
526
|
+
const command = typeof item["command"] === "string" ? item.command : void 0;
|
|
527
|
+
const output = typeof item["aggregatedOutput"] === "string" && item["aggregatedOutput"].trim() !== "" ? item["aggregatedOutput"] : void 0;
|
|
528
|
+
if (command === void 0 && output === void 0) return void 0;
|
|
529
|
+
return {
|
|
530
|
+
kind: "tool",
|
|
531
|
+
id: itemId ?? `codex-live-${randomUUID()}`,
|
|
532
|
+
name: "Bash",
|
|
533
|
+
...command === void 0 ? {} : { args: command },
|
|
534
|
+
...output === void 0 ? {} : { result: output }
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
case "webSearch": return {
|
|
538
|
+
kind: "tool",
|
|
539
|
+
id: itemId ?? `codex-live-${randomUUID()}`,
|
|
540
|
+
name: "WebSearch"
|
|
541
|
+
};
|
|
542
|
+
case "fileChange": {
|
|
543
|
+
const changes = Array.isArray(item["changes"]) ? item["changes"] : [];
|
|
544
|
+
const args = changes.map((change) => `${typeof change["kind"] === "string" ? change["kind"] : "update"}: ${typeof change["path"] === "string" ? change["path"] : "?"}`).join("\n");
|
|
545
|
+
const diff = changes.map((change) => change["diff"]).filter((entry) => typeof entry === "string" && entry.trim() !== "").join("\n");
|
|
546
|
+
return {
|
|
547
|
+
kind: "tool",
|
|
548
|
+
id: itemId ?? `codex-live-${randomUUID()}`,
|
|
549
|
+
name: "ApplyPatch",
|
|
550
|
+
...args === "" ? {} : { args },
|
|
551
|
+
...diff === "" ? {} : { result: diff }
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
case "dynamicToolCall": {
|
|
555
|
+
const tool = typeof item["tool"] === "string" ? item.tool : "dynamic";
|
|
556
|
+
const args = item["arguments"] === void 0 || item["arguments"] === null ? void 0 : typeof item["arguments"] === "string" ? item["arguments"] : JSON.stringify(item["arguments"]);
|
|
557
|
+
return {
|
|
558
|
+
kind: "tool",
|
|
559
|
+
id: itemId ?? `codex-live-${randomUUID()}`,
|
|
560
|
+
name: tool,
|
|
561
|
+
...args === void 0 || args === "" ? {} : { args }
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
case "collabAgentToolCall": {
|
|
565
|
+
const tool = typeof item["tool"] === "string" ? item.tool : "collab";
|
|
566
|
+
return {
|
|
567
|
+
kind: "tool",
|
|
568
|
+
id: itemId ?? `codex-live-${randomUUID()}`,
|
|
569
|
+
name: `collab/${tool}`
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
case "mcpToolCall": {
|
|
573
|
+
const server = typeof item["server"] === "string" ? item.server : void 0;
|
|
574
|
+
const tool = typeof item["tool"] === "string" ? item.tool : void 0;
|
|
575
|
+
return {
|
|
576
|
+
kind: "tool",
|
|
577
|
+
id: itemId ?? `codex-live-${randomUUID()}`,
|
|
578
|
+
name: server !== void 0 && tool !== void 0 ? `${server}/${tool}` : tool ?? "mcp"
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
case "plan": return typeof item["text"] === "string" && item["text"].trim() !== "" ? {
|
|
582
|
+
kind: "think",
|
|
583
|
+
text: item["text"]
|
|
584
|
+
} : void 0;
|
|
585
|
+
default: return;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** Map the app-server's per-turn usage breakdown onto the shared usage contract. */
|
|
589
|
+
function usageFromAppServer(breakdown) {
|
|
590
|
+
const input = Number(breakdown["inputTokens"]);
|
|
591
|
+
const cached = Number(breakdown["cachedInputTokens"]);
|
|
592
|
+
const output = Number(breakdown["outputTokens"]);
|
|
593
|
+
const usage = {
|
|
594
|
+
inputTokens: Number.isFinite(input) && Number.isFinite(cached) ? Math.max(0, input - cached) : Number.isFinite(input) ? input : 0,
|
|
595
|
+
outputTokens: Number.isFinite(output) ? output : 0
|
|
596
|
+
};
|
|
597
|
+
if (Number.isFinite(cached) && cached > 0) usage.cacheReadTokens = cached;
|
|
598
|
+
return usage;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The app-server wire peer: newline-delimited JSON-RPC over the child's
|
|
602
|
+
* stdio. Self-contained on purpose (no new package dependency): request
|
|
603
|
+
* correlation, StringDecoder framing, and server→client request answering
|
|
604
|
+
* (the approval surface, auto-declined per the unattended policy).
|
|
605
|
+
*/
|
|
606
|
+
var CodexWirePeer = class {
|
|
607
|
+
output;
|
|
608
|
+
warn;
|
|
609
|
+
onServerRequest;
|
|
610
|
+
onNotification;
|
|
611
|
+
requestMs;
|
|
612
|
+
buffer = "";
|
|
613
|
+
decoder = new StringDecoder("utf8");
|
|
614
|
+
nextId = 0;
|
|
615
|
+
pending = /* @__PURE__ */ new Map();
|
|
616
|
+
closed = false;
|
|
617
|
+
constructor(input, output, warn, onServerRequest, onNotification, requestMs) {
|
|
618
|
+
this.output = output;
|
|
619
|
+
this.warn = warn;
|
|
620
|
+
this.onServerRequest = onServerRequest;
|
|
621
|
+
this.onNotification = onNotification;
|
|
622
|
+
this.requestMs = requestMs;
|
|
623
|
+
input?.on("data", (chunk) => {
|
|
624
|
+
this.feed(this.decoder.write(chunk));
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
write(message) {
|
|
628
|
+
this.output?.write(JSON.stringify(message) + "\n");
|
|
629
|
+
}
|
|
630
|
+
request(method, params, timeoutMs) {
|
|
631
|
+
if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("subagent-codex live: the wire is closed"));
|
|
632
|
+
this.nextId += 1;
|
|
633
|
+
const id = `req_${this.nextId}`;
|
|
634
|
+
return new Promise((resolve, reject) => {
|
|
635
|
+
const timer = setTimeout(() => {
|
|
636
|
+
this.pending.delete(id);
|
|
637
|
+
reject(/* @__PURE__ */ new Error(`subagent-codex live: ${method} timed out`));
|
|
638
|
+
timer.unref();
|
|
639
|
+
}, timeoutMs ?? this.requestMs);
|
|
640
|
+
timer.unref();
|
|
641
|
+
this.pending.set(id, {
|
|
642
|
+
resolve: (result) => resolve(result),
|
|
643
|
+
reject,
|
|
644
|
+
timer
|
|
645
|
+
});
|
|
646
|
+
this.write({
|
|
647
|
+
jsonrpc: "2.0",
|
|
648
|
+
id,
|
|
649
|
+
method,
|
|
650
|
+
params
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
notify(method, params) {
|
|
655
|
+
this.write(params === void 0 ? {
|
|
656
|
+
jsonrpc: "2.0",
|
|
657
|
+
method
|
|
658
|
+
} : {
|
|
659
|
+
jsonrpc: "2.0",
|
|
660
|
+
method,
|
|
661
|
+
params
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/** Detach and reject every outstanding request. */
|
|
665
|
+
close() {
|
|
666
|
+
if (this.closed) return;
|
|
667
|
+
this.closed = true;
|
|
668
|
+
for (const request of this.pending.values()) {
|
|
669
|
+
clearTimeout(request.timer);
|
|
670
|
+
request.reject(/* @__PURE__ */ new Error("subagent-codex live: the wire closed"));
|
|
671
|
+
}
|
|
672
|
+
this.pending.clear();
|
|
673
|
+
}
|
|
674
|
+
feed(text) {
|
|
675
|
+
this.buffer += text;
|
|
676
|
+
let index = this.buffer.indexOf("\n");
|
|
677
|
+
while (index >= 0) {
|
|
678
|
+
const line = this.buffer.slice(0, index);
|
|
679
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
680
|
+
index = this.buffer.indexOf("\n");
|
|
681
|
+
if (line.trim() !== "") this.dispatchLine(line);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/** Handle one wire line; a malformed or hostile line never escapes this frame. */
|
|
685
|
+
dispatchLine(line) {
|
|
686
|
+
let message;
|
|
687
|
+
try {
|
|
688
|
+
message = JSON.parse(line);
|
|
689
|
+
} catch {
|
|
690
|
+
this.warn("subagent-codex live: ignored a malformed wire line");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
const hasId = typeof message.id === "string" || typeof message.id === "number";
|
|
695
|
+
if (hasId && typeof message.method === "string") {
|
|
696
|
+
const id = message.id;
|
|
697
|
+
try {
|
|
698
|
+
const result = this.onServerRequest(message.method, message.params ?? {});
|
|
699
|
+
this.write({
|
|
700
|
+
jsonrpc: "2.0",
|
|
701
|
+
id,
|
|
702
|
+
result
|
|
703
|
+
});
|
|
704
|
+
} catch (error) {
|
|
705
|
+
this.write({
|
|
706
|
+
jsonrpc: "2.0",
|
|
707
|
+
id,
|
|
708
|
+
error: {
|
|
709
|
+
code: -32603,
|
|
710
|
+
message: thrown$1(error).message
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (hasId && ("result" in message || "error" in message)) {
|
|
717
|
+
const request = this.pending.get(String(message.id));
|
|
718
|
+
if (request === void 0) return;
|
|
719
|
+
this.pending.delete(String(message.id));
|
|
720
|
+
clearTimeout(request.timer);
|
|
721
|
+
const wireError = message.error;
|
|
722
|
+
if (wireError !== void 0 && wireError !== null) request.reject(/* @__PURE__ */ new Error(`subagent-codex live: ${wireError.message ?? "wire error"}`));
|
|
723
|
+
else request.resolve(message.result);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (typeof message.method === "string") this.onNotification(message.method, message.params ?? {});
|
|
727
|
+
} catch (error) {
|
|
728
|
+
this.warn(`subagent-codex live: wire line handler failed: ${thrown$1(error).message}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
/** One resident app-server process: the wire peer, the loaded thread's id, and
|
|
733
|
+
* the reclaim ladder (stdin EOF → grace → SIGTERM → grace → SIGKILL).
|
|
734
|
+
*/
|
|
735
|
+
var CodexLiveRuntime = class {
|
|
736
|
+
child;
|
|
737
|
+
dead = false;
|
|
738
|
+
reclaimed = false;
|
|
739
|
+
peer;
|
|
740
|
+
/** The member's loaded thread; assigned by the first round's thread/start or thread/resume. */
|
|
741
|
+
threadId;
|
|
742
|
+
/**
|
|
743
|
+
* The model this process bound at spawn (the `-c model=…` override), or
|
|
744
|
+
* undefined when it spawned with no model flag at all. A round whose start
|
|
745
|
+
* model differs retires the runtime instead of silently running the old one.
|
|
746
|
+
*/
|
|
747
|
+
configurationKey;
|
|
748
|
+
boundModel;
|
|
749
|
+
/** Turn ids of settled/interrupted rounds — their late notifications are tagged out. */
|
|
750
|
+
retiredTurnIds = /* @__PURE__ */ new Set();
|
|
751
|
+
/** The active round's notification sink; installed per round, cleared at settle. */
|
|
752
|
+
onWireNotification;
|
|
753
|
+
onDead;
|
|
754
|
+
stderrTail = "";
|
|
755
|
+
stderrDecoder = new StringDecoder("utf8");
|
|
756
|
+
constructor(child, onServerRequest, warn, requestMs) {
|
|
757
|
+
this.child = child;
|
|
758
|
+
this.peer = new CodexWirePeer(child.stdout, child.stdin, warn, onServerRequest, (method, params) => {
|
|
759
|
+
this.onWireNotification?.(method, params);
|
|
760
|
+
}, requestMs);
|
|
761
|
+
child.stderr?.on("data", (chunk) => {
|
|
762
|
+
this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
|
|
763
|
+
});
|
|
764
|
+
child.done.then(() => {
|
|
765
|
+
this.markDead();
|
|
766
|
+
}, () => {
|
|
767
|
+
this.markDead();
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
get diagnostics() {
|
|
771
|
+
return this.stderrTail.trim();
|
|
772
|
+
}
|
|
773
|
+
markDead() {
|
|
774
|
+
if (this.dead) return;
|
|
775
|
+
this.dead = true;
|
|
776
|
+
this.peer.close();
|
|
777
|
+
this.onDead?.();
|
|
778
|
+
}
|
|
779
|
+
/** Graceful teardown: close the wire, EOF stdin, grace, then the terminate ladder. */
|
|
780
|
+
async reclaim() {
|
|
781
|
+
if (this.reclaimed) return;
|
|
782
|
+
this.reclaimed = true;
|
|
783
|
+
this.peer.close();
|
|
784
|
+
if (!this.dead) {
|
|
785
|
+
this.child.stdin?.end();
|
|
786
|
+
await Promise.race([this.child.done.catch(() => {}), delay(RECLAIM_EOF_GRACE_MS)]);
|
|
787
|
+
}
|
|
788
|
+
if (!this.dead) {
|
|
789
|
+
this.child.terminate();
|
|
790
|
+
await this.child.waitForExit();
|
|
791
|
+
}
|
|
792
|
+
await this.child.done.catch(() => {});
|
|
793
|
+
this.markDead();
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
/** Unattended approval answer: prefer cancel, else decline (harness-proven tables). */
|
|
797
|
+
function unattendedDecision(params) {
|
|
798
|
+
const available = params["availableDecisions"];
|
|
799
|
+
if (Array.isArray(available)) {
|
|
800
|
+
if (available.includes("cancel")) return "cancel";
|
|
801
|
+
if (available.includes("decline")) return "decline";
|
|
802
|
+
}
|
|
803
|
+
return "decline";
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* The codex live driver owns every resident app-server runtime of this
|
|
807
|
+
* provider generation. Created only when `live: true`; disposed with the
|
|
808
|
+
* plugin.
|
|
809
|
+
*/
|
|
810
|
+
var CodexLiveDriver = class {
|
|
811
|
+
ctx;
|
|
812
|
+
config;
|
|
813
|
+
timeouts;
|
|
814
|
+
runtimes = /* @__PURE__ */ new Map();
|
|
815
|
+
/** In-flight spawns by member: concurrent rounds share one, disposeAll waits them out. */
|
|
816
|
+
ensuring = /* @__PURE__ */ new Map();
|
|
817
|
+
idleTimers = /* @__PURE__ */ new Map();
|
|
818
|
+
/** Per-member round serialization (the resume lock covers resume-vs-resume only). */
|
|
819
|
+
roundChains = /* @__PURE__ */ new Map();
|
|
820
|
+
channelBrokenAt;
|
|
821
|
+
disposed = false;
|
|
822
|
+
/**
|
|
823
|
+
* Set by drain() (a settings-driven generation handoff): new rounds are
|
|
824
|
+
* refused so the provider falls back to exec, while in-flight rounds finish
|
|
825
|
+
* on their runtime undisturbed. Unlike `disposed`, the driver still serves
|
|
826
|
+
* what it already accepted.
|
|
827
|
+
*/
|
|
828
|
+
draining = false;
|
|
829
|
+
disposeController = new AbortController();
|
|
830
|
+
constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
|
|
831
|
+
this.ctx = ctx;
|
|
832
|
+
this.config = config;
|
|
833
|
+
this.timeouts = timeouts;
|
|
834
|
+
}
|
|
835
|
+
/** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
|
|
836
|
+
get disabled() {
|
|
837
|
+
return this.channelBrokenAt !== void 0 && Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? 3e5);
|
|
838
|
+
}
|
|
839
|
+
/** Count of resident runtimes currently registered (zombie accounting in tests). */
|
|
840
|
+
get liveCount() {
|
|
841
|
+
return this.runtimes.size;
|
|
842
|
+
}
|
|
843
|
+
clearIdleTimer(key) {
|
|
844
|
+
const timer = this.idleTimers.get(key);
|
|
845
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
846
|
+
this.idleTimers.delete(key);
|
|
847
|
+
}
|
|
848
|
+
armIdleTimer(key) {
|
|
849
|
+
this.clearIdleTimer(key);
|
|
850
|
+
const idleMs = this.config.liveIdleMs ?? 18e5;
|
|
851
|
+
const timer = setTimeout(() => {
|
|
852
|
+
this.reclaim(key);
|
|
853
|
+
}, idleMs);
|
|
854
|
+
timer.unref();
|
|
855
|
+
this.idleTimers.set(key, timer);
|
|
856
|
+
}
|
|
857
|
+
async reclaim(key) {
|
|
858
|
+
const runtime = this.runtimes.get(key);
|
|
859
|
+
this.clearIdleTimer(key);
|
|
860
|
+
if (runtime === void 0) return;
|
|
861
|
+
this.runtimes.delete(key);
|
|
862
|
+
await runtime.reclaim();
|
|
863
|
+
}
|
|
864
|
+
/** Reclaim every runtime (plugin unload); no process survives the profile. */
|
|
865
|
+
async disposeAll() {
|
|
866
|
+
this.disposed = true;
|
|
867
|
+
this.disposeController.abort();
|
|
868
|
+
await Promise.all([...this.ensuring.values()].map((pending) => pending.catch(() => void 0)));
|
|
869
|
+
for (const key of [...this.runtimes.keys()]) await this.reclaim(key);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* True while the member's runtime exists or is being spawned. The settings
|
|
873
|
+
* controller gates a new generation on this: a fresh driver must not serve
|
|
874
|
+
* a member whose retiring generation still hosts the (same) codex thread.
|
|
875
|
+
*/
|
|
876
|
+
hasRuntime(key) {
|
|
877
|
+
return this.runtimes.has(key) || this.ensuring.has(key);
|
|
878
|
+
}
|
|
879
|
+
/** @deprecated Compatibility no-op: live output is always incremental. */
|
|
880
|
+
setLiveMirrorGranularity(_granularity) {}
|
|
881
|
+
/**
|
|
882
|
+
* Drain for a settings-driven generation handoff: refuse new rounds (the
|
|
883
|
+
* provider's catch falls back to exec), let every in-flight round finish on
|
|
884
|
+
* its runtime, then reclaim. Resolves when no runtime or spawn remains.
|
|
885
|
+
* Unlike disposeAll, in-flight work is never interrupted.
|
|
886
|
+
*/
|
|
887
|
+
async drain() {
|
|
888
|
+
this.draining = true;
|
|
889
|
+
const keys = /* @__PURE__ */ new Set([
|
|
890
|
+
...this.runtimes.keys(),
|
|
891
|
+
...this.ensuring.keys(),
|
|
892
|
+
...this.roundChains.keys()
|
|
893
|
+
]);
|
|
894
|
+
await Promise.all([...keys].map(async (key) => {
|
|
895
|
+
await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => void 0);
|
|
896
|
+
await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => void 0);
|
|
897
|
+
await this.reclaim(key);
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* The model the member's live runtime bound at spawn: the identifier, or
|
|
902
|
+
* undefined when the runtime spawned with no model flag. Null when the
|
|
903
|
+
* member has no live runtime in this generation — the broker's switch check
|
|
904
|
+
* distinguishes "no runtime to retire" from "runtime bound to no model".
|
|
905
|
+
*/
|
|
906
|
+
boundModelOf(key) {
|
|
907
|
+
const runtime = this.runtimes.get(key);
|
|
908
|
+
if (runtime === void 0 || runtime.dead) return null;
|
|
909
|
+
return runtime.boundModel;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Retire the member's resident runtime so the NEXT round respawns (the CLI
|
|
913
|
+
* session itself carries over via thread/resume). The composer's model
|
|
914
|
+
* switch calls this after storing the new override; the caller guarantees
|
|
915
|
+
* no round is in flight for the member.
|
|
916
|
+
*/
|
|
917
|
+
async retireRuntime(key) {
|
|
918
|
+
await this.reclaim(key);
|
|
919
|
+
}
|
|
920
|
+
/** The member's runtime, spawning it (once per member at a time) when absent or dead. */
|
|
921
|
+
async ensureRuntime(spec, signal) {
|
|
922
|
+
const key = String(spec.childSession.id);
|
|
923
|
+
const startModel = spec.startModel?.trim();
|
|
924
|
+
const existing = this.runtimes.get(key);
|
|
925
|
+
if (existing !== void 0 && !existing.dead) {
|
|
926
|
+
if (spec.configuration !== void 0 && existing.configurationKey !== JSON.stringify(spec.configuration) || spec.configuration === void 0 && existing.configurationKey !== void 0 || startModel !== void 0 && startModel !== "" && existing.boundModel !== startModel) await this.reclaim(key);
|
|
927
|
+
else {
|
|
928
|
+
this.clearIdleTimer(key);
|
|
929
|
+
return existing;
|
|
930
|
+
}
|
|
931
|
+
} else if (existing !== void 0) this.runtimes.delete(key);
|
|
932
|
+
const pending = this.ensuring.get(key);
|
|
933
|
+
if (pending !== void 0) return pending;
|
|
934
|
+
const spawn = this.spawnRuntime(spec, signal).finally(() => {
|
|
935
|
+
this.ensuring.delete(key);
|
|
936
|
+
});
|
|
937
|
+
this.ensuring.set(key, spawn);
|
|
938
|
+
return spawn;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Spawn the member's resident app-server and prove the wire with a
|
|
942
|
+
* handshake. Failure trips the channel breaker; a cancellation mid-spawn
|
|
943
|
+
* reclaims the half-started process without touching the breaker.
|
|
944
|
+
*/
|
|
945
|
+
async spawnRuntime(spec, signal) {
|
|
946
|
+
const key = String(spec.childSession.id);
|
|
947
|
+
if (this.disposed) throw new LiveChannelUnavailableError("the live driver is disposed");
|
|
948
|
+
const member = registerCodexMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
|
|
949
|
+
const startModel = spec.startModel?.trim();
|
|
950
|
+
const model = spec.configuration !== void 0 ? spec.configuration.model : startModel !== void 0 && startModel !== "" ? startModel : this.config.model?.(key)?.trim();
|
|
951
|
+
const spawnSpec = {
|
|
952
|
+
argv: [
|
|
953
|
+
"codex",
|
|
954
|
+
"app-server",
|
|
955
|
+
...member === void 0 ? [] : ["-c", member.configOverride],
|
|
956
|
+
...model === void 0 || model === "" ? [] : ["-c", `model=${JSON.stringify(model)}`],
|
|
957
|
+
...spec.configuration?.effort === void 0 ? [] : ["-c", `model_reasoning_effort=${JSON.stringify(spec.configuration.effort)}`],
|
|
958
|
+
"--stdio"
|
|
959
|
+
],
|
|
960
|
+
cwd: spec.cwd,
|
|
961
|
+
stdio: {
|
|
962
|
+
stdin: "pipe",
|
|
963
|
+
stdout: "pipe",
|
|
964
|
+
stderr: "pipe"
|
|
965
|
+
},
|
|
966
|
+
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
967
|
+
env: delegationEnv({ CODEX_HOME: spec.homeDir })
|
|
968
|
+
};
|
|
969
|
+
let child;
|
|
970
|
+
try {
|
|
971
|
+
child = this.ctx.subprocess.spawn(spawnSpec);
|
|
972
|
+
} catch (error) {
|
|
973
|
+
member?.release();
|
|
974
|
+
this.markChannelBroken();
|
|
975
|
+
throw new LiveChannelUnavailableError(`the app-server failed to spawn: ${thrown$1(error).message}`);
|
|
976
|
+
}
|
|
977
|
+
const runtime = new CodexLiveRuntime(child, (method, params) => this.answerServerRequest(method, params), (message) => {
|
|
978
|
+
this.ctx.logger.warn(message);
|
|
979
|
+
}, this.timeouts.requestMs);
|
|
980
|
+
runtime.boundModel = model === void 0 || model === "" ? void 0 : model;
|
|
981
|
+
runtime.configurationKey = spec.configuration === void 0 ? void 0 : JSON.stringify(spec.configuration);
|
|
982
|
+
runtime.onDead = () => {
|
|
983
|
+
if (this.runtimes.get(key) === runtime) this.runtimes.delete(key);
|
|
984
|
+
this.clearIdleTimer(key);
|
|
985
|
+
member?.release();
|
|
986
|
+
};
|
|
987
|
+
const aborted = new Promise((_, reject) => {
|
|
988
|
+
const cancel = () => {
|
|
989
|
+
reject(/* @__PURE__ */ new Error("subagent-codex: run cancelled locally"));
|
|
990
|
+
};
|
|
991
|
+
const unload = () => {
|
|
992
|
+
reject(new LiveChannelUnavailableError("the live driver was disposed during spawn"));
|
|
993
|
+
};
|
|
994
|
+
if (signal.aborted) {
|
|
995
|
+
cancel();
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (this.disposeController.signal.aborted) {
|
|
999
|
+
unload();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
1003
|
+
this.disposeController.signal.addEventListener("abort", unload, { once: true });
|
|
1004
|
+
});
|
|
1005
|
+
aborted.catch(() => {});
|
|
1006
|
+
try {
|
|
1007
|
+
await Promise.race([runtime.peer.request("initialize", {
|
|
1008
|
+
clientInfo: {
|
|
1009
|
+
name: "dsh-local-agent-codex",
|
|
1010
|
+
title: "dsh local-agent codex live driver",
|
|
1011
|
+
version: "0.1.0"
|
|
1012
|
+
},
|
|
1013
|
+
capabilities: {
|
|
1014
|
+
experimentalApi: false,
|
|
1015
|
+
requestAttestation: false
|
|
1016
|
+
}
|
|
1017
|
+
}, this.timeouts.initializeMs), aborted]);
|
|
1018
|
+
runtime.peer.notify("initialized");
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
await runtime.reclaim();
|
|
1021
|
+
if (signal.aborted) throw thrown$1(error);
|
|
1022
|
+
this.markChannelBroken();
|
|
1023
|
+
throw new LiveChannelUnavailableError(`the app-server handshake failed: ${thrown$1(error).message}`);
|
|
1024
|
+
}
|
|
1025
|
+
if (this.disposed) {
|
|
1026
|
+
await runtime.reclaim();
|
|
1027
|
+
throw new LiveChannelUnavailableError("the live driver was disposed during spawn");
|
|
1028
|
+
}
|
|
1029
|
+
this.channelBrokenAt = void 0;
|
|
1030
|
+
this.runtimes.set(key, runtime);
|
|
1031
|
+
return runtime;
|
|
1032
|
+
}
|
|
1033
|
+
markChannelBroken() {
|
|
1034
|
+
this.channelBrokenAt = Date.now();
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Report an auth-shaped round failure to the family registry's auth-failure
|
|
1038
|
+
* mark (the exec path's post-exit 401 detection ported to a process that
|
|
1039
|
+
* never exits: the match runs against the settled error instead). Degrades
|
|
1040
|
+
* silently on a core predating reportAuthFailure.
|
|
1041
|
+
*/
|
|
1042
|
+
reportAuthIfShaped(error) {
|
|
1043
|
+
if (!CODEX_LIVE_AUTH_FAILURE.test(error.message)) return;
|
|
1044
|
+
const registry = this.ctx.localAgent;
|
|
1045
|
+
if (typeof registry.reportAuthFailure === "function") registry.reportAuthFailure("codex", error.message.split("\n").find((line) => line.trim() !== "") ?? error.message);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Auto-answer the app-server's approval/elicitation requests, matching the
|
|
1049
|
+
* unattended exec behavior (and the harness reference implementation's
|
|
1050
|
+
* tables): the live driver never grants interactive approval. This is the
|
|
1051
|
+
* proposal's default permission policy; the relay hook for a human
|
|
1052
|
+
* approval UI is a later increment.
|
|
1053
|
+
*/
|
|
1054
|
+
answerServerRequest(method, params) {
|
|
1055
|
+
switch (method) {
|
|
1056
|
+
case "item/commandExecution/requestApproval":
|
|
1057
|
+
case "item/fileChange/requestApproval": return { decision: unattendedDecision(params) };
|
|
1058
|
+
case "item/permissions/requestApproval": return {
|
|
1059
|
+
permissions: {},
|
|
1060
|
+
scope: "turn"
|
|
1061
|
+
};
|
|
1062
|
+
case "item/tool/requestUserInput": return { answers: {} };
|
|
1063
|
+
case "mcpServer/elicitation/request": return {
|
|
1064
|
+
action: "decline",
|
|
1065
|
+
content: null,
|
|
1066
|
+
_meta: null
|
|
1067
|
+
};
|
|
1068
|
+
default: throw new Error(`subagent-codex live: unsupported app-server request ${method}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Drive one delegation round on the member's resident app-server. Mirrors
|
|
1073
|
+
* the exec run's settlement contract exactly so the facade, the tool, and
|
|
1074
|
+
* the projections cannot tell the difference — except `cancel` is a runtime
|
|
1075
|
+
* interrupt and the process survives. Cancellation is honored in every
|
|
1076
|
+
* window (the M1 acceptance lesson): the abort listener registers before
|
|
1077
|
+
* any await, the spawn/handshake races it, and a pre-accept cancel reclaims
|
|
1078
|
+
* the fresh runtime instead of letting the turn run unwatched.
|
|
1079
|
+
*/
|
|
1080
|
+
/** Create/resume the native thread without starting a model turn. */
|
|
1081
|
+
async prepare(spec, signal) {
|
|
1082
|
+
signal.throwIfAborted();
|
|
1083
|
+
if (this.draining || this.disabled) throw new LiveChannelUnavailableError("the live driver is unavailable for preparation");
|
|
1084
|
+
try {
|
|
1085
|
+
const runtime = await this.ensureRuntime(spec, signal);
|
|
1086
|
+
await this.ensureThread(runtime, spec);
|
|
1087
|
+
signal.throwIfAborted();
|
|
1088
|
+
this.armIdleTimer(String(spec.childSession.id));
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
await this.reclaim(String(spec.childSession.id));
|
|
1091
|
+
throw error;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
async ensureThread(rt, spec) {
|
|
1095
|
+
if (rt.threadId === void 0) {
|
|
1096
|
+
const permissionParams = {
|
|
1097
|
+
approvalPolicy: "never",
|
|
1098
|
+
sandbox: this.config.sandbox ?? "workspace-write"
|
|
1099
|
+
};
|
|
1100
|
+
if (spec.resume === void 0) {
|
|
1101
|
+
const response = await rt.peer.request("thread/start", {
|
|
1102
|
+
cwd: spec.cwd,
|
|
1103
|
+
ephemeral: false,
|
|
1104
|
+
...permissionParams
|
|
1105
|
+
});
|
|
1106
|
+
if (typeof response?.thread?.id !== "string") throw new Error("subagent-codex live: thread/start returned no thread id");
|
|
1107
|
+
rt.threadId = response.thread.id;
|
|
1108
|
+
} else {
|
|
1109
|
+
const response = await rt.peer.request("thread/resume", {
|
|
1110
|
+
threadId: spec.resume.cliSessionId,
|
|
1111
|
+
cwd: spec.cwd,
|
|
1112
|
+
...permissionParams
|
|
1113
|
+
});
|
|
1114
|
+
if (typeof response?.thread?.id !== "string") throw new Error("subagent-codex live: thread/resume returned no thread");
|
|
1115
|
+
rt.threadId = response.thread.id;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (spec.resume === void 0 && rt.threadId !== void 0) spec.onThreadId?.(rt.threadId);
|
|
1119
|
+
}
|
|
1120
|
+
async startRound(request, spec) {
|
|
1121
|
+
if (this.draining) throw new LiveChannelUnavailableError("the live driver is draining (a settings change retired this generation)");
|
|
1122
|
+
const key = String(spec.childSession.id);
|
|
1123
|
+
const round = (this.roundChains.get(key) ?? Promise.resolve()).catch(() => {}).then(() => this.startRoundLocked(request, spec));
|
|
1124
|
+
this.roundChains.set(key, round.then((handle) => handle.result.catch(() => ({})), () => ({})));
|
|
1125
|
+
return round;
|
|
1126
|
+
}
|
|
1127
|
+
/** The serialized round body. */
|
|
1128
|
+
async startRoundLocked(request, spec) {
|
|
1129
|
+
const task = textTask(request.prompt);
|
|
1130
|
+
if (request.signal.aborted) throw new Error("subagent-codex: request was aborted before the run started");
|
|
1131
|
+
if (this.draining) throw new LiveChannelUnavailableError("the live driver is draining (a settings change retired this generation)");
|
|
1132
|
+
if (this.disposed) throw new Error("subagent-codex: the live driver is disposed");
|
|
1133
|
+
const turn = spec.resume?.turn ?? 1;
|
|
1134
|
+
const childSession = spec.childSession;
|
|
1135
|
+
const localAgent = this.ctx.get("localAgent");
|
|
1136
|
+
/** The round's start moment, anchoring the settle read-back's rollout time window. */
|
|
1137
|
+
const startedAtMs = Date.now();
|
|
1138
|
+
const runAbort = new AbortController();
|
|
1139
|
+
let roundSettled = false;
|
|
1140
|
+
let runtime;
|
|
1141
|
+
/** The in-flight turn's server-assigned id; interrupt needs it. */
|
|
1142
|
+
let activeTurnId;
|
|
1143
|
+
/** Pre-ack turn id observed via turn/started (notifications can precede the response). */
|
|
1144
|
+
let pendingTurnId;
|
|
1145
|
+
let turnOpened = false;
|
|
1146
|
+
let lastText = "";
|
|
1147
|
+
/** Final-answer selection per the harness wire: final_answer phase beats unphased. */
|
|
1148
|
+
let lastFinalAnswer;
|
|
1149
|
+
let lastUnphasedAnswer;
|
|
1150
|
+
/** The round's folded transcript (the exec fold's line shape). */
|
|
1151
|
+
const lines = [];
|
|
1152
|
+
let mirrored = 0;
|
|
1153
|
+
let usage;
|
|
1154
|
+
/** Whether a folded line already carries the round's usage (no backfill event exists). */
|
|
1155
|
+
let usageCarried = false;
|
|
1156
|
+
/**
|
|
1157
|
+
* The step ledger: `reservedSteps` holds every step a stream reservation
|
|
1158
|
+
* consumed (in increasing order — permanently, whether the stream later
|
|
1159
|
+
* completes or stays an orphan snapshot), and a sequential line folds at
|
|
1160
|
+
* its index shifted past every reservation before it. A tool completed
|
|
1161
|
+
* mid-stream therefore folds ABOVE the stream's step and the projection
|
|
1162
|
+
* renders the round's chronological order.
|
|
1163
|
+
*/
|
|
1164
|
+
const reservedSteps = [];
|
|
1165
|
+
/**
|
|
1166
|
+
* The streaming items seen this round by item id:
|
|
1167
|
+
* deltas accumulate into throttled snapshot assistant/messages appended
|
|
1168
|
+
* at the item's reserved (turn, step) — the host folds repeated settles
|
|
1169
|
+
* at one coordinate into one live-updating chat node, which is the only
|
|
1170
|
+
* streaming channel left after 0.1.5 retired the durable per-chunk event.
|
|
1171
|
+
* The item's completion folds at the same step and finalizes it; an entry
|
|
1172
|
+
* whose completion never arrives is force-finalized at settle.
|
|
1173
|
+
*/
|
|
1174
|
+
const streams = /* @__PURE__ */ new Map();
|
|
1175
|
+
/** The item deltas currently accumulate into (items stream sequentially). */
|
|
1176
|
+
let activeStream;
|
|
1177
|
+
/** Items/completions that arrived before the turn id was known. */
|
|
1178
|
+
const earlyNotifications = [];
|
|
1179
|
+
let persistQueue = Promise.resolve();
|
|
1180
|
+
const persist = () => {
|
|
1181
|
+
persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
|
|
1182
|
+
this.ctx.logger.warn(`subagent-codex: live persistence failed: ${thrown$1(error).message}`);
|
|
1183
|
+
});
|
|
1184
|
+
};
|
|
1185
|
+
const requestCancel = () => {
|
|
1186
|
+
if (roundSettled || runAbort.signal.aborted) return;
|
|
1187
|
+
runAbort.abort(/* @__PURE__ */ new Error("subagent-codex: run cancelled locally"));
|
|
1188
|
+
const turnId = activeTurnId ?? pendingTurnId;
|
|
1189
|
+
if (turnId !== void 0 && runtime?.threadId !== void 0 && !runtime.dead) runtime.peer.request("turn/interrupt", {
|
|
1190
|
+
threadId: runtime.threadId,
|
|
1191
|
+
turnId
|
|
1192
|
+
}).catch(() => {});
|
|
1193
|
+
};
|
|
1194
|
+
const onAbort = () => {
|
|
1195
|
+
requestCancel();
|
|
1196
|
+
};
|
|
1197
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
1198
|
+
const abortBranch = new Promise((_, reject) => {
|
|
1199
|
+
runAbort.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("subagent-codex: run cancelled locally")), { once: true });
|
|
1200
|
+
});
|
|
1201
|
+
const collectOutput = () => {
|
|
1202
|
+
const partial = activeStream === void 0 ? "" : streams.get(activeStream)?.text ?? "";
|
|
1203
|
+
const selected = lastFinalAnswer ?? lastUnphasedAnswer ?? (lastText !== "" ? lastText : partial === "" ? void 0 : partial);
|
|
1204
|
+
return selected === void 0 || selected.trim() === "" ? [] : [{
|
|
1205
|
+
type: "text",
|
|
1206
|
+
text: selected
|
|
1207
|
+
}];
|
|
1208
|
+
};
|
|
1209
|
+
/**
|
|
1210
|
+
* Append one snapshot of a streaming item at its reserved (turn, step).
|
|
1211
|
+
* Throttled per item by interval and growth unless `force`; the forced
|
|
1212
|
+
* final snapshot carries `interrupted` (a cancelled turn reads 已停止
|
|
1213
|
+
* legitimately) and, when `withUsage` and no folded line carried it, the
|
|
1214
|
+
* round's usage.
|
|
1215
|
+
*/
|
|
1216
|
+
const streamPublisher = localAgent?.liveStreams === void 0 ? void 0 : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, (error) => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
|
|
1217
|
+
const liveFlush = new LiveFlush((error) => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
|
|
1218
|
+
const pendingStreamArrival = /* @__PURE__ */ new Map();
|
|
1219
|
+
const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
|
|
1220
|
+
if (stream.text.trim() === "") return;
|
|
1221
|
+
const now = Date.now();
|
|
1222
|
+
if (!force) {
|
|
1223
|
+
if (!pendingStreamArrival.has(stream.step)) pendingStreamArrival.set(stream.step, now);
|
|
1224
|
+
liveFlush.schedule(stream.step, () => appendStreamSnapshot(stream, true, interrupted, withUsage));
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
liveFlush.cancel(stream.step);
|
|
1228
|
+
const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
|
|
1229
|
+
pendingStreamArrival.delete(stream.step);
|
|
1230
|
+
if (streamPublisher !== void 0 && !final) {
|
|
1231
|
+
if (!stream.opened) {
|
|
1232
|
+
childSession.append("step/start", {
|
|
1233
|
+
turn,
|
|
1234
|
+
step: stream.step
|
|
1235
|
+
});
|
|
1236
|
+
stream.opened = true;
|
|
1237
|
+
}
|
|
1238
|
+
streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
|
|
1239
|
+
stream.lastSnapshotAt = now;
|
|
1240
|
+
stream.lastSnapshotLen = stream.text.length;
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
if (!stream.opened) {
|
|
1244
|
+
childSession.append("step/start", {
|
|
1245
|
+
turn,
|
|
1246
|
+
step: stream.step
|
|
1247
|
+
});
|
|
1248
|
+
stream.opened = true;
|
|
1249
|
+
}
|
|
1250
|
+
const attachUsage = withUsage && !usageCarried && usage !== void 0;
|
|
1251
|
+
childSession.append("assistant/message", {
|
|
1252
|
+
turn,
|
|
1253
|
+
step: stream.step,
|
|
1254
|
+
message: codexAssistantEvent([stream.kind === "think" ? {
|
|
1255
|
+
type: "reasoning",
|
|
1256
|
+
text: stream.text
|
|
1257
|
+
} : {
|
|
1258
|
+
type: "text",
|
|
1259
|
+
text: stream.text
|
|
1260
|
+
}]),
|
|
1261
|
+
stream: [],
|
|
1262
|
+
...withUsage && !usageCarried && usage !== void 0 ? { usage } : {},
|
|
1263
|
+
...interrupted ? { interrupted: true } : {}
|
|
1264
|
+
}, { surfaceOp: "append" });
|
|
1265
|
+
if (attachUsage) usageCarried = true;
|
|
1266
|
+
stream.lastSnapshotAt = now;
|
|
1267
|
+
stream.lastSnapshotLen = stream.text.length;
|
|
1268
|
+
persist();
|
|
1269
|
+
};
|
|
1270
|
+
/**
|
|
1271
|
+
* Reserve the step one new streaming item will occupy — past every
|
|
1272
|
+
* completed line (folded or held back) and every earlier reservation. A
|
|
1273
|
+
* different item's deltas force one final snapshot of the current stream
|
|
1274
|
+
* (its completion still folds at its own reserved step when it lands).
|
|
1275
|
+
*/
|
|
1276
|
+
const reserveStream = (itemId, kind) => {
|
|
1277
|
+
if (activeStream !== void 0 && activeStream !== itemId) {
|
|
1278
|
+
const previous = streams.get(activeStream);
|
|
1279
|
+
if (previous !== void 0 && previous.text.length !== previous.lastSnapshotLen) appendStreamSnapshot(previous, true, false);
|
|
1280
|
+
activeStream = void 0;
|
|
1281
|
+
}
|
|
1282
|
+
let stream = streams.get(itemId);
|
|
1283
|
+
if (stream === void 0) {
|
|
1284
|
+
const step = lines.length + reservedSteps.length + 1;
|
|
1285
|
+
reservedSteps.push(step);
|
|
1286
|
+
stream = {
|
|
1287
|
+
itemId,
|
|
1288
|
+
kind,
|
|
1289
|
+
step,
|
|
1290
|
+
text: "",
|
|
1291
|
+
lastSnapshotAt: 0,
|
|
1292
|
+
lastSnapshotLen: 0,
|
|
1293
|
+
opened: false
|
|
1294
|
+
};
|
|
1295
|
+
streams.set(itemId, stream);
|
|
1296
|
+
}
|
|
1297
|
+
activeStream = itemId;
|
|
1298
|
+
};
|
|
1299
|
+
/** The sequential fold step for `lines[index]`: its position shifted past every reservation before it. */
|
|
1300
|
+
const foldStep = (index) => {
|
|
1301
|
+
let step = index + 1;
|
|
1302
|
+
for (const reserved of reservedSteps) if (reserved <= step) step += 1;
|
|
1303
|
+
else break;
|
|
1304
|
+
return step;
|
|
1305
|
+
};
|
|
1306
|
+
/** Mirror folded lines [mirrored, upto); the last line is held back until completion. */
|
|
1307
|
+
const mirrorUpTo = (upto, withUsage) => {
|
|
1308
|
+
let usageIndex = -1;
|
|
1309
|
+
if (withUsage) {
|
|
1310
|
+
for (let index = 0; index < lines.length; index += 1) if (lines[index]?.kind !== "tool") usageIndex = index;
|
|
1311
|
+
}
|
|
1312
|
+
for (let index = mirrored; index < upto; index += 1) {
|
|
1313
|
+
const line = lines[index];
|
|
1314
|
+
if (line === void 0) continue;
|
|
1315
|
+
const lineUsage = withUsage && index === usageIndex ? usage : void 0;
|
|
1316
|
+
if (lineUsage !== void 0) usageCarried = true;
|
|
1317
|
+
const itemId = line.kind === "tool" ? void 0 : line.itemId;
|
|
1318
|
+
const stream = itemId === void 0 ? void 0 : streams.get(itemId);
|
|
1319
|
+
if (itemId !== void 0 && stream !== void 0) {
|
|
1320
|
+
liveFlush.cancel(stream.step);
|
|
1321
|
+
streams.delete(itemId);
|
|
1322
|
+
if (activeStream === itemId) activeStream = void 0;
|
|
1323
|
+
if (!stream.opened) childSession.append("step/start", {
|
|
1324
|
+
turn,
|
|
1325
|
+
step: stream.step
|
|
1326
|
+
});
|
|
1327
|
+
const blocks = stream.kind === "think" ? [{
|
|
1328
|
+
type: "reasoning",
|
|
1329
|
+
text: line.kind === "tool" ? "" : line.text
|
|
1330
|
+
}] : [{
|
|
1331
|
+
type: "text",
|
|
1332
|
+
text: line.kind === "tool" ? "" : line.text
|
|
1333
|
+
}];
|
|
1334
|
+
childSession.append("assistant/message", {
|
|
1335
|
+
turn,
|
|
1336
|
+
step: stream.step,
|
|
1337
|
+
message: codexAssistantEvent(blocks),
|
|
1338
|
+
stream: [],
|
|
1339
|
+
...lineUsage === void 0 ? {} : { usage: lineUsage }
|
|
1340
|
+
}, { surfaceOp: "append" });
|
|
1341
|
+
streamPublisher?.finish(stream.step);
|
|
1342
|
+
childSession.append("step/end", {
|
|
1343
|
+
turn,
|
|
1344
|
+
step: stream.step
|
|
1345
|
+
});
|
|
1346
|
+
} else appendCodexTranscriptLine(childSession, turn, foldStep(index), line, lineUsage);
|
|
1347
|
+
mirrored = index + 1;
|
|
1348
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1349
|
+
kind: "delta",
|
|
1350
|
+
text: codexLineText(line)
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
if (upto > 0) persist();
|
|
1354
|
+
};
|
|
1355
|
+
let resolveCompletion;
|
|
1356
|
+
const completion = new Promise((resolve) => {
|
|
1357
|
+
resolveCompletion = resolve;
|
|
1358
|
+
});
|
|
1359
|
+
const openTurn = () => {
|
|
1360
|
+
childSession.append("turn/start", { turn });
|
|
1361
|
+
childSession.append("user/message", createUserMessage({
|
|
1362
|
+
content: [{
|
|
1363
|
+
type: "text",
|
|
1364
|
+
text: task
|
|
1365
|
+
}],
|
|
1366
|
+
source: { kind: "user" }
|
|
1367
|
+
}), { surfaceOp: "append" });
|
|
1368
|
+
turnOpened = true;
|
|
1369
|
+
persist();
|
|
1370
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1371
|
+
kind: "delta",
|
|
1372
|
+
text: task
|
|
1373
|
+
});
|
|
1374
|
+
for (const early of earlyNotifications.splice(0)) dispatchTurnNotification(early.method, early.params);
|
|
1375
|
+
};
|
|
1376
|
+
/** Process one turn-scoped notification for the round's own turn id. */
|
|
1377
|
+
const dispatchTurnNotification = (method, params) => {
|
|
1378
|
+
if (method === "item/completed") {
|
|
1379
|
+
const item = params["item"];
|
|
1380
|
+
const line = codexAppServerItemToLine(item);
|
|
1381
|
+
if (item["type"] === "agentMessage" && typeof item["text"] === "string") {
|
|
1382
|
+
const phase = item["phase"];
|
|
1383
|
+
if (phase === "final_answer") lastFinalAnswer = item["text"];
|
|
1384
|
+
else if (phase === null || phase === void 0) lastUnphasedAnswer = item["text"];
|
|
1385
|
+
lastText = item["text"];
|
|
1386
|
+
}
|
|
1387
|
+
if (line === void 0) return;
|
|
1388
|
+
lines.push(line);
|
|
1389
|
+
mirrorUpTo(lines.length - 1, false);
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (method === "item/agentMessage/delta" || method === "item/reasoning/textDelta") {
|
|
1393
|
+
if (typeof params["delta"] !== "string") return;
|
|
1394
|
+
const text = params["delta"];
|
|
1395
|
+
if (text === "") return;
|
|
1396
|
+
const reasoning = method === "item/reasoning/textDelta";
|
|
1397
|
+
const itemId = typeof params["itemId"] === "string" ? params["itemId"] : `codex-stream-${reasoning ? "think" : "text"}-${turn}`;
|
|
1398
|
+
reserveStream(itemId, reasoning ? "think" : "text");
|
|
1399
|
+
const stream = activeStream === void 0 ? void 0 : streams.get(activeStream);
|
|
1400
|
+
if (stream !== void 0) {
|
|
1401
|
+
stream.text += text;
|
|
1402
|
+
appendStreamSnapshot(stream, false, false);
|
|
1403
|
+
}
|
|
1404
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1405
|
+
kind: "delta",
|
|
1406
|
+
text
|
|
1407
|
+
});
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (method === "thread/tokenUsage/updated") {
|
|
1411
|
+
const last = params["tokenUsage"]?.["last"];
|
|
1412
|
+
if (last !== void 0) usage = usageFromAppServer(last);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (method === "turn/completed") {
|
|
1416
|
+
const turnData = params["turn"];
|
|
1417
|
+
mirrorUpTo(lines.length, true);
|
|
1418
|
+
resolveCompletion({
|
|
1419
|
+
status: String(turnData["status"]),
|
|
1420
|
+
...turnData["error"] === null || turnData["error"] === void 0 ? {} : { error: turnData["error"] }
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
/** Route one wire notification: round-scoped, turn-id tagged, early ones buffered. */
|
|
1425
|
+
const dispatchNotification = (method, params) => {
|
|
1426
|
+
const paramThreadId = params["threadId"];
|
|
1427
|
+
if (typeof paramThreadId === "string" && runtime?.threadId !== void 0 && paramThreadId !== runtime.threadId) return;
|
|
1428
|
+
if (method === "turn/started") {
|
|
1429
|
+
const id = params["turn"]?.["id"];
|
|
1430
|
+
if (typeof id === "string" && activeTurnId === void 0 && !runtime?.retiredTurnIds.has(id)) pendingTurnId = id;
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (![
|
|
1434
|
+
"item/completed",
|
|
1435
|
+
"item/agentMessage/delta",
|
|
1436
|
+
"item/reasoning/textDelta",
|
|
1437
|
+
"thread/tokenUsage/updated",
|
|
1438
|
+
"turn/completed"
|
|
1439
|
+
].includes(method)) return;
|
|
1440
|
+
const turnId = method === "turn/completed" ? params["turn"]?.["id"] : params["turnId"];
|
|
1441
|
+
if (!(activeTurnId !== void 0 && turnId === activeTurnId) && !(activeTurnId === void 0 && pendingTurnId !== void 0 && turnId === pendingTurnId)) return;
|
|
1442
|
+
if (!turnOpened) {
|
|
1443
|
+
earlyNotifications.push({
|
|
1444
|
+
method,
|
|
1445
|
+
params
|
|
1446
|
+
});
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
dispatchTurnNotification(method, params);
|
|
1450
|
+
};
|
|
1451
|
+
const accepted = (async () => {
|
|
1452
|
+
const rt = await this.ensureRuntime(spec, request.signal);
|
|
1453
|
+
runtime = rt;
|
|
1454
|
+
if (runAbort.signal.aborted) {
|
|
1455
|
+
await this.reclaim(String(childSession.id));
|
|
1456
|
+
throw new Error("subagent-codex: run cancelled locally");
|
|
1457
|
+
}
|
|
1458
|
+
rt.onWireNotification = dispatchNotification;
|
|
1459
|
+
try {
|
|
1460
|
+
await this.ensureThread(rt, spec);
|
|
1461
|
+
const response = await rt.peer.request("turn/start", {
|
|
1462
|
+
threadId: rt.threadId,
|
|
1463
|
+
input: [{
|
|
1464
|
+
type: "text",
|
|
1465
|
+
text: task,
|
|
1466
|
+
text_elements: []
|
|
1467
|
+
}],
|
|
1468
|
+
...spec.configuration?.model === void 0 ? {} : { model: spec.configuration.model },
|
|
1469
|
+
...spec.configuration?.effort === void 0 ? {} : { effort: spec.configuration.effort }
|
|
1470
|
+
});
|
|
1471
|
+
if (typeof response?.turn?.id !== "string") throw new Error("subagent-codex live: turn/start returned no turn id");
|
|
1472
|
+
activeTurnId = response.turn.id;
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
if (!runAbort.signal.aborted) await this.reclaim(String(childSession.id));
|
|
1475
|
+
throw thrown$1(error);
|
|
1476
|
+
}
|
|
1477
|
+
if (runAbort.signal.aborted && runtime !== void 0 && runtime.threadId !== void 0) runtime.peer.request("turn/interrupt", {
|
|
1478
|
+
threadId: runtime.threadId,
|
|
1479
|
+
turnId: activeTurnId
|
|
1480
|
+
}).catch(() => {});
|
|
1481
|
+
openTurn();
|
|
1482
|
+
})();
|
|
1483
|
+
const attempt = accepted.then(async () => {
|
|
1484
|
+
if (runAbort.signal.aborted) throw new Error("subagent-codex: run cancelled locally");
|
|
1485
|
+
const processFailure = runtime.child.done.then((outcome) => Promise.reject(/* @__PURE__ */ new Error(`subagent-codex: the live runtime exited mid-round (code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown$1(error)));
|
|
1486
|
+
processFailure.catch(() => {});
|
|
1487
|
+
const outcome = await Promise.race([completion, processFailure]);
|
|
1488
|
+
if (outcome.status === "interrupted") return {
|
|
1489
|
+
output: collectOutput(),
|
|
1490
|
+
stopReason: "aborted"
|
|
1491
|
+
};
|
|
1492
|
+
if (outcome.status === "failed") {
|
|
1493
|
+
const message = typeof outcome.error?.["message"] === "string" ? outcome.error["message"] : "the codex turn failed";
|
|
1494
|
+
throw new Error(`subagent-codex live: ${message}`);
|
|
1495
|
+
}
|
|
1496
|
+
if (outcome.status !== "completed") throw new Error(`subagent-codex live: the turn ended with unexpected status ${outcome.status}`);
|
|
1497
|
+
const output = collectOutput();
|
|
1498
|
+
if (output.length === 0) throw new Error("subagent-codex live: the turn completed but produced no answer");
|
|
1499
|
+
return {
|
|
1500
|
+
output,
|
|
1501
|
+
stopReason: "completed"
|
|
1502
|
+
};
|
|
1503
|
+
});
|
|
1504
|
+
const result = settleRunResult({
|
|
1505
|
+
attempt: () => Promise.race([attempt, abortBranch]),
|
|
1506
|
+
collectOutput,
|
|
1507
|
+
cancelled: () => runAbort.signal.aborted,
|
|
1508
|
+
onError: (error, stopReason) => {
|
|
1509
|
+
const diagnostics = runtime?.diagnostics ?? "";
|
|
1510
|
+
const suffix = diagnostics === "" ? "" : `; ${diagnostics}`;
|
|
1511
|
+
this.ctx.logger.warn(`subagent-codex: live round failed (${stopReason}): ${error.message}${suffix}`);
|
|
1512
|
+
this.reportAuthIfShaped(error);
|
|
1513
|
+
},
|
|
1514
|
+
signal: request.signal,
|
|
1515
|
+
onAbort
|
|
1516
|
+
}).then(async (settled) => {
|
|
1517
|
+
roundSettled = true;
|
|
1518
|
+
liveFlush.dispose();
|
|
1519
|
+
if (turnOpened) {
|
|
1520
|
+
if (mirrored < lines.length) mirrorUpTo(lines.length, true);
|
|
1521
|
+
if (streams.size > 0) {
|
|
1522
|
+
const remaining = [...streams.values()];
|
|
1523
|
+
for (const [position, stream] of remaining.entries()) {
|
|
1524
|
+
appendStreamSnapshot(stream, true, settled.stopReason !== "completed", position === remaining.length - 1, true);
|
|
1525
|
+
streamPublisher?.finish(stream.step);
|
|
1526
|
+
if (stream.opened) childSession.append("step/end", {
|
|
1527
|
+
turn,
|
|
1528
|
+
step: stream.step
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
streams.clear();
|
|
1532
|
+
activeStream = void 0;
|
|
1533
|
+
}
|
|
1534
|
+
if (settled.stopReason === "completed") childSession.append("turn/end", {
|
|
1535
|
+
turn,
|
|
1536
|
+
reason: { kind: "completed" }
|
|
1537
|
+
});
|
|
1538
|
+
else if (settled.stopReason === "aborted") childSession.append("turn/end", {
|
|
1539
|
+
turn,
|
|
1540
|
+
reason: {
|
|
1541
|
+
kind: "aborted",
|
|
1542
|
+
reason: { kind: "parent" }
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
else childSession.append("turn/end", {
|
|
1546
|
+
turn,
|
|
1547
|
+
reason: {
|
|
1548
|
+
kind: "error",
|
|
1549
|
+
error: {
|
|
1550
|
+
message: "the live round did not complete",
|
|
1551
|
+
code: "UNKNOWN"
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
if (runtime !== void 0) {
|
|
1557
|
+
if (activeTurnId !== void 0) runtime.retiredTurnIds.add(activeTurnId);
|
|
1558
|
+
runtime.onWireNotification = void 0;
|
|
1559
|
+
}
|
|
1560
|
+
streamPublisher?.dispose();
|
|
1561
|
+
if (turnOpened) {
|
|
1562
|
+
persist();
|
|
1563
|
+
await persistQueue;
|
|
1564
|
+
}
|
|
1565
|
+
return settled;
|
|
1566
|
+
});
|
|
1567
|
+
result.then(async () => {
|
|
1568
|
+
try {
|
|
1569
|
+
if (turnOpened) {
|
|
1570
|
+
await persistQueue.catch(() => {});
|
|
1571
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1572
|
+
kind: "mirror",
|
|
1573
|
+
mirroredLines: mirrored
|
|
1574
|
+
});
|
|
1575
|
+
const facts = await codexRolloutRoundFacts(spec.homeDir, {
|
|
1576
|
+
threadId: runtime?.threadId,
|
|
1577
|
+
windowStart: startedAtMs,
|
|
1578
|
+
cwd: spec.cwd
|
|
1579
|
+
});
|
|
1580
|
+
const settledUsage = usage ?? facts.usage;
|
|
1581
|
+
const round = {
|
|
1582
|
+
...facts.model === void 0 ? {} : { observedModel: facts.model },
|
|
1583
|
+
...facts.cliVersion === void 0 ? {} : { cliVersion: facts.cliVersion },
|
|
1584
|
+
...settledUsage === void 0 ? {} : { usage: settledUsage }
|
|
1585
|
+
};
|
|
1586
|
+
localAgent?.recordRoundSettled?.(childSession.id, round);
|
|
1587
|
+
}
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
this.ctx.logger.warn(`subagent-codex: live settle observation failed: ${thrown$1(error).message}`);
|
|
1590
|
+
} finally {
|
|
1591
|
+
this.armIdleTimer(String(childSession.id));
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
await Promise.race([accepted, abortBranch.catch(() => void 0)]);
|
|
1595
|
+
return subprocessRunHandle({
|
|
1596
|
+
id: childSession.id,
|
|
1597
|
+
result,
|
|
1598
|
+
signal: request.signal,
|
|
1599
|
+
onAbort,
|
|
1600
|
+
requestCancel,
|
|
1601
|
+
teardown: async () => {
|
|
1602
|
+
if (roundSettled) return;
|
|
1603
|
+
await Promise.race([completion.catch(() => ({ status: "interrupted" })), delay(this.timeouts.convergeMs)]);
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region lib/types/provision.js
|
|
1610
|
+
/**
|
|
1611
|
+
* Codex scoped-home provisioning: pin file-based credential storage so the
|
|
1612
|
+
* device-code login writes `auth.json` inside the scoped home. Codex's
|
|
1613
|
+
* `cli_auth_credentials_store` defaults to `auto` — on macOS that resolves
|
|
1614
|
+
* to the keychain, which would both leak credentials outside the scoped
|
|
1615
|
+
* home and defeat the `auth.json` presence check this package's records
|
|
1616
|
+
* adapter uses for authentication. An existing config is respected
|
|
1617
|
+
* untouched; sign-out removes the credential file.
|
|
1618
|
+
* @module @khorsheed/dsh-local-agent-codex/provision
|
|
1619
|
+
*/
|
|
1620
|
+
/** Credential file inside the scoped home; its presence marks auth. */
|
|
1621
|
+
const AUTH_FILE = "auth.json";
|
|
1622
|
+
/** Minimal scoped config: keep credentials in the scoped home. */
|
|
1623
|
+
const SCOPED_CONFIG = `# Written by dsh-local-agent-codex: keep the device-code
|
|
1624
|
+
# credentials inside the scoped home (auth.json) instead of the OS keychain.
|
|
1625
|
+
cli_auth_credentials_store = "file"
|
|
1626
|
+
`;
|
|
1627
|
+
/**
|
|
1628
|
+
* Provision `<homeDir>/config.toml` unless one already exists. A present
|
|
1629
|
+
* config is respected untouched (a person may have edited it); otherwise a
|
|
1630
|
+
* minimal config pinning file-based credential storage is written so login
|
|
1631
|
+
* lands in the scoped home's `auth.json`.
|
|
1632
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
1633
|
+
* @returns true when a config was written, false when one already existed.
|
|
1634
|
+
*/
|
|
1635
|
+
async function provisionCodexConfig(homeDir) {
|
|
1636
|
+
try {
|
|
1637
|
+
await readFile(join(homeDir, "config.toml"));
|
|
1638
|
+
return false;
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
if (error.code !== "ENOENT") throw error;
|
|
1641
|
+
}
|
|
1642
|
+
await writeFile(join(homeDir, "config.toml"), SCOPED_CONFIG, { flag: "wx" });
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Read the scoped config's effective custom endpoint: the base_url of the
|
|
1647
|
+
* `model_providers` entry named by `model_provider`, or the first custom
|
|
1648
|
+
* provider's base_url when no selection exists (a user manually editing the
|
|
1649
|
+
* config to route through a custom endpoint typically sets both). Only used
|
|
1650
|
+
* for diagnostics — the config is authoritative and a user-edited value is
|
|
1651
|
+
* respected untouched.
|
|
1652
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
1653
|
+
* @returns the configured custom base URL, or undefined when none is set.
|
|
1654
|
+
*/
|
|
1655
|
+
async function readCodexBaseUrl(homeDir) {
|
|
1656
|
+
let text;
|
|
1657
|
+
try {
|
|
1658
|
+
text = await readFile(join(homeDir, "config.toml"), "utf8");
|
|
1659
|
+
} catch {
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
let section = "";
|
|
1663
|
+
let selected;
|
|
1664
|
+
const providers = /* @__PURE__ */ new Map();
|
|
1665
|
+
for (const rawLine of text.split("\n")) {
|
|
1666
|
+
const line = rawLine.trim();
|
|
1667
|
+
const header = /^\[+([^\]]+)\]+$/.exec(line);
|
|
1668
|
+
if (header !== null) {
|
|
1669
|
+
section = header[1];
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
const key = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
|
|
1673
|
+
if (key === null) continue;
|
|
1674
|
+
if (section === "" && key[1] === "model_provider") selected = key[2];
|
|
1675
|
+
const provider = /^model_providers\.(".*"|[\w-]+)$/.exec(section);
|
|
1676
|
+
if (provider !== null && key[1] === "base_url") providers.set(provider[1].replace(/^"(.*)"$/, "$1"), key[2]);
|
|
1677
|
+
}
|
|
1678
|
+
if (selected !== void 0 && providers.has(selected)) return providers.get(selected);
|
|
1679
|
+
if (providers.size === 1) return [...providers.values()][0];
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Read the scoped config's effective reasoning effort: the top-level
|
|
1683
|
+
* `model_reasoning_effort` key (codex reads it for reasoning-capable models).
|
|
1684
|
+
* A missing or malformed config, or no key, yields undefined — the honest
|
|
1685
|
+
* "unknown", never a guessed default. Read-only: the config is authoritative
|
|
1686
|
+
* and a user-edited value is reported as-is. The provider passes no effort
|
|
1687
|
+
* override on the argv, so this IS the value every round runs with.
|
|
1688
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
1689
|
+
* @returns the configured effort, or undefined when none is set.
|
|
1690
|
+
*/
|
|
1691
|
+
async function readCodexReasoningEffort(homeDir) {
|
|
1692
|
+
return readTopLevelConfigString(homeDir, "model_reasoning_effort");
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Read the scoped config's configured model: the top-level `model` key — the
|
|
1696
|
+
* selection a plain `codex exec` round runs with. A missing or malformed
|
|
1697
|
+
* config, or no key, yields undefined — the honest "unknown", never a guessed
|
|
1698
|
+
* default. Read-only: the config is authoritative and a user-edited value is
|
|
1699
|
+
* reported as-is.
|
|
1700
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
1701
|
+
* @returns the configured model identifier, or undefined when none is set.
|
|
1702
|
+
*/
|
|
1703
|
+
async function readCodexModel(homeDir) {
|
|
1704
|
+
return readTopLevelConfigString(homeDir, "model");
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* One top-level string key from the scoped config.toml: counts only before
|
|
1708
|
+
* the first table header — the same rule the model_provider scan applies.
|
|
1709
|
+
* Absent file or absent key yields undefined, never a guessed default.
|
|
1710
|
+
*/
|
|
1711
|
+
async function readTopLevelConfigString(homeDir, key) {
|
|
1712
|
+
let text;
|
|
1713
|
+
try {
|
|
1714
|
+
text = await readFile(join(homeDir, "config.toml"), "utf8");
|
|
1715
|
+
} catch {
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
let section = "";
|
|
1719
|
+
for (const rawLine of text.split("\n")) {
|
|
1720
|
+
const line = rawLine.trim();
|
|
1721
|
+
const header = /^\[+([^\]]+)\]+$/.exec(line);
|
|
1722
|
+
if (header !== null) {
|
|
1723
|
+
section = header[1];
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
if (section !== "") continue;
|
|
1727
|
+
const match = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
|
|
1728
|
+
if (match !== null && match[1] === key) return match[2];
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Every model identifier the scoped config.toml itself names, deduped in file
|
|
1733
|
+
* order: the top-level `model` key plus each `[profiles.<name>]` table's own
|
|
1734
|
+
* `model` (a profile is how a codex config names a second model at all).
|
|
1735
|
+
* `[model_providers.*]` tables are deliberately NOT collected — their keys
|
|
1736
|
+
* name providers (endpoint definitions), not models, so offering one as a
|
|
1737
|
+
* model choice would be a wrong suggestion, not a discovered one. Absent file
|
|
1738
|
+
* or no model keys yields an empty list, never a hardcoded catalog entry.
|
|
1739
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
1740
|
+
* @returns the model identifiers the file carries.
|
|
1741
|
+
*/
|
|
1742
|
+
async function listCodexConfigModels(homeDir) {
|
|
1743
|
+
let text;
|
|
1744
|
+
try {
|
|
1745
|
+
text = await readFile(join(homeDir, "config.toml"), "utf8");
|
|
1746
|
+
} catch {
|
|
1747
|
+
return [];
|
|
1748
|
+
}
|
|
1749
|
+
const found = [];
|
|
1750
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1751
|
+
const collect = (value) => {
|
|
1752
|
+
if (value === void 0 || value.trim() === "" || seen.has(value)) return;
|
|
1753
|
+
seen.add(value);
|
|
1754
|
+
found.push(value);
|
|
1755
|
+
};
|
|
1756
|
+
let section = "";
|
|
1757
|
+
for (const rawLine of text.split("\n")) {
|
|
1758
|
+
const line = rawLine.trim();
|
|
1759
|
+
const header = /^\[+([^\]]+)\]+$/.exec(line);
|
|
1760
|
+
if (header !== null) {
|
|
1761
|
+
section = header[1];
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
const match = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
|
|
1765
|
+
if (match === null || match[1] !== "model") continue;
|
|
1766
|
+
if (section === "" || section.startsWith("profiles.")) collect(match[2]);
|
|
1767
|
+
}
|
|
1768
|
+
return found;
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* The credential file's modification stamp (epoch ms), undefined when absent.
|
|
1772
|
+
* A completed device-code login rewrites `auth.json`, so the stamp
|
|
1773
|
+
* distinguishes a fresh login from a leftover (possibly revoked) credential.
|
|
1774
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
1775
|
+
* @returns the marker's mtime, or undefined when no credential exists.
|
|
1776
|
+
*/
|
|
1777
|
+
async function codexCredentialStamp(homeDir) {
|
|
1778
|
+
try {
|
|
1779
|
+
return (await stat(join(homeDir, AUTH_FILE))).mtimeMs;
|
|
1780
|
+
} catch {
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* Sign out of the scoped account: remove the credential file, so `/<name>
|
|
1786
|
+
* status` reports not authenticated and the next login authorizes a fresh
|
|
1787
|
+
* account. With `cli_auth_credentials_store = "file"` the credentials live
|
|
1788
|
+
* solely in `auth.json`, so removing it is the logout path.
|
|
1789
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
1790
|
+
*/
|
|
1791
|
+
async function codexLogout(homeDir) {
|
|
1792
|
+
await rm(join(homeDir, AUTH_FILE), { force: true });
|
|
1793
|
+
}
|
|
1794
|
+
//#endregion
|
|
1795
|
+
//#region lib/types/codex-cli-provider.js
|
|
1796
|
+
/**
|
|
1797
|
+
* One-shot Codex CLI subagent lifecycle: spawn `codex exec "<task>"` through
|
|
1798
|
+
* the subprocess seam under the harness's scoped home, capture its printed
|
|
1799
|
+
* response as the run output, and dispose to whole-tree quiescence. Mirrors
|
|
1800
|
+
* the kimi-cli provider: every accepted run is a fresh process and a fresh
|
|
1801
|
+
* codex session; there is no continuation across runs.
|
|
1802
|
+
*
|
|
1803
|
+
* The provider name `codex-local` avoids colliding with the official
|
|
1804
|
+
* `subagent-codex` package's provider name `codex` — a composition that
|
|
1805
|
+
* mounts both would fail loud with DUPLICATE_PROVIDER. The tool row in this
|
|
1806
|
+
* bundle's patch takes the official model-facing name `subagent_codex`
|
|
1807
|
+
* instead: the official preset row ships disabled, and the patch disables
|
|
1808
|
+
* it too (a deliberate user re-enable conflicts loud, by design).
|
|
1809
|
+
* @module @khorsheed/dsh-local-agent-codex/codex-cli-provider
|
|
1810
|
+
*/
|
|
1811
|
+
/** Quote one TOML basic string for the `-c` config override. */
|
|
1812
|
+
function tomlString(value) {
|
|
1813
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1814
|
+
}
|
|
1815
|
+
/** Default POSIX grace between subprocess termination tiers. */
|
|
1816
|
+
const DEFAULT_DISPOSE_GRACE_MS = 3e3;
|
|
1817
|
+
/**
|
|
1818
|
+
* Auth-shaped failure signatures in codex's stderr or event stream: the
|
|
1819
|
+
* endpoint rejected the credential. Narrow on purpose — matched against
|
|
1820
|
+
* provider-controlled error output only.
|
|
1821
|
+
*/
|
|
1822
|
+
const CODEX_AUTH_FAILURE = /401 unauthorized|unauthorized|authentication failed|not logged in|access token/i;
|
|
1823
|
+
/** Register one member run with the channel; see {@link CodexMemberRunHandle}. */
|
|
1824
|
+
function registerCodexMemberRun(ctx, childSessionId, parentSessionId) {
|
|
1825
|
+
const registry = ctx.localAgent;
|
|
1826
|
+
if (typeof registry.registerMemberRun !== "function" || typeof registry.memberBridgeSocketPath !== "function" || typeof registry.memberBridgeCommand !== "function") return void 0;
|
|
1827
|
+
const token = registry.registerMemberRun({
|
|
1828
|
+
childSessionId,
|
|
1829
|
+
parentSessionId,
|
|
1830
|
+
provider: "codex-local"
|
|
1831
|
+
});
|
|
1832
|
+
const serverName = `dsh-member-${token.slice(0, 8)}`;
|
|
1833
|
+
const bridge = registry.memberBridgeCommand();
|
|
1834
|
+
const configOverride = `mcp_servers.${serverName}={command=${tomlString(bridge.command)},args=[${bridge.args.map(tomlString).join(",")}],env={${MEMBER_BRIDGE_SOCKET_ENV}=${tomlString(registry.memberBridgeSocketPath())},${MEMBER_BRIDGE_TOKEN_ENV}=${tomlString(token)}},default_tools_approval_mode=${tomlString("approve")}}`;
|
|
1835
|
+
let released = false;
|
|
1836
|
+
return {
|
|
1837
|
+
token,
|
|
1838
|
+
configOverride,
|
|
1839
|
+
release: () => {
|
|
1840
|
+
if (released) return;
|
|
1841
|
+
released = true;
|
|
1842
|
+
registry.unregisterMemberRun(token);
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* One-shot and resumable Codex CLI subagent provider: every accepted FRESH run
|
|
1848
|
+
* starts a `codex exec` process in the delegating Session's workspace, under
|
|
1849
|
+
* the harness scoped home; a resume round (the family tool's staged resume
|
|
1850
|
+
* intent) continues the SAME thread with `codex exec --json resume <thread_id>`
|
|
1851
|
+
* inside the SAME dsh child session. With the live driver configured
|
|
1852
|
+
* (`live: true`), rounds instead go to the resident app-server process (see
|
|
1853
|
+
* live-driver.ts); the exec path below stays the fallback.
|
|
1854
|
+
*/
|
|
1855
|
+
/**
|
|
1856
|
+
* Mark the harness credential verified after a completed round, degrading
|
|
1857
|
+
* silently on a core that predates the mark (the family's companion-pair
|
|
1858
|
+
* rule: a provider paired with an older core loses the grade, never the run).
|
|
1859
|
+
* @param ctx - host context carrying the family registry.
|
|
1860
|
+
*/
|
|
1861
|
+
function markCredentialVerified(ctx, scope) {
|
|
1862
|
+
const registry = ctx.get("localAgent");
|
|
1863
|
+
if (registry === void 0 || typeof registry.reportAuthSuccess !== "function") return;
|
|
1864
|
+
registry.reportAuthSuccess("codex", scope);
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* The codex build `codex --version` reports, probed through the shared
|
|
1868
|
+
* subprocess seam inside the SCOPED home (never the user's own installation)
|
|
1869
|
+
* and cached by the family probe against the executable's identity. Both the
|
|
1870
|
+
* effective-settings snapshot and a settled round's read-back use it — the
|
|
1871
|
+
* round prefers its own rollout head, which names the build that actually ran.
|
|
1872
|
+
* @param ctx - host context carrying the subprocess seam.
|
|
1873
|
+
* @param homeDir - the harness's scoped home.
|
|
1874
|
+
* @returns the version, or undefined when the CLI cannot be asked.
|
|
1875
|
+
*/
|
|
1876
|
+
function codexCliVersion(ctx, homeDir) {
|
|
1877
|
+
const subprocess = ctx.get("subprocess");
|
|
1878
|
+
if (subprocess === void 0) return Promise.resolve(void 0);
|
|
1879
|
+
return probeCliVersion({
|
|
1880
|
+
argv: ["codex", "--version"],
|
|
1881
|
+
cwd: homeDir,
|
|
1882
|
+
spawn: (spec) => subprocess.spawn(spec),
|
|
1883
|
+
env: delegationEnv({ CODEX_HOME: homeDir })
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
var CodexCliProvider = class {
|
|
1887
|
+
ctx;
|
|
1888
|
+
sandbox;
|
|
1889
|
+
live;
|
|
1890
|
+
model;
|
|
1891
|
+
overrides;
|
|
1892
|
+
name = "codex-local";
|
|
1893
|
+
capabilities = NO_START_CAPABILITIES;
|
|
1894
|
+
inheritsParentContext = false;
|
|
1895
|
+
/**
|
|
1896
|
+
* @param live - the live driver, or a resolver returning the current
|
|
1897
|
+
* generation's driver per member (the settings toggle swaps generations;
|
|
1898
|
+
* a resolver may return undefined to steer one member's round to exec
|
|
1899
|
+
* while a retiring generation still hosts it).
|
|
1900
|
+
* @param model - resolver for the configured model, read PER ROUND so a
|
|
1901
|
+
* settings-card write takes effect on the next delegation without a
|
|
1902
|
+
* reload. Undefined (the resolver absent, or returning undefined) leaves
|
|
1903
|
+
* the argv exactly as it was before the key existed — the scoped
|
|
1904
|
+
* config.toml's own `model` then decides, as it always did.
|
|
1905
|
+
* @param overrides - resolver for the member's session-level model override
|
|
1906
|
+
* (the composer picker, owned by the model broker). It outranks BOTH the
|
|
1907
|
+
* delegation's recorded model and the configured one: on the exec path it
|
|
1908
|
+
* re-points the next round's `-m`, and on the live path it is the member's
|
|
1909
|
+
* start model — a resident runtime bound to a different model is retired
|
|
1910
|
+
* so the round respawns onto the override.
|
|
1911
|
+
*/
|
|
1912
|
+
constructor(ctx, sandbox = "workspace-write", live, model, overrides) {
|
|
1913
|
+
this.ctx = ctx;
|
|
1914
|
+
this.sandbox = sandbox;
|
|
1915
|
+
this.live = live;
|
|
1916
|
+
this.model = model;
|
|
1917
|
+
this.overrides = overrides;
|
|
1918
|
+
}
|
|
1919
|
+
/** Resolve the live driver for one round's member, if live is on for it. */
|
|
1920
|
+
liveDriver(childSessionId) {
|
|
1921
|
+
const live = this.live;
|
|
1922
|
+
if (live === void 0) return void 0;
|
|
1923
|
+
return typeof live === "function" ? live(childSessionId) : live;
|
|
1924
|
+
}
|
|
1925
|
+
/** Per-round member-channel registration for the exec path (see {@link registerCodexMemberRun}). */
|
|
1926
|
+
memberRun(childSessionId, parentSessionId) {
|
|
1927
|
+
return registerCodexMemberRun(this.ctx, childSessionId, parentSessionId);
|
|
1928
|
+
}
|
|
1929
|
+
async start(request) {
|
|
1930
|
+
const intent = this.ctx.localAgent.takeDelegationIntent(request.parent.session.id, this.name);
|
|
1931
|
+
const scope = intent?.scope;
|
|
1932
|
+
const homeDir = this.ctx.localAgent.homeDir("codex", scope);
|
|
1933
|
+
const cwd = resolveChildCwd(request.parent.session.header.cwd, intent?.cwd);
|
|
1934
|
+
if (cwd === void 0) throw new Error("subagent-codex: the parent session has no working directory to run the CLI in");
|
|
1935
|
+
const exec = intent?.exec;
|
|
1936
|
+
if (exec !== void 0) containerScopedHome(exec, "CODEX_HOME", "subagent-codex");
|
|
1937
|
+
if (intent !== void 0 && intent.kind === "resume") {
|
|
1938
|
+
const record = this.ctx.localAgent.getDelegation(intent.childSessionId);
|
|
1939
|
+
assertResumeCwdUnchanged(record, cwd, "subagent-codex");
|
|
1940
|
+
assertResumeScopeUnchanged(record, scope, "subagent-codex");
|
|
1941
|
+
if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") return this.startCodexResume(request, intent, cwd, homeDir, exec, scope, record?.model);
|
|
1942
|
+
return this.ctx.localAgent.withMemberConfigurationRound({
|
|
1943
|
+
childSessionId: intent.childSessionId,
|
|
1944
|
+
provider: this.name,
|
|
1945
|
+
parentSessionId: request.parent.session.id,
|
|
1946
|
+
cwd,
|
|
1947
|
+
...scope === void 0 ? {} : { scope },
|
|
1948
|
+
...record?.model === void 0 ? {} : { model: record.model },
|
|
1949
|
+
...record?.effort === void 0 ? {} : { effort: record.effort },
|
|
1950
|
+
...record?.configurationLock === void 0 ? {} : { configurationLock: record.configurationLock }
|
|
1951
|
+
}, (configuration) => this.startCodexResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
|
|
1952
|
+
}
|
|
1953
|
+
const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
|
|
1954
|
+
if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") {
|
|
1955
|
+
if (intent?.effort !== void 0) throw new Error("Codex effort requires the configuration admission core");
|
|
1956
|
+
return this.startCodexFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
|
|
1957
|
+
}
|
|
1958
|
+
return this.ctx.localAgent.withMemberConfigurationRound({
|
|
1959
|
+
childSessionId,
|
|
1960
|
+
provider: this.name,
|
|
1961
|
+
parentSessionId: request.parent.session.id,
|
|
1962
|
+
cwd,
|
|
1963
|
+
...scope === void 0 ? {} : { scope },
|
|
1964
|
+
...intent?.model === void 0 ? {} : { model: intent.model },
|
|
1965
|
+
...intent?.effort === void 0 ? {} : { effort: intent.effort },
|
|
1966
|
+
...intent?.configurationLock === void 0 ? {} : { configurationLock: intent.configurationLock }
|
|
1967
|
+
}, (configuration) => this.startCodexFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId, configuration), request.signal, intent?.onAdmitted);
|
|
1968
|
+
}
|
|
1969
|
+
/** Fresh round: record the child session, spawn `codex exec`, append after settle. */
|
|
1970
|
+
async startCodexFresh(request, cwd, homeDir, exec, scope, requestedModel, runId, configuration) {
|
|
1971
|
+
let childSession;
|
|
1972
|
+
try {
|
|
1973
|
+
const sessions = this.ctx.get("sessions");
|
|
1974
|
+
if (sessions === void 0) throw new Error("the sessions service is not mounted");
|
|
1975
|
+
childSession = sessions.get(runId) ?? sessions.create(runId, { meta: {
|
|
1976
|
+
cwd,
|
|
1977
|
+
parentSession: request.parent.session.id,
|
|
1978
|
+
origin: "subagent",
|
|
1979
|
+
delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1
|
|
1980
|
+
} });
|
|
1981
|
+
const harness = this.ctx.localAgent.get("codex");
|
|
1982
|
+
const label = subagentDelegationLabel(harness?.displayName ?? "codex", request.descriptor.label);
|
|
1983
|
+
childSession.append("subagent/descriptor", {
|
|
1984
|
+
...request.descriptor,
|
|
1985
|
+
label
|
|
1986
|
+
});
|
|
1987
|
+
establishSubagentCatalogChild(request.parent.session, childSession.header, label);
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
this.ctx.logger.warn(`subagent-codex: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1990
|
+
}
|
|
1991
|
+
const baseUrl = await readCodexBaseUrl(homeDir).catch(() => void 0);
|
|
1992
|
+
this.ctx.logger.info(`subagent-codex: delegating via ${baseUrl ?? "codex default endpoint"}`);
|
|
1993
|
+
const live = exec === void 0 ? this.liveDriver(runId) : void 0;
|
|
1994
|
+
if (live !== void 0 && childSession !== void 0 && !live.disabled) {
|
|
1995
|
+
const startModel = configuration === void 0 ? this.overrides?.(runId) ?? requestedModel : configuration.resolved.model;
|
|
1996
|
+
try {
|
|
1997
|
+
return await live.startRound(request, {
|
|
1998
|
+
cwd,
|
|
1999
|
+
homeDir,
|
|
2000
|
+
childSession,
|
|
2001
|
+
parentSessionId: request.parent.session.id,
|
|
2002
|
+
...startModel === void 0 ? {} : { startModel },
|
|
2003
|
+
...configuration === void 0 ? {} : { configuration: configuration.resolved },
|
|
2004
|
+
onThreadId: (threadId) => {
|
|
2005
|
+
this.ctx.localAgent.recordDelegation({
|
|
2006
|
+
childSessionId: runId,
|
|
2007
|
+
provider: this.name,
|
|
2008
|
+
parentSessionId: request.parent.session.id,
|
|
2009
|
+
cliSessionId: threadId,
|
|
2010
|
+
cwd,
|
|
2011
|
+
...scope === void 0 ? {} : { scope },
|
|
2012
|
+
...requestedModel === void 0 ? {} : { model: requestedModel }
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted) throw error;
|
|
2018
|
+
this.ctx.logger.warn(`subagent-codex: live driver unavailable, using the exec one-shot: ${error.message}`);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
const member = exec === void 0 ? this.memberRun(runId, request.parent.session.id) : void 0;
|
|
2022
|
+
try {
|
|
2023
|
+
const run = await startCodexCliRun(request, {
|
|
2024
|
+
cwd,
|
|
2025
|
+
env: delegationEnv({ CODEX_HOME: homeDir }),
|
|
2026
|
+
...exec === void 0 ? {} : { exec },
|
|
2027
|
+
endpointLabel: baseUrl,
|
|
2028
|
+
sandbox: this.sandbox,
|
|
2029
|
+
...configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, this.model),
|
|
2030
|
+
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
2031
|
+
spawn: (spec) => this.ctx.subprocess.spawn(spec),
|
|
2032
|
+
onError: (error, stopReason) => {
|
|
2033
|
+
this.ctx.logger.warn(`subagent-codex: child run failed (${stopReason}) via ${baseUrl ?? "codex default endpoint"}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2034
|
+
},
|
|
2035
|
+
onAuthFailure: (detail) => {
|
|
2036
|
+
this.ctx.localAgent.reportAuthFailure("codex", detail, scope);
|
|
2037
|
+
},
|
|
2038
|
+
onAuthSuccess: () => {
|
|
2039
|
+
markCredentialVerified(this.ctx, scope);
|
|
2040
|
+
},
|
|
2041
|
+
cliVersion: () => codexCliVersion(this.ctx, homeDir),
|
|
2042
|
+
...member === void 0 ? {} : { member: { configOverride: member.configOverride } },
|
|
2043
|
+
childSession,
|
|
2044
|
+
ctx: this.ctx,
|
|
2045
|
+
onThreadId: (threadId) => {
|
|
2046
|
+
if (threadId === void 0) return;
|
|
2047
|
+
this.ctx.localAgent.recordDelegation({
|
|
2048
|
+
childSessionId: runId,
|
|
2049
|
+
provider: this.name,
|
|
2050
|
+
parentSessionId: request.parent.session.id,
|
|
2051
|
+
cliSessionId: threadId,
|
|
2052
|
+
cwd,
|
|
2053
|
+
...scope === void 0 ? {} : { scope },
|
|
2054
|
+
...requestedModel === void 0 ? {} : { model: requestedModel }
|
|
2055
|
+
});
|
|
2056
|
+
},
|
|
2057
|
+
onRoundSettled: (round) => {
|
|
2058
|
+
this.ctx.localAgent.recordRoundSettled(runId, round);
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
2061
|
+
if (member !== void 0) run.result.then(member.release, member.release);
|
|
2062
|
+
return run;
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
member?.release();
|
|
2065
|
+
throw error;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
/** Resume round: continue the recorded thread inside the existing child session. */
|
|
2069
|
+
async startCodexResume(request, intent, cwd, homeDir, exec, scope, requestedModel, configuration) {
|
|
2070
|
+
if (!this.ctx.localAgent.acquireResumeLock(intent.childSessionId)) throw new Error(`subagent-codex: 该子会话有进行中的委派,等其完成后再追问 (child session ${intent.childSessionId})`);
|
|
2071
|
+
try {
|
|
2072
|
+
const childSession = this.ctx.get("sessions")?.get(SessionId(intent.childSessionId));
|
|
2073
|
+
if (childSession === void 0) throw new Error(`subagent-codex: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
|
|
2074
|
+
const nextTurn = childSession.snapshotEvents().filter((event) => event.type === "turn/start").length + 1;
|
|
2075
|
+
const baseUrl = await readCodexBaseUrl(homeDir).catch(() => void 0);
|
|
2076
|
+
this.ctx.logger.info(`subagent-codex: resuming via ${baseUrl ?? "codex default endpoint"}`);
|
|
2077
|
+
const live = exec === void 0 ? this.liveDriver(intent.childSessionId) : void 0;
|
|
2078
|
+
if (live !== void 0 && !live.disabled) {
|
|
2079
|
+
const startModel = configuration === void 0 ? this.overrides?.(intent.childSessionId) ?? requestedModel : configuration.resolved.model;
|
|
2080
|
+
try {
|
|
2081
|
+
const liveRun = await live.startRound(request, {
|
|
2082
|
+
cwd,
|
|
2083
|
+
homeDir,
|
|
2084
|
+
childSession,
|
|
2085
|
+
parentSessionId: request.parent.session.id,
|
|
2086
|
+
resume: {
|
|
2087
|
+
cliSessionId: intent.cliSessionId,
|
|
2088
|
+
turn: nextTurn
|
|
2089
|
+
},
|
|
2090
|
+
...startModel === void 0 ? {} : { startModel },
|
|
2091
|
+
...configuration === void 0 ? {} : { configuration: configuration.resolved }
|
|
2092
|
+
});
|
|
2093
|
+
liveRun.result.then(() => {
|
|
2094
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2095
|
+
}, () => {
|
|
2096
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2097
|
+
});
|
|
2098
|
+
return liveRun;
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted) throw error;
|
|
2101
|
+
this.ctx.logger.warn(`subagent-codex: live driver unavailable, using the exec one-shot: ${error.message}`);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
const member = exec === void 0 ? this.memberRun(intent.childSessionId, request.parent.session.id) : void 0;
|
|
2105
|
+
let run;
|
|
2106
|
+
try {
|
|
2107
|
+
run = await startCodexCliRun(request, {
|
|
2108
|
+
cwd,
|
|
2109
|
+
env: delegationEnv({ CODEX_HOME: homeDir }),
|
|
2110
|
+
...exec === void 0 ? {} : { exec },
|
|
2111
|
+
endpointLabel: baseUrl,
|
|
2112
|
+
sandbox: this.sandbox,
|
|
2113
|
+
...configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, this.model),
|
|
2114
|
+
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
2115
|
+
spawn: (spec) => this.ctx.subprocess.spawn(spec),
|
|
2116
|
+
onError: (error, stopReason) => {
|
|
2117
|
+
this.ctx.logger.warn(`subagent-codex: child run failed (${stopReason}) via ${baseUrl ?? "codex default endpoint"}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2118
|
+
},
|
|
2119
|
+
onAuthFailure: (detail) => {
|
|
2120
|
+
this.ctx.localAgent.reportAuthFailure("codex", detail, scope);
|
|
2121
|
+
},
|
|
2122
|
+
onAuthSuccess: () => {
|
|
2123
|
+
markCredentialVerified(this.ctx, scope);
|
|
2124
|
+
},
|
|
2125
|
+
cliVersion: () => codexCliVersion(this.ctx, homeDir),
|
|
2126
|
+
...member === void 0 ? {} : { member: { configOverride: member.configOverride } },
|
|
2127
|
+
childSession,
|
|
2128
|
+
ctx: this.ctx,
|
|
2129
|
+
resume: {
|
|
2130
|
+
cliSessionId: intent.cliSessionId,
|
|
2131
|
+
turn: nextTurn
|
|
2132
|
+
},
|
|
2133
|
+
onRoundSettled: (round) => {
|
|
2134
|
+
this.ctx.localAgent.recordRoundSettled(intent.childSessionId, round);
|
|
2135
|
+
}
|
|
2136
|
+
});
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
member?.release();
|
|
2139
|
+
throw error;
|
|
2140
|
+
}
|
|
2141
|
+
run.result.then(() => {
|
|
2142
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2143
|
+
member?.release();
|
|
2144
|
+
}, () => {
|
|
2145
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2146
|
+
member?.release();
|
|
2147
|
+
});
|
|
2148
|
+
return run;
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2151
|
+
throw error;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
function thrown(value) {
|
|
2156
|
+
/* v8 ignore next -- typed subprocess failures reject with Error. */
|
|
2157
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Validate and join the one-shot task before crossing the process boundary.
|
|
2161
|
+
* @param prompt - task content accepted from the shared subagent service.
|
|
2162
|
+
* @returns the joined non-empty text.
|
|
2163
|
+
*/
|
|
2164
|
+
function textTask(prompt) {
|
|
2165
|
+
if (prompt.length === 0) throw new Error("subagent-codex: the one-shot task must contain only text blocks");
|
|
2166
|
+
const texts = [];
|
|
2167
|
+
for (const block of prompt) {
|
|
2168
|
+
if (block.type !== "text") throw new Error("subagent-codex: the one-shot task must contain only text blocks");
|
|
2169
|
+
texts.push(block.text);
|
|
2170
|
+
}
|
|
2171
|
+
if (texts.every((text) => text.trim().length === 0)) throw new Error("subagent-codex: the one-shot task must not be empty");
|
|
2172
|
+
return texts.join("\n");
|
|
2173
|
+
}
|
|
2174
|
+
/** Count one tool call under the CLI's own name for the item. */
|
|
2175
|
+
function countToolCall(state, name) {
|
|
2176
|
+
state.toolCalls.set(name, (state.toolCalls.get(name) ?? 0) + 1);
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Fold a tool-call counter into the settled shape: `{count, byName}` with the
|
|
2180
|
+
* per-name values summing to `count`. An empty map yields undefined — a
|
|
2181
|
+
* stream that named no tool call reports nothing rather than a zero it did
|
|
2182
|
+
* not observe.
|
|
2183
|
+
* @param counts - the fold's per-name tally.
|
|
2184
|
+
* @returns the settled accounting, or undefined when nothing was counted.
|
|
2185
|
+
*/
|
|
2186
|
+
function toolCallsOf(counts) {
|
|
2187
|
+
if (counts.size === 0) return void 0;
|
|
2188
|
+
let count = 0;
|
|
2189
|
+
const byName = {};
|
|
2190
|
+
for (const [name, value] of counts) {
|
|
2191
|
+
count += value;
|
|
2192
|
+
byName[name] = value;
|
|
2193
|
+
}
|
|
2194
|
+
return {
|
|
2195
|
+
count,
|
|
2196
|
+
byName
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Fold one NDJSON line into the stream state. Shared by
|
|
2201
|
+
* {@link parseCodexJsonStream} (settle-time whole-stream parse) and
|
|
2202
|
+
* {@link CodexStreamParser} (live incremental parse) so the two paths cannot
|
|
2203
|
+
* drift.
|
|
2204
|
+
*/
|
|
2205
|
+
function foldCodexStreamLine(state, raw) {
|
|
2206
|
+
const line = raw.trim();
|
|
2207
|
+
if (line === "") return;
|
|
2208
|
+
let event;
|
|
2209
|
+
try {
|
|
2210
|
+
event = JSON.parse(line);
|
|
2211
|
+
} catch {
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
if (event.type === "thread.started" && typeof event.thread_id === "string") {
|
|
2215
|
+
state.threadId = event.thread_id;
|
|
2216
|
+
if (typeof event.model === "string" && event.model !== "") state.model = event.model;
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
if (event.type === "turn.completed") {
|
|
2220
|
+
state.completed = true;
|
|
2221
|
+
if (event.usage !== void 0) state.usage = usageFromCodex(event.usage);
|
|
2222
|
+
if (typeof event.model === "string" && event.model !== "") state.model = event.model;
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
if (event.type !== "item.completed" || event.item === void 0) return;
|
|
2226
|
+
const item = event.item;
|
|
2227
|
+
if (item.type === "reasoning" && typeof item.text === "string" && item.text.trim() !== "") state.lines.push({
|
|
2228
|
+
kind: "think",
|
|
2229
|
+
text: item.text
|
|
2230
|
+
});
|
|
2231
|
+
else if (item.type === "agent_message" && typeof item.text === "string") {
|
|
2232
|
+
state.lines.push({
|
|
2233
|
+
kind: "text",
|
|
2234
|
+
text: item.text
|
|
2235
|
+
});
|
|
2236
|
+
state.text = item.text;
|
|
2237
|
+
} else if (item.type === "command_execution") {
|
|
2238
|
+
countToolCall(state, "command_execution");
|
|
2239
|
+
const command = typeof item.command === "string" ? item.command : void 0;
|
|
2240
|
+
const output = typeof item.aggregated_output === "string" && item.aggregated_output.trim() !== "" ? item.aggregated_output : void 0;
|
|
2241
|
+
if (command !== void 0 || output !== void 0) state.lines.push({
|
|
2242
|
+
kind: "tool",
|
|
2243
|
+
id: typeof item.id === "string" ? item.id : `codex-tool-${state.lines.length}`,
|
|
2244
|
+
name: "Bash",
|
|
2245
|
+
...command === void 0 ? {} : { args: command },
|
|
2246
|
+
...output === void 0 ? {} : { result: output }
|
|
2247
|
+
});
|
|
2248
|
+
} else if (item.type === "web_search_call") {
|
|
2249
|
+
countToolCall(state, "web_search_call");
|
|
2250
|
+
state.lines.push({
|
|
2251
|
+
kind: "tool",
|
|
2252
|
+
id: typeof item.id === "string" ? item.id : `codex-tool-${state.lines.length}`,
|
|
2253
|
+
name: "WebSearch"
|
|
2254
|
+
});
|
|
2255
|
+
} else if (item.type === "file_change") {
|
|
2256
|
+
countToolCall(state, "file_change");
|
|
2257
|
+
const args = (item.changes ?? []).map((change) => `${change.kind ?? "update"}: ${change.path ?? "?"}`).join("\n");
|
|
2258
|
+
if (args !== "") state.lines.push({
|
|
2259
|
+
kind: "tool",
|
|
2260
|
+
id: typeof item.id === "string" ? item.id : `codex-tool-${state.lines.length}`,
|
|
2261
|
+
name: "ApplyPatch",
|
|
2262
|
+
args
|
|
2263
|
+
});
|
|
2264
|
+
} else if (item.type === "function_call") {
|
|
2265
|
+
countToolCall(state, "function_call");
|
|
2266
|
+
state.lines.push({
|
|
2267
|
+
kind: "tool",
|
|
2268
|
+
id: typeof item.id === "string" ? item.id : `codex-tool-${state.lines.length}`,
|
|
2269
|
+
name: item.name ?? "function",
|
|
2270
|
+
...typeof item.arguments === "string" && item.arguments !== "" ? { args: item.arguments } : {}
|
|
2271
|
+
});
|
|
2272
|
+
} else if (item.type === "function_call_output") {
|
|
2273
|
+
const output = typeof item.output === "string" ? item.output : void 0;
|
|
2274
|
+
if (output !== void 0 && output.trim() !== "") {
|
|
2275
|
+
const last = state.lines[state.lines.length - 1];
|
|
2276
|
+
if (last !== void 0 && last.kind === "tool") state.lines[state.lines.length - 1] = {
|
|
2277
|
+
...last,
|
|
2278
|
+
result: last.result === void 0 ? output : `${last.result}\n${output}`
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Incremental `codex exec --json` parser: feed stdout chunks as they arrive
|
|
2285
|
+
* and the folded transcript accumulates line by line. The last line is
|
|
2286
|
+
* volatile until the terminal `turn.completed` — a `function_call_output`
|
|
2287
|
+
* merges into a pending tool line — so live consumers hold it back (see the
|
|
2288
|
+
* run's live mirror).
|
|
2289
|
+
*/
|
|
2290
|
+
var CodexStreamParser = class {
|
|
2291
|
+
buffer = "";
|
|
2292
|
+
lines = [];
|
|
2293
|
+
text;
|
|
2294
|
+
usage;
|
|
2295
|
+
threadId;
|
|
2296
|
+
model;
|
|
2297
|
+
completed = false;
|
|
2298
|
+
toolCalls = /* @__PURE__ */ new Map();
|
|
2299
|
+
/** Fold every complete NDJSON line in the chunk; the tail stays buffered. */
|
|
2300
|
+
push(chunk) {
|
|
2301
|
+
this.buffer += chunk;
|
|
2302
|
+
const parts = this.buffer.split("\n");
|
|
2303
|
+
this.buffer = parts.pop() ?? "";
|
|
2304
|
+
for (const raw of parts) foldCodexStreamLine(this, raw);
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
/**
|
|
2308
|
+
* Parse a `codex exec --json` NDJSON event stream into an ordered transcript
|
|
2309
|
+
* (thinking, agent text, tool/command activity in event order), plus the final
|
|
2310
|
+
* answer text, token usage, the thread id a later resume round continues, and
|
|
2311
|
+
* the model identifier the stream named (usually absent — codex 0.144.0 does
|
|
2312
|
+
* not put one on the wire; the settle path then reads the rollout's
|
|
2313
|
+
* turn_context). Item types covered: `reasoning` (thinking), `agent_message`
|
|
2314
|
+
* (reply text), `command_execution` (shell command with aggregated output),
|
|
2315
|
+
* `web_search_call`, and `function_call_output`. The last `agent_message`
|
|
2316
|
+
* wins as the run output (the final answer), and `turn.completed` carries the
|
|
2317
|
+
* turn's usage. Malformed lines are skipped.
|
|
2318
|
+
* @param stream - the collected stdout NDJSON text.
|
|
2319
|
+
* @returns the ordered transcript, final answer text, usage, thread id, and
|
|
2320
|
+
* any stream-named model.
|
|
2321
|
+
*/
|
|
2322
|
+
function parseCodexJsonStream(stream) {
|
|
2323
|
+
const state = {
|
|
2324
|
+
lines: [],
|
|
2325
|
+
text: void 0,
|
|
2326
|
+
usage: void 0,
|
|
2327
|
+
threadId: void 0,
|
|
2328
|
+
model: void 0,
|
|
2329
|
+
completed: false,
|
|
2330
|
+
toolCalls: /* @__PURE__ */ new Map()
|
|
2331
|
+
};
|
|
2332
|
+
for (const raw of stream.split("\n")) foldCodexStreamLine(state, raw);
|
|
2333
|
+
const toolCalls = toolCallsOf(state.toolCalls);
|
|
2334
|
+
return {
|
|
2335
|
+
lines: state.lines,
|
|
2336
|
+
...state.text === void 0 ? {} : { text: state.text },
|
|
2337
|
+
...state.usage === void 0 ? {} : { usage: state.usage },
|
|
2338
|
+
...state.threadId === void 0 ? {} : { threadId: state.threadId },
|
|
2339
|
+
...state.model === void 0 ? {} : { model: state.model },
|
|
2340
|
+
...toolCalls === void 0 ? {} : { toolCalls }
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Start the real `codex exec` child and publish its run. The codex
|
|
2345
|
+
* reply arrives as an NDJSON event stream on stdout (`--json`): the final
|
|
2346
|
+
* `agent_message` item is the run output and the `turn.completed` usage is
|
|
2347
|
+
* the token accounting. stderr is piped for diagnostics and never folded
|
|
2348
|
+
* into the run output. A resume round spawns `codex exec --json resume
|
|
2349
|
+
* <thread_id>` instead, continuing the same thread. The run id is the child
|
|
2350
|
+
* session id for a session-backed run.
|
|
2351
|
+
* @param request - resolved shared subagent request.
|
|
2352
|
+
* @param spec - workspace, environment, process service, and diagnostic policy.
|
|
2353
|
+
* @returns the published run after the child starts.
|
|
2354
|
+
*/
|
|
2355
|
+
function startCodexCliRun(request, spec) {
|
|
2356
|
+
const task = textTask(request.prompt);
|
|
2357
|
+
if (request.signal.aborted) throw new Error("subagent-codex: request was aborted before the CLI started");
|
|
2358
|
+
const turn = spec.resume?.turn ?? 1;
|
|
2359
|
+
const memberArgv = spec.member === void 0 ? [] : ["-c", spec.member.configOverride];
|
|
2360
|
+
const modelArgv = [...spec.model === void 0 ? [] : ["-m", spec.model], ...spec.effort === void 0 ? [] : ["-c", `model_reasoning_effort=${JSON.stringify(spec.effort)}`]];
|
|
2361
|
+
const argv = spec.resume === void 0 ? [
|
|
2362
|
+
"codex",
|
|
2363
|
+
"exec",
|
|
2364
|
+
...memberArgv,
|
|
2365
|
+
...modelArgv,
|
|
2366
|
+
"--sandbox",
|
|
2367
|
+
spec.sandbox,
|
|
2368
|
+
"--skip-git-repo-check",
|
|
2369
|
+
"--json",
|
|
2370
|
+
task
|
|
2371
|
+
] : [
|
|
2372
|
+
"codex",
|
|
2373
|
+
"exec",
|
|
2374
|
+
...memberArgv,
|
|
2375
|
+
...modelArgv,
|
|
2376
|
+
"--sandbox",
|
|
2377
|
+
spec.sandbox,
|
|
2378
|
+
"--skip-git-repo-check",
|
|
2379
|
+
"--json",
|
|
2380
|
+
"resume",
|
|
2381
|
+
spec.resume.cliSessionId,
|
|
2382
|
+
task
|
|
2383
|
+
];
|
|
2384
|
+
const launch = spec.exec === void 0 ? {
|
|
2385
|
+
argv,
|
|
2386
|
+
env: spec.env
|
|
2387
|
+
} : containerExecSpawn(spec.exec, {
|
|
2388
|
+
argv,
|
|
2389
|
+
env: spec.env
|
|
2390
|
+
}, "subagent-codex");
|
|
2391
|
+
const child = spec.spawn({
|
|
2392
|
+
argv: launch.argv,
|
|
2393
|
+
cwd: spec.cwd,
|
|
2394
|
+
stdio: {
|
|
2395
|
+
stdin: "ignore",
|
|
2396
|
+
stdout: "pipe",
|
|
2397
|
+
stderr: "pipe"
|
|
2398
|
+
},
|
|
2399
|
+
graceMs: spec.disposeGraceMs,
|
|
2400
|
+
env: launch.env
|
|
2401
|
+
});
|
|
2402
|
+
const startedAtMs = Date.now();
|
|
2403
|
+
spec.childSession?.append("turn/start", { turn });
|
|
2404
|
+
const liveMirror = spec.childSession !== void 0 && spec.ctx !== void 0 ? createCodexLiveMirror(spec, task, turn) : void 0;
|
|
2405
|
+
let output = "";
|
|
2406
|
+
child.stdout?.on("data", (chunk) => {
|
|
2407
|
+
const text = chunk.toString();
|
|
2408
|
+
output += text;
|
|
2409
|
+
liveMirror?.push(text);
|
|
2410
|
+
});
|
|
2411
|
+
let stderr = "";
|
|
2412
|
+
child.stderr?.on("data", (chunk) => {
|
|
2413
|
+
stderr += chunk.toString();
|
|
2414
|
+
});
|
|
2415
|
+
const disposeProcess = async () => {
|
|
2416
|
+
child.terminate();
|
|
2417
|
+
await child.waitForExit().catch(() => false);
|
|
2418
|
+
await child.done.catch(() => {});
|
|
2419
|
+
};
|
|
2420
|
+
const runAbort = new AbortController();
|
|
2421
|
+
const requestCancel = () => {
|
|
2422
|
+
if (runAbort.signal.aborted) return;
|
|
2423
|
+
runAbort.abort(/* @__PURE__ */ new Error("subagent-codex: run cancelled locally"));
|
|
2424
|
+
};
|
|
2425
|
+
const onAbort = () => {
|
|
2426
|
+
requestCancel();
|
|
2427
|
+
};
|
|
2428
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
2429
|
+
const abortBranch = new Promise((_, reject) => {
|
|
2430
|
+
runAbort.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("subagent-codex: run cancelled locally")), { once: true });
|
|
2431
|
+
});
|
|
2432
|
+
const processFailure = child.done.then((outcome) => Promise.reject(/* @__PURE__ */ new Error(`subagent-codex: CLI exited before the run settled (code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
|
|
2433
|
+
processFailure.catch(() => {});
|
|
2434
|
+
const collectOutput = () => {
|
|
2435
|
+
const drained = child.collected.stdout?.readFrom(0).text;
|
|
2436
|
+
const text = parseCodexJsonStream(drained !== void 0 && drained !== "" ? drained : output).text?.trim();
|
|
2437
|
+
return text === void 0 || text === "" ? [] : [{
|
|
2438
|
+
type: "text",
|
|
2439
|
+
text
|
|
2440
|
+
}];
|
|
2441
|
+
};
|
|
2442
|
+
let exitCode = null;
|
|
2443
|
+
/** The flattened failure's own message, snapshotted for the diagnostic. */
|
|
2444
|
+
let settleFailure;
|
|
2445
|
+
const result = settleRunResult({
|
|
2446
|
+
attempt: () => Promise.race([
|
|
2447
|
+
child.done.then((outcome) => {
|
|
2448
|
+
if (outcome.exitCode !== 0) {
|
|
2449
|
+
const via = spec.endpointLabel ?? "codex default endpoint";
|
|
2450
|
+
exitCode = outcome.exitCode;
|
|
2451
|
+
throw new Error(`subagent-codex: codex exec exited with code ${String(outcome.exitCode)} via ${via}`);
|
|
2452
|
+
}
|
|
2453
|
+
if (parseCodexJsonStream(output).lines.length === 0) {
|
|
2454
|
+
spec.onError?.(/* @__PURE__ */ new Error("subagent-codex: exited 0 but parsed no codex events — check the codex --json event schema"), "error");
|
|
2455
|
+
throw new Error("subagent-codex: codex exec exited 0 but produced no answer");
|
|
2456
|
+
}
|
|
2457
|
+
const answer = collectOutput();
|
|
2458
|
+
if (answer.length === 0) throw new Error("subagent-codex: codex exec exited 0 but produced no answer");
|
|
2459
|
+
return {
|
|
2460
|
+
output: answer,
|
|
2461
|
+
stopReason: "completed"
|
|
2462
|
+
};
|
|
2463
|
+
}),
|
|
2464
|
+
processFailure,
|
|
2465
|
+
abortBranch
|
|
2466
|
+
]),
|
|
2467
|
+
collectOutput,
|
|
2468
|
+
collectDiagnostic: () => {
|
|
2469
|
+
const detail = [];
|
|
2470
|
+
if (settleFailure !== void 0) detail.push(settleFailure);
|
|
2471
|
+
const stderrTail = (child.collected.stderr?.readFrom(0).text || stderr).trim();
|
|
2472
|
+
if (stderrTail !== "") detail.push(`stderr: ${stderrTail.slice(-500)}`);
|
|
2473
|
+
const homeDir = spec.env["CODEX_HOME"];
|
|
2474
|
+
if (homeDir !== void 0 && homeDir !== "") {
|
|
2475
|
+
const threadId = parseCodexJsonStream(output).threadId;
|
|
2476
|
+
const when = new Date(startedAtMs);
|
|
2477
|
+
const dir = `${homeDir}/sessions/${String(when.getFullYear())}/${String(when.getMonth() + 1).padStart(2, "0")}/${String(when.getDate()).padStart(2, "0")}`;
|
|
2478
|
+
detail.push(threadId === void 0 ? `rollout 目录: ${dir}` : `rollout: ${dir}/rollout-*-${threadId}.jsonl`);
|
|
2479
|
+
}
|
|
2480
|
+
return detail.length === 0 ? void 0 : detail.join("\n");
|
|
2481
|
+
},
|
|
2482
|
+
cancelled: () => runAbort.signal.aborted,
|
|
2483
|
+
onError: (error, stopReason) => {
|
|
2484
|
+
settleFailure = error instanceof Error ? error.message : String(error);
|
|
2485
|
+
spec.onError?.(error instanceof Error ? error : new Error(String(error)), stopReason);
|
|
2486
|
+
},
|
|
2487
|
+
signal: request.signal,
|
|
2488
|
+
onAbort
|
|
2489
|
+
}).then((settled) => {
|
|
2490
|
+
if (settled.stopReason === "completed") spec.onAuthSuccess?.();
|
|
2491
|
+
if (spec.childSession !== void 0) {
|
|
2492
|
+
if (settled.stopReason === "completed") spec.childSession.append("turn/end", {
|
|
2493
|
+
turn,
|
|
2494
|
+
reason: { kind: "completed" }
|
|
2495
|
+
});
|
|
2496
|
+
else if (settled.stopReason === "aborted") spec.childSession.append("turn/end", {
|
|
2497
|
+
turn,
|
|
2498
|
+
reason: {
|
|
2499
|
+
kind: "aborted",
|
|
2500
|
+
reason: { kind: "parent" }
|
|
2501
|
+
}
|
|
2502
|
+
});
|
|
2503
|
+
else spec.childSession.append("turn/end", {
|
|
2504
|
+
turn,
|
|
2505
|
+
reason: {
|
|
2506
|
+
kind: "error",
|
|
2507
|
+
error: {
|
|
2508
|
+
message: `codex exec exited with code ${String(exitCode)}`,
|
|
2509
|
+
code: "UNKNOWN"
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
return settled;
|
|
2515
|
+
});
|
|
2516
|
+
result.then(() => child.done).then(() => {
|
|
2517
|
+
if (spec.onAuthFailure !== void 0) {
|
|
2518
|
+
const authDetail = `${child.collected.stderr?.readFrom(0).text || stderr}\n${child.collected.stdout?.readFrom(0).text || output}`;
|
|
2519
|
+
if (CODEX_AUTH_FAILURE.test(authDetail)) spec.onAuthFailure(authDetail.split("\n").find((line) => line.trim() !== "") ?? "auth failure");
|
|
2520
|
+
}
|
|
2521
|
+
return mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
|
|
2522
|
+
}, () => {});
|
|
2523
|
+
return Promise.resolve(subprocessRunHandle({
|
|
2524
|
+
id: spec.childSession?.id ?? SessionId(randomUUID()),
|
|
2525
|
+
result,
|
|
2526
|
+
signal: request.signal,
|
|
2527
|
+
onAbort,
|
|
2528
|
+
requestCancel,
|
|
2529
|
+
teardown: disposeProcess
|
|
2530
|
+
}));
|
|
2531
|
+
}
|
|
2532
|
+
/** One assistant-role message event, attributed to the codex route. */
|
|
2533
|
+
function codexAssistantEvent(blocks) {
|
|
2534
|
+
return createAssistantMessage({
|
|
2535
|
+
content: blocks,
|
|
2536
|
+
source: {
|
|
2537
|
+
provider: "codex-local",
|
|
2538
|
+
model: "codex"
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Fold one transcript line into the child session as one assistant step,
|
|
2544
|
+
* wrapped in the step/start–step/end boundary pair the live conversation
|
|
2545
|
+
* assembler requires: without a boundary the step never enters the location
|
|
2546
|
+
* index, the line's assistant message resolves to a turn-level location, and
|
|
2547
|
+
* the real-time view renders nothing (only a full rebuild, which mints step
|
|
2548
|
+
* drafts from the explicit coordinates, recovers it). Tool cards escape that
|
|
2549
|
+
* fate only because their definition does not consult the step location.
|
|
2550
|
+
* @returns whether the line folded (false when the options skipped it).
|
|
2551
|
+
*/
|
|
2552
|
+
function appendCodexTranscriptLine(childSession, turn, step, line, usage) {
|
|
2553
|
+
childSession.append("step/start", {
|
|
2554
|
+
turn,
|
|
2555
|
+
step
|
|
2556
|
+
});
|
|
2557
|
+
if (line.kind === "tool") {
|
|
2558
|
+
const call = childSession.append("tool/call", {
|
|
2559
|
+
turn,
|
|
2560
|
+
step,
|
|
2561
|
+
callId: line.id,
|
|
2562
|
+
name: line.name,
|
|
2563
|
+
arguments: line.args ?? ""
|
|
2564
|
+
});
|
|
2565
|
+
if (line.result !== void 0) childSession.append("tool/result", {
|
|
2566
|
+
turn,
|
|
2567
|
+
step,
|
|
2568
|
+
message: createToolResultMessage({
|
|
2569
|
+
callId: line.id,
|
|
2570
|
+
content: [{
|
|
2571
|
+
type: "text",
|
|
2572
|
+
text: line.result
|
|
2573
|
+
}],
|
|
2574
|
+
isError: false
|
|
2575
|
+
})
|
|
2576
|
+
}, {
|
|
2577
|
+
surfaceOp: "append",
|
|
2578
|
+
sourceEventSeqs: [call.seq]
|
|
2579
|
+
});
|
|
2580
|
+
childSession.append("step/end", {
|
|
2581
|
+
turn,
|
|
2582
|
+
step
|
|
2583
|
+
});
|
|
2584
|
+
return true;
|
|
2585
|
+
}
|
|
2586
|
+
const blocks = line.kind === "think" ? [{
|
|
2587
|
+
type: "reasoning",
|
|
2588
|
+
text: line.text
|
|
2589
|
+
}] : [{
|
|
2590
|
+
type: "text",
|
|
2591
|
+
text: line.text
|
|
2592
|
+
}];
|
|
2593
|
+
childSession.append("assistant/message", {
|
|
2594
|
+
turn,
|
|
2595
|
+
step,
|
|
2596
|
+
message: codexAssistantEvent(blocks),
|
|
2597
|
+
stream: [],
|
|
2598
|
+
...usage === void 0 ? {} : { usage }
|
|
2599
|
+
}, { surfaceOp: "append" });
|
|
2600
|
+
childSession.append("step/end", {
|
|
2601
|
+
turn,
|
|
2602
|
+
step
|
|
2603
|
+
});
|
|
2604
|
+
return true;
|
|
2605
|
+
}
|
|
2606
|
+
/** Fold one transcript line into the run's child session as one assistant step. */
|
|
2607
|
+
function appendCodexLine(spec, turn, step, line, usage) {
|
|
2608
|
+
if (spec.childSession === void 0) return;
|
|
2609
|
+
appendCodexTranscriptLine(spec.childSession, turn, step, line, usage);
|
|
2610
|
+
}
|
|
2611
|
+
/** The delta-progress text for one transcript line. */
|
|
2612
|
+
function codexLineText(line) {
|
|
2613
|
+
return line.kind === "tool" ? `[工具 ${line.name}]${line.args !== void 0 ? ` ${line.args}` : ""}${line.result !== void 0 ? `\n${line.result}` : ""}` : line.text;
|
|
2614
|
+
}
|
|
2615
|
+
/** Create the per-run live mirror over the incremental parser. */
|
|
2616
|
+
function createCodexLiveMirror(spec, task, turn) {
|
|
2617
|
+
const parser = new CodexStreamParser();
|
|
2618
|
+
const childSession = spec.childSession;
|
|
2619
|
+
const ctx = spec.ctx;
|
|
2620
|
+
let mirrored = 0;
|
|
2621
|
+
let userMirrored = false;
|
|
2622
|
+
let queue = Promise.resolve();
|
|
2623
|
+
const mirror = {
|
|
2624
|
+
get mirroredLines() {
|
|
2625
|
+
return mirrored;
|
|
2626
|
+
},
|
|
2627
|
+
get userMirrored() {
|
|
2628
|
+
return userMirrored;
|
|
2629
|
+
},
|
|
2630
|
+
enqueue(task) {
|
|
2631
|
+
queue = queue.then(task);
|
|
2632
|
+
return queue;
|
|
2633
|
+
},
|
|
2634
|
+
push(chunk) {
|
|
2635
|
+
parser.push(chunk);
|
|
2636
|
+
const upto = parser.completed ? parser.lines.length : parser.lines.length - 1;
|
|
2637
|
+
if (upto <= mirrored) return;
|
|
2638
|
+
mirror.enqueue(async () => {
|
|
2639
|
+
try {
|
|
2640
|
+
const localAgent = ctx.get("localAgent");
|
|
2641
|
+
if (!userMirrored) {
|
|
2642
|
+
userMirrored = true;
|
|
2643
|
+
childSession.append("user/message", createUserMessage({
|
|
2644
|
+
content: [{
|
|
2645
|
+
type: "text",
|
|
2646
|
+
text: task
|
|
2647
|
+
}],
|
|
2648
|
+
source: { kind: "user" }
|
|
2649
|
+
}), { surfaceOp: "append" });
|
|
2650
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2651
|
+
kind: "delta",
|
|
2652
|
+
text: task
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
let carrier = -1;
|
|
2656
|
+
if (parser.completed && parser.usage !== void 0) {
|
|
2657
|
+
for (let scan = 0; scan < parser.lines.length; scan += 1) if (parser.lines[scan]?.kind !== "tool") carrier = scan;
|
|
2658
|
+
}
|
|
2659
|
+
for (let index = mirrored; index < upto; index += 1) {
|
|
2660
|
+
const line = parser.lines[index];
|
|
2661
|
+
if (line === void 0) continue;
|
|
2662
|
+
const usage = index === carrier ? parser.usage : void 0;
|
|
2663
|
+
appendCodexLine(spec, turn, index + 1, line, usage);
|
|
2664
|
+
mirrored = index + 1;
|
|
2665
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2666
|
+
kind: "delta",
|
|
2667
|
+
text: codexLineText(line)
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
await persistChildSession(ctx, childSession);
|
|
2671
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2672
|
+
kind: "mirror",
|
|
2673
|
+
mirroredLines: mirrored
|
|
2674
|
+
});
|
|
2675
|
+
} catch (error) {
|
|
2676
|
+
ctx.logger.warn(`subagent-codex: live mirror failed: ${thrown(error).message}`);
|
|
2677
|
+
}
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
};
|
|
2681
|
+
return mirror;
|
|
2682
|
+
}
|
|
2683
|
+
/**
|
|
2684
|
+
* Mirror the delegation's user prompt and the codex event transcript into the
|
|
2685
|
+
* dsh subagent session, then persist. Runs detached from the settle race — the
|
|
2686
|
+
* run result settles with the child exit, and a failure here is
|
|
2687
|
+
* diagnostic-only (the subagent record degrades to a final-text-only view).
|
|
2688
|
+
* The transcript's reasoning folds to `reasoning` blocks, agent text and tool
|
|
2689
|
+
* activity to text blocks (tool lines render `[工具 Bash] <command> → output`),
|
|
2690
|
+
* and the final assistant message carries the round's usage when codex
|
|
2691
|
+
* reported it, so the tokenUsage projection counts the delegation.
|
|
2692
|
+
* @param spec - the run spec carrying the child session and host context.
|
|
2693
|
+
* @param task - the one-shot task text (the user prompt).
|
|
2694
|
+
* @param turn - the round's turn number (1 for a fresh round, incremented on resume).
|
|
2695
|
+
* @param parsed - the parsed NDJSON transcript, final output, and usage.
|
|
2696
|
+
* @param fromLines - transcript lines the live mirror already appended.
|
|
2697
|
+
* @param userMirrored - whether the live mirror already appended the prompt.
|
|
2698
|
+
*/
|
|
2699
|
+
async function appendCodexResponse(spec, task, turn, parsed, fromLines = 0, userMirrored = false) {
|
|
2700
|
+
if (spec.childSession === void 0 || spec.ctx === void 0) return;
|
|
2701
|
+
const childSession = spec.childSession;
|
|
2702
|
+
const localAgent = spec.ctx.get("localAgent");
|
|
2703
|
+
if (!userMirrored) {
|
|
2704
|
+
childSession.append("user/message", createUserMessage({
|
|
2705
|
+
content: [{
|
|
2706
|
+
type: "text",
|
|
2707
|
+
text: task
|
|
2708
|
+
}],
|
|
2709
|
+
source: { kind: "user" }
|
|
2710
|
+
}), { surfaceOp: "append" });
|
|
2711
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2712
|
+
kind: "delta",
|
|
2713
|
+
text: task
|
|
2714
|
+
});
|
|
2715
|
+
}
|
|
2716
|
+
let usageIndex = -1;
|
|
2717
|
+
for (let index = 0; index < parsed.lines.length; index += 1) if (parsed.lines[index]?.kind !== "tool") usageIndex = index;
|
|
2718
|
+
let step = fromLines + 1;
|
|
2719
|
+
for (const line of parsed.lines.slice(fromLines)) {
|
|
2720
|
+
appendCodexLine(spec, turn, step, line, step - 1 === usageIndex ? parsed.usage : void 0);
|
|
2721
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2722
|
+
kind: "delta",
|
|
2723
|
+
text: codexLineText(line)
|
|
2724
|
+
});
|
|
2725
|
+
step += 1;
|
|
2726
|
+
}
|
|
2727
|
+
if (parsed.usage !== void 0 && usageIndex !== -1 && usageIndex < fromLines) spec.ctx?.logger.warn(`subagent-codex: round ${turn} usage arrived after its carrier line was mirrored; the accounting is dropped`);
|
|
2728
|
+
await persistChildSession(spec.ctx, childSession);
|
|
2729
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2730
|
+
kind: "mirror",
|
|
2731
|
+
mirroredLines: parsed.lines.length
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Mirror the codex NDJSON stream into the child session AFTER the CLI process
|
|
2736
|
+
* has exited, whatever its stop reason. A cancelled or failed round still
|
|
2737
|
+
* preserves the events the stream already emitted (reasoning, commands,
|
|
2738
|
+
* replies, usage) instead of leaving the child blank — the run result settles
|
|
2739
|
+
* 'aborted'/'error' at the cancel moment, but codex may have produced content
|
|
2740
|
+
* before the kill landed. Also records the thread id (fresh rounds) so a
|
|
2741
|
+
* later resume can continue the partial thread.
|
|
2742
|
+
*
|
|
2743
|
+
* **Usage recovery**: a non-completed terminal state never emits
|
|
2744
|
+
* `turn.completed`, so the parsed stream carries no usage even though codex
|
|
2745
|
+
* wrote the run's real token spend to its rollout file. When the stream's
|
|
2746
|
+
* usage is absent, the mirror falls back to the run's rollout file — located
|
|
2747
|
+
* by the thread id (the `session_meta` head id) or by the spawn-time window —
|
|
2748
|
+
* and attaches the file's LAST `token_count` entry (same
|
|
2749
|
+
* `input − cached` caliber as `turn.completed`, via the shared
|
|
2750
|
+
* {@link usageFromCodex}), so a killed or failed run still books its tokens.
|
|
2751
|
+
* The fallback is best-effort and silent: no rollout file, an unreadable
|
|
2752
|
+
* home, or a hard kill that wrote no token_count leaves the child without
|
|
2753
|
+
* usage, exactly as before.
|
|
2754
|
+
*
|
|
2755
|
+
* **Model observation**: the exec --json wire carries no model field (codex
|
|
2756
|
+
* 0.144.0), so the run's model identifier is read from the located rollout
|
|
2757
|
+
* file's LAST `turn_context` line whose timestamp falls inside this run's
|
|
2758
|
+
* window — the turn codex actually started for this round. When the stream
|
|
2759
|
+
* itself names a model (a future CLI), the stream wins. Absent on any miss;
|
|
2760
|
+
* the observation rides {@link CodexCliRunSpec.onRoundSettled} along with the
|
|
2761
|
+
* round's usage.
|
|
2762
|
+
* @param spec - the run spec carrying the child session and host context.
|
|
2763
|
+
* @param task - the one-shot task text (the user prompt).
|
|
2764
|
+
* @param turn - the round's turn number.
|
|
2765
|
+
* @param output - the collected NDJSON stdout.
|
|
2766
|
+
* @param startedAtMs - the spawn moment, anchoring the rollout time window.
|
|
2767
|
+
*/
|
|
2768
|
+
async function mirrorCodexAfterExit(spec, task, turn, output, live, startedAtMs) {
|
|
2769
|
+
if (spec.childSession === void 0 || spec.ctx === void 0) return;
|
|
2770
|
+
const work = async () => {
|
|
2771
|
+
const parsed = parseCodexJsonStream(output);
|
|
2772
|
+
if (spec.resume === void 0) spec.onThreadId?.(parsed.threadId);
|
|
2773
|
+
const fromLines = live?.mirroredLines ?? 0;
|
|
2774
|
+
const userMirrored = live?.userMirrored ?? false;
|
|
2775
|
+
let usage = parsed.usage;
|
|
2776
|
+
let observedModel = parsed.model;
|
|
2777
|
+
let cliVersion;
|
|
2778
|
+
const homeDir = spec.env["CODEX_HOME"];
|
|
2779
|
+
if (homeDir !== void 0 && homeDir !== "") {
|
|
2780
|
+
const facts = await codexRolloutRoundFacts(homeDir, {
|
|
2781
|
+
threadId: parsed.threadId,
|
|
2782
|
+
windowStart: startedAtMs,
|
|
2783
|
+
cwd: spec.cwd
|
|
2784
|
+
});
|
|
2785
|
+
usage ??= facts.usage;
|
|
2786
|
+
observedModel ??= facts.model;
|
|
2787
|
+
cliVersion = facts.cliVersion;
|
|
2788
|
+
}
|
|
2789
|
+
cliVersion ??= await spec.cliVersion?.();
|
|
2790
|
+
spec.onRoundSettled?.({
|
|
2791
|
+
...observedModel === void 0 ? {} : { observedModel },
|
|
2792
|
+
...cliVersion === void 0 ? {} : { cliVersion },
|
|
2793
|
+
...usage === void 0 ? {} : { usage },
|
|
2794
|
+
...parsed.toolCalls === void 0 ? {} : { toolCalls: parsed.toolCalls }
|
|
2795
|
+
});
|
|
2796
|
+
if (parsed.lines.length === 0 && !userMirrored) return;
|
|
2797
|
+
await appendCodexResponse(spec, task, turn, {
|
|
2798
|
+
lines: parsed.lines,
|
|
2799
|
+
output: collectOutputBlocks(parsed.text),
|
|
2800
|
+
...usage === void 0 ? {} : { usage }
|
|
2801
|
+
}, fromLines, userMirrored);
|
|
2802
|
+
};
|
|
2803
|
+
try {
|
|
2804
|
+
if (live === void 0) await work();
|
|
2805
|
+
else await live.enqueue(work);
|
|
2806
|
+
} catch (error) {
|
|
2807
|
+
spec.onError?.(thrown(error), "error");
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
/** Build the run-output blocks from the final codex answer text. */
|
|
2811
|
+
function collectOutputBlocks(text) {
|
|
2812
|
+
const trimmed = text?.trim();
|
|
2813
|
+
return trimmed === void 0 || trimmed === "" ? [] : [{
|
|
2814
|
+
type: "text",
|
|
2815
|
+
text: trimmed
|
|
2816
|
+
}];
|
|
2817
|
+
}
|
|
2818
|
+
//#endregion
|
|
2819
|
+
//#region lib/types/live-switch.js
|
|
2820
|
+
/**
|
|
2821
|
+
* The settings-driven live-driver switch: owns the active driver generation
|
|
2822
|
+
* and retires old ones on a settings change, without a plugin reload.
|
|
2823
|
+
*
|
|
2824
|
+
* Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
|
|
2825
|
+
* new rounds are refused (the provider's catch falls back to exec), in-flight
|
|
2826
|
+
* rounds finish on their runtime, idle runtimes are reclaimed at once.
|
|
2827
|
+
* Toggling ON builds the next generation lazily (no process until the first
|
|
2828
|
+
* round). Legacy granularity settings are ignored.
|
|
2829
|
+
*
|
|
2830
|
+
* @module @khorsheed/dsh-local-agent-codex — internal, unit-tested directly.
|
|
2831
|
+
*/
|
|
2832
|
+
var LiveDriverSwitch = class {
|
|
2833
|
+
ctx;
|
|
2834
|
+
options;
|
|
2835
|
+
liveOn = false;
|
|
2836
|
+
active;
|
|
2837
|
+
/** Retiring generations still draining their in-flight rounds. */
|
|
2838
|
+
draining = /* @__PURE__ */ new Set();
|
|
2839
|
+
unwatch;
|
|
2840
|
+
constructor(ctx, face, options) {
|
|
2841
|
+
this.ctx = ctx;
|
|
2842
|
+
this.options = options;
|
|
2843
|
+
this.apply(face.get());
|
|
2844
|
+
this.unwatch = face.watch((next) => {
|
|
2845
|
+
this.apply(next);
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
/**
|
|
2849
|
+
* Resolve the live driver for one round's member (the provider's call
|
|
2850
|
+
* site). A retiring generation still hosting the member gates the new one —
|
|
2851
|
+
* the round falls back to exec rather than double-loading the same codex
|
|
2852
|
+
* thread into two processes.
|
|
2853
|
+
*/
|
|
2854
|
+
resolve = (childSessionId) => {
|
|
2855
|
+
if (this.active === void 0) return void 0;
|
|
2856
|
+
for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return void 0;
|
|
2857
|
+
return this.active;
|
|
2858
|
+
};
|
|
2859
|
+
/**
|
|
2860
|
+
* The model the member's live runtime bound at spawn (the broker's switch
|
|
2861
|
+
* check): the identifier, undefined for "bound to no model", null for "no
|
|
2862
|
+
* live runtime". Consults only the ACTIVE generation — a draining one is on
|
|
2863
|
+
* its way out anyway.
|
|
2864
|
+
*/
|
|
2865
|
+
boundModel(childSessionId) {
|
|
2866
|
+
return this.active?.boundModelOf(childSessionId) ?? null;
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Retire the member's resident runtime in the active generation so the next
|
|
2870
|
+
* round respawns onto the member's new effective model (the composer's
|
|
2871
|
+
* model switch path; the CLI session itself carries over via thread/resume).
|
|
2872
|
+
*/
|
|
2873
|
+
async retireRuntime(childSessionId) {
|
|
2874
|
+
await this.active?.retireRuntime(childSessionId);
|
|
2875
|
+
}
|
|
2876
|
+
/** Mirror the resolved settings into the driver generation. */
|
|
2877
|
+
apply(next) {
|
|
2878
|
+
if (next.live === this.liveOn) return;
|
|
2879
|
+
this.liveOn = next.live;
|
|
2880
|
+
const retiring = this.active;
|
|
2881
|
+
this.active = next.live ? new CodexLiveDriver(this.ctx, {
|
|
2882
|
+
...this.options.sandbox === void 0 ? {} : { sandbox: this.options.sandbox },
|
|
2883
|
+
...this.options.liveIdleMs === void 0 ? {} : { liveIdleMs: this.options.liveIdleMs },
|
|
2884
|
+
...this.options.model === void 0 ? {} : { model: this.options.model }
|
|
2885
|
+
}) : void 0;
|
|
2886
|
+
if (retiring !== void 0) {
|
|
2887
|
+
this.draining.add(retiring);
|
|
2888
|
+
retiring.drain().then(() => {
|
|
2889
|
+
this.draining.delete(retiring);
|
|
2890
|
+
}, (error) => {
|
|
2891
|
+
this.draining.delete(retiring);
|
|
2892
|
+
this.ctx.logger.warn(`local-agent-codex: live driver drain failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
/** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
|
|
2897
|
+
dispose() {
|
|
2898
|
+
this.unwatch();
|
|
2899
|
+
this.active?.disposeAll();
|
|
2900
|
+
for (const retiring of this.draining) retiring.disposeAll();
|
|
2901
|
+
this.draining.clear();
|
|
2902
|
+
}
|
|
2903
|
+
};
|
|
2904
|
+
//#endregion
|
|
2905
|
+
//#region lib/types/model-broker.js
|
|
2906
|
+
/**
|
|
2907
|
+
* The codex harness's model broker: the member-aware model surface the core
|
|
2908
|
+
* gateway routes to (`harnessModel` for the settings card, `memberModel` /
|
|
2909
|
+
* `setMemberModel` for the composer picker). It owns the session-level
|
|
2910
|
+
* override map — shared with the exec provider (which consults it per round)
|
|
2911
|
+
* and the live driver (which binds it at runtime spawn) — and retires a
|
|
2912
|
+
* member's resident runtime when a switch leaves it bound to a different
|
|
2913
|
+
* model, so the NEXT round respawns onto the new one while the codex thread
|
|
2914
|
+
* itself carries over via thread/resume.
|
|
2915
|
+
*
|
|
2916
|
+
* Resolution order is the family's fixed one (first match wins): override →
|
|
2917
|
+
* the delegation's recorded model → the plugin-config `model` (settings) →
|
|
2918
|
+
* the scoped config.toml's own `model` (cliDefault) → the account catalog's
|
|
2919
|
+
* `isDefault` slug (catalogDefault) → the CLI's built-in default, which names
|
|
2920
|
+
* nothing. The catalogDefault layer is the ONE case source `cli-builtin`
|
|
2921
|
+
* carries an `effective` model: the app-server probe marks the account's
|
|
2922
|
+
* compiled default with `isDefault`, so "follow the CLI default" finally has
|
|
2923
|
+
* a name. The `cliDefault` FIELD stays scoped-config-only — the two layers
|
|
2924
|
+
* must stay distinguishable (the file is user-editable, the catalog answer is
|
|
2925
|
+
* the account's). The pickable vocabulary is what the instance actually
|
|
2926
|
+
* knows — settings + cliDefault + the identifiers the scoped config itself
|
|
2927
|
+
* carries (profiles) + the app-server `model/list` account catalog (probed,
|
|
2928
|
+
* cached, best-effort — see model-catalog.ts) + the card's recent-model
|
|
2929
|
+
* memory — never a hardcoded catalog.
|
|
2930
|
+
* @module @khorsheed/dsh-local-agent-codex/model-broker
|
|
2931
|
+
*/
|
|
2932
|
+
/** Dedupe the choice vocabulary, dropping unset layers and blanks. */
|
|
2933
|
+
function dedupeChoices(layers) {
|
|
2934
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2935
|
+
const choices = [];
|
|
2936
|
+
for (const layer of layers) {
|
|
2937
|
+
const value = layer?.trim();
|
|
2938
|
+
if (value === void 0 || value === "" || seen.has(value)) continue;
|
|
2939
|
+
seen.add(value);
|
|
2940
|
+
choices.push(value);
|
|
2941
|
+
}
|
|
2942
|
+
return choices;
|
|
2943
|
+
}
|
|
2944
|
+
var CodexModelBroker = class {
|
|
2945
|
+
deps;
|
|
2946
|
+
constructor(deps) {
|
|
2947
|
+
this.deps = deps;
|
|
2948
|
+
}
|
|
2949
|
+
configurationAdapter(binding) {
|
|
2950
|
+
const home = this.deps.ctx.localAgent.homeDir("codex", binding.scope);
|
|
2951
|
+
const resolve = async (selection) => {
|
|
2952
|
+
let directory = this.deps.directory?.(home, binding.cwd);
|
|
2953
|
+
if (directory?.status === "loading") {
|
|
2954
|
+
await this.deps.refreshDirectory?.(home, binding.cwd);
|
|
2955
|
+
directory = this.deps.directory?.(home, binding.cwd);
|
|
2956
|
+
}
|
|
2957
|
+
const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel() ?? await readCodexModel(home) ?? this.deps.catalogDefault(home, binding.cwd);
|
|
2958
|
+
const entry = directory?.entries.find((entry) => entry.value === model);
|
|
2959
|
+
const explicitEffort = selection.effort.mode === "value" ? selection.effort.value : selection.effort.mode === "inherit" ? binding.effort : void 0;
|
|
2960
|
+
const effort = explicitEffort ?? await readCodexReasoningEffort(home) ?? entry?.reasoning?.default;
|
|
2961
|
+
if (explicitEffort !== void 0 && !entry?.reasoning?.options.some((option) => option.value === explicitEffort)) throw new Error("Codex has not advertised this reasoning effort for the selected model; refresh its native directory");
|
|
2962
|
+
return {
|
|
2963
|
+
...model === void 0 ? {} : { model },
|
|
2964
|
+
...effort === void 0 ? {} : { effort }
|
|
2965
|
+
};
|
|
2966
|
+
};
|
|
2967
|
+
return {
|
|
2968
|
+
validate: async (selection) => {
|
|
2969
|
+
await resolve(selection);
|
|
2970
|
+
},
|
|
2971
|
+
prepare: resolve,
|
|
2972
|
+
apply: async (selection) => {
|
|
2973
|
+
const resolved = await resolve(selection);
|
|
2974
|
+
await this.deps.retireRuntime(binding.childSessionId);
|
|
2975
|
+
return resolved;
|
|
2976
|
+
},
|
|
2977
|
+
reconcile: async (state) => {
|
|
2978
|
+
if (this.activeDelegations().includes(binding.childSessionId)) return {
|
|
2979
|
+
active: true,
|
|
2980
|
+
matches: "unknown",
|
|
2981
|
+
resolved: {}
|
|
2982
|
+
};
|
|
2983
|
+
await this.deps.retireRuntime(binding.childSessionId);
|
|
2984
|
+
return {
|
|
2985
|
+
active: false,
|
|
2986
|
+
matches: "current",
|
|
2987
|
+
resolved: await resolve(state.current.selection)
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
async modelDirectory(childSessionId, refresh = false) {
|
|
2993
|
+
const home = this.deps.homeDir(childSessionId);
|
|
2994
|
+
if (refresh) await this.deps.refreshDirectory?.(home, this.deps.cwd?.(childSessionId));
|
|
2995
|
+
return (await this.modelInfo(childSessionId, childSessionId === void 0 ? void 0 : this.deps.ctx.localAgent.getDelegation(childSessionId)?.model)).directory ?? {
|
|
2996
|
+
entries: [],
|
|
2997
|
+
complete: false,
|
|
2998
|
+
customInput: true,
|
|
2999
|
+
status: "unsupported",
|
|
3000
|
+
refreshing: false,
|
|
3001
|
+
revision: 0
|
|
3002
|
+
};
|
|
3003
|
+
}
|
|
3004
|
+
async *followModelDirectory(childSessionId, signal) {
|
|
3005
|
+
if (this.deps.followDirectory === void 0) {
|
|
3006
|
+
yield await this.modelDirectory(childSessionId);
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
for await (const _snapshot of this.deps.followDirectory(this.deps.homeDir(childSessionId), this.deps.cwd?.(childSessionId), signal)) yield await this.modelDirectory(childSessionId);
|
|
3010
|
+
}
|
|
3011
|
+
/** The member's in-flight rounds (an empty answer on a core that predates the read). */
|
|
3012
|
+
activeDelegations() {
|
|
3013
|
+
const registry = this.deps.ctx.localAgent;
|
|
3014
|
+
return typeof registry.activeDelegations === "function" ? registry.activeDelegations() : [];
|
|
3015
|
+
}
|
|
3016
|
+
/** The model the member's NEXT spawn binds explicitly: override → recorded → settings. */
|
|
3017
|
+
boundLayer(childSessionId) {
|
|
3018
|
+
const override = this.deps.overrides.get(childSessionId);
|
|
3019
|
+
if (override !== void 0) return override;
|
|
3020
|
+
const recorded = this.deps.ctx.localAgent.getDelegation(childSessionId)?.model?.trim();
|
|
3021
|
+
if (recorded !== void 0 && recorded !== "") return recorded;
|
|
3022
|
+
return this.deps.settingsModel();
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Read the model surface. The cliDefault and the discovered identifiers come
|
|
3026
|
+
* from the scoped config.toml LIVE (a person-edited file is authoritative);
|
|
3027
|
+
* a read failure degrades that layer to absent, never to a guessed value.
|
|
3028
|
+
* The account catalog comes from the probe cache SYNCHRONOUSLY (a cold
|
|
3029
|
+
* cache reads empty and re-probes in the background — the settings card
|
|
3030
|
+
* re-fetches on open, so it never blocks on a CLI boot).
|
|
3031
|
+
*/
|
|
3032
|
+
async modelInfo(childSessionId, delegationModel) {
|
|
3033
|
+
const settings = this.deps.settingsModel();
|
|
3034
|
+
const homeDir = this.deps.homeDir(childSessionId);
|
|
3035
|
+
const [cliDefault, discovered] = await Promise.all([readCodexModel(homeDir).catch(() => void 0), listCodexConfigModels(homeDir).catch(() => [])]);
|
|
3036
|
+
const override = childSessionId === void 0 ? void 0 : this.deps.overrides.get(childSessionId);
|
|
3037
|
+
const delegation = delegationModel?.trim() === "" ? void 0 : delegationModel;
|
|
3038
|
+
const catalogDefaultRaw = this.deps.catalogDefault(homeDir, this.deps.cwd?.(childSessionId))?.trim();
|
|
3039
|
+
const effective = override ?? delegation ?? settings ?? cliDefault ?? (catalogDefaultRaw === void 0 || catalogDefaultRaw === "" ? void 0 : catalogDefaultRaw);
|
|
3040
|
+
const source = override !== void 0 ? "override" : delegation !== void 0 ? "delegation" : settings !== void 0 ? "settings" : cliDefault !== void 0 ? "cli-config" : "cli-builtin";
|
|
3041
|
+
const inFlight = childSessionId !== void 0 && this.activeDelegations().includes(childSessionId);
|
|
3042
|
+
return {
|
|
3043
|
+
...effective === void 0 ? {} : { effective },
|
|
3044
|
+
source,
|
|
3045
|
+
...override === void 0 ? {} : { override },
|
|
3046
|
+
...delegation === void 0 ? {} : { delegation },
|
|
3047
|
+
...settings === void 0 ? {} : { settings },
|
|
3048
|
+
...cliDefault === void 0 ? {} : { cliDefault },
|
|
3049
|
+
...this.deps.directory === void 0 ? {} : { directory: extendModelDirectory(this.deps.directory(homeDir, this.deps.cwd?.(childSessionId)), [
|
|
3050
|
+
settings,
|
|
3051
|
+
cliDefault,
|
|
3052
|
+
...discovered
|
|
3053
|
+
], this.deps.recentModels()) },
|
|
3054
|
+
choices: dedupeChoices([
|
|
3055
|
+
settings,
|
|
3056
|
+
cliDefault,
|
|
3057
|
+
...discovered,
|
|
3058
|
+
...this.deps.catalog(homeDir, this.deps.cwd?.(childSessionId)),
|
|
3059
|
+
...this.deps.recentModels()
|
|
3060
|
+
]),
|
|
3061
|
+
live: this.deps.live(),
|
|
3062
|
+
switchable: !inFlight,
|
|
3063
|
+
...inFlight ? { reason: "成员有进行中的委派轮次,等其完成后再切换模型" } : {}
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* Set (or clear) the member's session-level override. Same-model is a
|
|
3068
|
+
* no-op; a different model with a live runtime bound elsewhere retires the
|
|
3069
|
+
* runtime so the next round respawns (the codex thread resumes). Throws
|
|
3070
|
+
* while a round is in flight — retiring mid-round would kill the run.
|
|
3071
|
+
*/
|
|
3072
|
+
async setMemberModel(childSessionId, model) {
|
|
3073
|
+
if (typeof this.deps.ctx.localAgent.setMemberModel === "function") return this.deps.ctx.localAgent.setMemberModel(childSessionId, model);
|
|
3074
|
+
if (this.activeDelegations().includes(childSessionId)) throw new Error(`subagent-codex: 成员有进行中的委派轮次,等其完成后再切换模型 (child session ${childSessionId})`);
|
|
3075
|
+
const trimmed = model?.trim();
|
|
3076
|
+
const next = trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
|
|
3077
|
+
if (this.deps.overrides.get(childSessionId) === next) return;
|
|
3078
|
+
if (next === void 0) this.deps.overrides.delete(childSessionId);
|
|
3079
|
+
else this.deps.overrides.set(childSessionId, next);
|
|
3080
|
+
const bound = this.deps.liveBoundModel(childSessionId);
|
|
3081
|
+
if (bound !== null && bound !== this.boundLayer(childSessionId)) await this.deps.retireRuntime(childSessionId);
|
|
3082
|
+
}
|
|
3083
|
+
};
|
|
3084
|
+
/** Grace between stdin EOF and terminate when the probe tears the process down. */
|
|
3085
|
+
const PROBE_EOF_GRACE_MS = 1e3;
|
|
3086
|
+
/** Preserve native metadata, including hidden candidates; reject malformed pages. */
|
|
3087
|
+
function catalogEntries(result) {
|
|
3088
|
+
const page = result;
|
|
3089
|
+
if (!Array.isArray(page?.data)) throw new Error("model/list returned no model array");
|
|
3090
|
+
const entries = [];
|
|
3091
|
+
let defaultModel;
|
|
3092
|
+
for (const entry of page.data) {
|
|
3093
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
3094
|
+
const record = entry;
|
|
3095
|
+
const raw = typeof record["id"] === "string" ? record["id"] : record["model"];
|
|
3096
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
3097
|
+
if (value === "") continue;
|
|
3098
|
+
if (record["isDefault"] === true && defaultModel === void 0) defaultModel = value;
|
|
3099
|
+
const nativeEfforts = record["supportedReasoningEfforts"];
|
|
3100
|
+
const options = Array.isArray(nativeEfforts) ? nativeEfforts.flatMap((option) => {
|
|
3101
|
+
if (typeof option !== "object" || option === null) return [];
|
|
3102
|
+
const effort = option;
|
|
3103
|
+
const choice = effort["reasoningEffort"];
|
|
3104
|
+
if (typeof choice !== "string" || choice === "") return [];
|
|
3105
|
+
return [{
|
|
3106
|
+
value: choice,
|
|
3107
|
+
label: choice,
|
|
3108
|
+
...typeof effort["description"] === "string" ? { description: effort["description"] } : {}
|
|
3109
|
+
}];
|
|
3110
|
+
}) : void 0;
|
|
3111
|
+
const defaultEffort = record["defaultReasoningEffort"];
|
|
3112
|
+
entries.push({
|
|
3113
|
+
value,
|
|
3114
|
+
label: typeof record["displayName"] === "string" ? record["displayName"] : value,
|
|
3115
|
+
source: "native",
|
|
3116
|
+
hidden: record["hidden"] === true,
|
|
3117
|
+
...typeof record["description"] === "string" ? { description: record["description"] } : {},
|
|
3118
|
+
...options === void 0 ? {} : { reasoning: {
|
|
3119
|
+
options,
|
|
3120
|
+
...typeof defaultEffort === "string" ? { default: defaultEffort } : {}
|
|
3121
|
+
} }
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
return {
|
|
3125
|
+
entries,
|
|
3126
|
+
...defaultModel === void 0 ? {} : { defaultModel },
|
|
3127
|
+
...typeof page.nextCursor === "string" && page.nextCursor !== "" ? { nextCursor: page.nextCursor } : {}
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
/** Synchronous compatibility reads plus a rich, subscribable directory. */
|
|
3131
|
+
var CodexModelCatalog = class {
|
|
3132
|
+
deps;
|
|
3133
|
+
timeoutMs;
|
|
3134
|
+
cache;
|
|
3135
|
+
constructor(deps) {
|
|
3136
|
+
this.deps = deps;
|
|
3137
|
+
this.timeoutMs = deps.timeoutMs ?? 5e3;
|
|
3138
|
+
this.cache = new ModelDirectoryCache({
|
|
3139
|
+
load: (key, signal) => {
|
|
3140
|
+
const context = JSON.parse(key);
|
|
3141
|
+
return this.probe(context.homeDir, context.cwd, signal);
|
|
3142
|
+
},
|
|
3143
|
+
ttlMs: deps.ttlMs ?? 3e5,
|
|
3144
|
+
...deps.now === void 0 ? {} : { now: deps.now },
|
|
3145
|
+
onError: (error) => deps.warn?.(`local-agent-codex: the model catalog probe degraded: ${error instanceof Error ? error.message : String(error)}`)
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
key(homeDir, cwd = homeDir) {
|
|
3149
|
+
const files = [join(homeDir, "config.toml"), join(homeDir, "auth.json")];
|
|
3150
|
+
for (let directory = cwd;; directory = dirname(directory)) {
|
|
3151
|
+
files.push(join(directory, ".codex", "config.toml"));
|
|
3152
|
+
if (dirname(directory) === directory) break;
|
|
3153
|
+
}
|
|
3154
|
+
return modelDirectoryContextKey({
|
|
3155
|
+
provider: "codex",
|
|
3156
|
+
homeDir,
|
|
3157
|
+
cwd,
|
|
3158
|
+
cli: ["codex"],
|
|
3159
|
+
files
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
read(homeDir, cwd) {
|
|
3163
|
+
return this.directory(homeDir, cwd).entries.filter((entry) => !entry.hidden).map((entry) => entry.value);
|
|
3164
|
+
}
|
|
3165
|
+
readDefault(homeDir, cwd) {
|
|
3166
|
+
return this.directory(homeDir, cwd).defaultModel;
|
|
3167
|
+
}
|
|
3168
|
+
directory(homeDir, cwd) {
|
|
3169
|
+
return this.cache.read(this.key(homeDir, cwd));
|
|
3170
|
+
}
|
|
3171
|
+
follow(homeDir, signal, cwd) {
|
|
3172
|
+
return this.cache.follow(() => this.key(homeDir, cwd), signal);
|
|
3173
|
+
}
|
|
3174
|
+
invalidate(homeDir, cwd) {
|
|
3175
|
+
this.cache.invalidate(this.key(homeDir, cwd));
|
|
3176
|
+
}
|
|
3177
|
+
dispose() {
|
|
3178
|
+
this.cache.dispose();
|
|
3179
|
+
}
|
|
3180
|
+
async refresh(homeDir, cwd) {
|
|
3181
|
+
return (await this.cache.refresh(this.key(homeDir, cwd))).entries.filter((entry) => !entry.hidden).map((entry) => entry.value);
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* One bounded probe: spawn the app-server against the scoped home, answer
|
|
3185
|
+
* the initialize handshake exactly as the live driver shapes it, ask
|
|
3186
|
+
* `model/list`, then tear the process down (stdin EOF → grace →
|
|
3187
|
+
* terminate). The overall timeout kills the process on expiry. Failures propagate to the shared cache, which preserves prior data.
|
|
3188
|
+
*/
|
|
3189
|
+
async probe(homeDir, cwd, signal) {
|
|
3190
|
+
const spec = {
|
|
3191
|
+
argv: [
|
|
3192
|
+
"codex",
|
|
3193
|
+
"app-server",
|
|
3194
|
+
"--stdio"
|
|
3195
|
+
],
|
|
3196
|
+
cwd,
|
|
3197
|
+
stdio: {
|
|
3198
|
+
stdin: "pipe",
|
|
3199
|
+
stdout: "pipe",
|
|
3200
|
+
stderr: "pipe"
|
|
3201
|
+
},
|
|
3202
|
+
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
3203
|
+
env: delegationEnv({ CODEX_HOME: homeDir })
|
|
3204
|
+
};
|
|
3205
|
+
const child = this.deps.spawn(spec);
|
|
3206
|
+
const decoder = new StringDecoder("utf8");
|
|
3207
|
+
let buffer = "";
|
|
3208
|
+
const pending = /* @__PURE__ */ new Map();
|
|
3209
|
+
let nextId = 0;
|
|
3210
|
+
let done = false;
|
|
3211
|
+
const finish = () => {
|
|
3212
|
+
if (done) return;
|
|
3213
|
+
done = true;
|
|
3214
|
+
for (const request of pending.values()) request.reject(/* @__PURE__ */ new Error("the model catalog probe closed"));
|
|
3215
|
+
pending.clear();
|
|
3216
|
+
};
|
|
3217
|
+
child.stdout?.on("data", (chunk) => {
|
|
3218
|
+
buffer += decoder.write(chunk);
|
|
3219
|
+
let index = buffer.indexOf("\n");
|
|
3220
|
+
while (index >= 0) {
|
|
3221
|
+
const line = buffer.slice(0, index);
|
|
3222
|
+
buffer = buffer.slice(index + 1);
|
|
3223
|
+
index = buffer.indexOf("\n");
|
|
3224
|
+
if (line.trim() === "") continue;
|
|
3225
|
+
let message;
|
|
3226
|
+
try {
|
|
3227
|
+
message = JSON.parse(line);
|
|
3228
|
+
} catch {
|
|
3229
|
+
continue;
|
|
3230
|
+
}
|
|
3231
|
+
const id = typeof message.id === "number" ? message.id : void 0;
|
|
3232
|
+
if (id !== void 0 && typeof message.method === "string") {
|
|
3233
|
+
child.stdin?.write(JSON.stringify({
|
|
3234
|
+
jsonrpc: "2.0",
|
|
3235
|
+
id,
|
|
3236
|
+
error: {
|
|
3237
|
+
code: -32601,
|
|
3238
|
+
message: "unsupported"
|
|
3239
|
+
}
|
|
3240
|
+
}) + "\n");
|
|
3241
|
+
continue;
|
|
3242
|
+
}
|
|
3243
|
+
if (id === void 0) continue;
|
|
3244
|
+
const request = pending.get(id);
|
|
3245
|
+
if (request === void 0) continue;
|
|
3246
|
+
pending.delete(id);
|
|
3247
|
+
const wireError = message.error;
|
|
3248
|
+
if (wireError !== void 0 && wireError !== null) request.reject(new Error(wireError.message ?? "wire error"));
|
|
3249
|
+
else request.resolve(message.result);
|
|
3250
|
+
}
|
|
3251
|
+
});
|
|
3252
|
+
child.done.then(finish, finish);
|
|
3253
|
+
const abort = () => {
|
|
3254
|
+
finish();
|
|
3255
|
+
child.terminate();
|
|
3256
|
+
};
|
|
3257
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
3258
|
+
if (signal.aborted) abort();
|
|
3259
|
+
const request = (method, params) => {
|
|
3260
|
+
if (done) return Promise.reject(/* @__PURE__ */ new Error("the model catalog probe closed"));
|
|
3261
|
+
nextId += 1;
|
|
3262
|
+
const id = nextId;
|
|
3263
|
+
return new Promise((resolve, reject) => {
|
|
3264
|
+
pending.set(id, {
|
|
3265
|
+
resolve: (result) => resolve(result),
|
|
3266
|
+
reject
|
|
3267
|
+
});
|
|
3268
|
+
child.stdin?.write(JSON.stringify({
|
|
3269
|
+
jsonrpc: "2.0",
|
|
3270
|
+
id,
|
|
3271
|
+
method,
|
|
3272
|
+
params
|
|
3273
|
+
}) + "\n");
|
|
3274
|
+
});
|
|
3275
|
+
};
|
|
3276
|
+
const teardown = async () => {
|
|
3277
|
+
finish();
|
|
3278
|
+
child.stdin?.end();
|
|
3279
|
+
await Promise.race([child.done.catch(() => {}), new Promise((resolve) => setTimeout(resolve, PROBE_EOF_GRACE_MS))]);
|
|
3280
|
+
child.terminate();
|
|
3281
|
+
await child.waitForExit().catch(() => {});
|
|
3282
|
+
};
|
|
3283
|
+
let timer;
|
|
3284
|
+
try {
|
|
3285
|
+
return await Promise.race([(async () => {
|
|
3286
|
+
await request("initialize", {
|
|
3287
|
+
clientInfo: {
|
|
3288
|
+
name: "dsh-local-agent-codex",
|
|
3289
|
+
title: "dsh local-agent codex model catalog probe",
|
|
3290
|
+
version: "0.1.0"
|
|
3291
|
+
},
|
|
3292
|
+
capabilities: {
|
|
3293
|
+
experimentalApi: false,
|
|
3294
|
+
requestAttestation: false
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
child.stdin?.write(JSON.stringify({
|
|
3298
|
+
jsonrpc: "2.0",
|
|
3299
|
+
method: "initialized"
|
|
3300
|
+
}) + "\n");
|
|
3301
|
+
const entries = /* @__PURE__ */ new Map();
|
|
3302
|
+
const cursors = /* @__PURE__ */ new Set();
|
|
3303
|
+
let cursor;
|
|
3304
|
+
let defaultModel;
|
|
3305
|
+
do {
|
|
3306
|
+
const page = catalogEntries(await request("model/list", {
|
|
3307
|
+
includeHidden: true,
|
|
3308
|
+
...cursor === void 0 ? {} : { cursor }
|
|
3309
|
+
}));
|
|
3310
|
+
for (const entry of page.entries) if (!entries.has(entry.value)) entries.set(entry.value, entry);
|
|
3311
|
+
defaultModel ??= page.defaultModel;
|
|
3312
|
+
cursor = page.nextCursor;
|
|
3313
|
+
if (cursor !== void 0) {
|
|
3314
|
+
if (cursors.has(cursor)) throw new Error("model/list repeated a pagination cursor");
|
|
3315
|
+
cursors.add(cursor);
|
|
3316
|
+
}
|
|
3317
|
+
} while (cursor !== void 0);
|
|
3318
|
+
return {
|
|
3319
|
+
entries: [...entries.values()],
|
|
3320
|
+
complete: true,
|
|
3321
|
+
customInput: true,
|
|
3322
|
+
...defaultModel === void 0 ? {} : { defaultModel }
|
|
3323
|
+
};
|
|
3324
|
+
})(), new Promise((_, reject) => {
|
|
3325
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("the model catalog probe timed out")), this.timeoutMs);
|
|
3326
|
+
timer.unref();
|
|
3327
|
+
})]);
|
|
3328
|
+
} finally {
|
|
3329
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
3330
|
+
signal.removeEventListener("abort", abort);
|
|
3331
|
+
await teardown();
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
};
|
|
3335
|
+
//#endregion
|
|
3336
|
+
//#region lib/types/index.js
|
|
3337
|
+
/**
|
|
3338
|
+
* Codex harness instantiation of the local-agent family. Registers the
|
|
3339
|
+
* `codex` harness into the core registry: scoped home under the shared homes
|
|
3340
|
+
* root (`CODEX_HOME`), device-code login through `codex login --device-auth`
|
|
3341
|
+
* (prompt captured from stdout), and rollout-file session records.
|
|
3342
|
+
* Delegation mounts through the `codex-local` provider (one-shot
|
|
3343
|
+
* `codex exec` under the scoped home); the bundle patch's
|
|
3344
|
+
* `tool-subagent-codex-local` row puts the tool on the profile root, so
|
|
3345
|
+
* every agent preset can delegate.
|
|
3346
|
+
*
|
|
3347
|
+
* @module @khorsheed/dsh-local-agent-codex
|
|
3348
|
+
*/
|
|
3349
|
+
/** Stable Cordis plugin name; the bundle patch row id. */
|
|
3350
|
+
const name = "local-agent-codex";
|
|
3351
|
+
/**
|
|
3352
|
+
* Boot delay before the model catalog's eager warmup probe fires. The probe
|
|
3353
|
+
* spawns a `codex app-server` — cheap, but not free — so it waits out the
|
|
3354
|
+
* boot storm instead of competing with it, and the FIRST settings-card open
|
|
3355
|
+
* usually hits a warm cache. A card opened before the timer fires simply
|
|
3356
|
+
* triggers the probe itself (the read path's lazy kick), so the delay never
|
|
3357
|
+
* costs correctness. Kept short: a setting visible within seconds of boot.
|
|
3358
|
+
*/
|
|
3359
|
+
const CATALOG_WARMUP_DELAY_MS = 1500;
|
|
3360
|
+
/** Services required before the harness can register. */
|
|
3361
|
+
const inject = [
|
|
3362
|
+
"localAgent",
|
|
3363
|
+
"subagents",
|
|
3364
|
+
"subprocess",
|
|
3365
|
+
"settings"
|
|
3366
|
+
];
|
|
3367
|
+
/**
|
|
3368
|
+
* The settings fields (`live`, `liveMirrorGranularity`, `model`,
|
|
3369
|
+
* `recentModels`) ride the plugin Config, marked `.volatile()` where the
|
|
3370
|
+
* host's schemastery has it (3.18.4+, the rc.1 line — probed, never sniffed):
|
|
3371
|
+
* there the SettingsForms writes hot-track the running fiber's Volatile
|
|
3372
|
+
* references without a remount, and the official one-shot import moves an old
|
|
3373
|
+
* settings.yaml section into this row's config. On 0.1.5 the call is absent,
|
|
3374
|
+
* the fields stay plain config keys (a semantic superset — the profile row
|
|
3375
|
+
* can now preset them too), and the legacy settings namespace below carries
|
|
3376
|
+
* the user layer exactly as before. `model` deliberately carries NO default:
|
|
3377
|
+
* an unset key must resolve to undefined, which is what keeps the pre-key
|
|
3378
|
+
* behavior byte-identical.
|
|
3379
|
+
*/
|
|
3380
|
+
const SETTINGS_FIELDS = {
|
|
3381
|
+
live: z.boolean().default(false),
|
|
3382
|
+
liveMirrorGranularity: z.union([z.const("event"), z.const("token")]).default("token"),
|
|
3383
|
+
model: z.string(),
|
|
3384
|
+
recentModels: z.array(z.string()).default([])
|
|
3385
|
+
};
|
|
3386
|
+
/** Mark one schema field volatile when the host's schemastery has the method (rc.1), pass it through plain when not (0.1.5). */
|
|
3387
|
+
function volatilize(field) {
|
|
3388
|
+
return typeof field.volatile === "function" ? field.volatile() : field;
|
|
3389
|
+
}
|
|
3390
|
+
const Config = z.object({
|
|
3391
|
+
sandbox: z.union([
|
|
3392
|
+
z.const("read-only"),
|
|
3393
|
+
z.const("workspace-write"),
|
|
3394
|
+
z.const("danger-full-access")
|
|
3395
|
+
]),
|
|
3396
|
+
model: volatilize(SETTINGS_FIELDS.model),
|
|
3397
|
+
live: volatilize(SETTINGS_FIELDS.live),
|
|
3398
|
+
liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
|
|
3399
|
+
liveMirrorGranularity: volatilize(SETTINGS_FIELDS.liveMirrorGranularity),
|
|
3400
|
+
recentModels: volatilize(SETTINGS_FIELDS.recentModels)
|
|
3401
|
+
});
|
|
3402
|
+
/** The sandbox policy a fresh delegation defaults to. */
|
|
3403
|
+
const DEFAULT_SANDBOX = "workspace-write";
|
|
3404
|
+
/**
|
|
3405
|
+
* Settings namespace for the settings-page card. The Cordis config feeds the
|
|
3406
|
+
* composition `base` layer, so a field absent from the user layer inherits the
|
|
3407
|
+
* YAML value — the card only ever stores deliberate overrides.
|
|
3408
|
+
*/
|
|
3409
|
+
const CODEX_SETTINGS_NAMESPACE = "local-agent-codex";
|
|
3410
|
+
/**
|
|
3411
|
+
* The card's 0.1.5 schema; field defaults are the innermost layer below
|
|
3412
|
+
* `base`. Shared with the Config schema's settings fields above.
|
|
3413
|
+
*/
|
|
3414
|
+
const CODEX_SETTINGS_SCHEMA = z.object(SETTINGS_FIELDS);
|
|
3415
|
+
/**
|
|
3416
|
+
* Register the Codex harness into the local-agent registry.
|
|
3417
|
+
* @param ctx - plugin context carrying the registry.
|
|
3418
|
+
* @param config - plugin config; `sandbox` selects the `codex exec` policy.
|
|
3419
|
+
*/
|
|
3420
|
+
function apply(ctx, config) {
|
|
3421
|
+
ctx.effect(() => {
|
|
3422
|
+
const sandbox = config.sandbox ?? "workspace-write";
|
|
3423
|
+
const homeDir = ctx.localAgent.homeDir("codex");
|
|
3424
|
+
provisionCodexConfig(homeDir).catch((error) => {
|
|
3425
|
+
ctx.logger.warn(`local-agent-codex: config.toml provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3426
|
+
});
|
|
3427
|
+
const readSettings = () => {
|
|
3428
|
+
const model = vol(config.model);
|
|
3429
|
+
const recentModels = vol(config.recentModels);
|
|
3430
|
+
return {
|
|
3431
|
+
live: vol(config.live) ?? false,
|
|
3432
|
+
liveMirrorGranularity: vol(config.liveMirrorGranularity) ?? "token",
|
|
3433
|
+
...model === void 0 ? {} : { model },
|
|
3434
|
+
...recentModels === void 0 ? {} : { recentModels }
|
|
3435
|
+
};
|
|
3436
|
+
};
|
|
3437
|
+
const legacyBase = {};
|
|
3438
|
+
for (const field of [
|
|
3439
|
+
"live",
|
|
3440
|
+
"liveMirrorGranularity",
|
|
3441
|
+
"model"
|
|
3442
|
+
]) {
|
|
3443
|
+
const value = vol(config[field]);
|
|
3444
|
+
if (value !== void 0) legacyBase[field] = value;
|
|
3445
|
+
}
|
|
3446
|
+
const face = settingsFace(ctx, {
|
|
3447
|
+
namespace: CODEX_SETTINGS_NAMESPACE,
|
|
3448
|
+
legacySchema: CODEX_SETTINGS_SCHEMA,
|
|
3449
|
+
legacyBase,
|
|
3450
|
+
read: readSettings
|
|
3451
|
+
});
|
|
3452
|
+
const resolveModel = () => {
|
|
3453
|
+
const model = face.get().model?.trim();
|
|
3454
|
+
return model === void 0 || model === "" ? void 0 : model;
|
|
3455
|
+
};
|
|
3456
|
+
const memberModelOverrides = /* @__PURE__ */ new Map();
|
|
3457
|
+
const modelCatalog = new CodexModelCatalog({
|
|
3458
|
+
spawn: (spec) => ctx.subprocess.spawn(spec),
|
|
3459
|
+
warn: (message) => ctx.logger.warn(message)
|
|
3460
|
+
});
|
|
3461
|
+
const warmup = setTimeout(() => {
|
|
3462
|
+
modelCatalog.read(homeDir);
|
|
3463
|
+
}, CATALOG_WARMUP_DELAY_MS);
|
|
3464
|
+
warmup.unref();
|
|
3465
|
+
const liveSwitch = new LiveDriverSwitch(ctx, face, {
|
|
3466
|
+
sandbox,
|
|
3467
|
+
model: (childSessionId) => memberModelOverrides.get(childSessionId) ?? resolveModel(),
|
|
3468
|
+
...config.liveIdleMs === void 0 ? {} : { liveIdleMs: config.liveIdleMs }
|
|
3469
|
+
});
|
|
3470
|
+
const modelBroker = new CodexModelBroker({
|
|
3471
|
+
ctx,
|
|
3472
|
+
settingsModel: resolveModel,
|
|
3473
|
+
recentModels: () => face.get().recentModels ?? [],
|
|
3474
|
+
homeDir: (childSessionId) => ctx.localAgent.homeDir("codex", childSessionId === void 0 ? void 0 : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.scope),
|
|
3475
|
+
cwd: (childSessionId) => childSessionId === void 0 ? void 0 : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.cwd,
|
|
3476
|
+
directory: (home, cwd) => modelCatalog.directory(home, cwd),
|
|
3477
|
+
refreshDirectory: (home, cwd) => modelCatalog.refresh(home, cwd),
|
|
3478
|
+
followDirectory: (home, cwd, signal) => modelCatalog.follow(home, signal, cwd),
|
|
3479
|
+
live: () => face.get().live,
|
|
3480
|
+
overrides: memberModelOverrides,
|
|
3481
|
+
liveBoundModel: (childSessionId) => liveSwitch.boundModel(childSessionId),
|
|
3482
|
+
retireRuntime: (childSessionId) => liveSwitch.retireRuntime(childSessionId),
|
|
3483
|
+
catalog: (scopedHome, cwd) => modelCatalog.read(scopedHome, cwd),
|
|
3484
|
+
catalogDefault: (scopedHome, cwd) => modelCatalog.readDefault(scopedHome, cwd)
|
|
3485
|
+
});
|
|
3486
|
+
const disposeProvider = ctx.subagents.registerProvider(new CodexCliProvider(ctx, sandbox, liveSwitch.resolve, resolveModel, (childSessionId) => memberModelOverrides.get(childSessionId)));
|
|
3487
|
+
const disposeHarness = ctx.localAgent.register({
|
|
3488
|
+
prepareMember: async ({ binding, childSession, configuration, signal }) => {
|
|
3489
|
+
const driver = liveSwitch.resolve(binding.childSessionId);
|
|
3490
|
+
if (driver === void 0 || driver.disabled) throw new Error("Enable the native live harness before preparing a coordinator");
|
|
3491
|
+
await driver.prepare({
|
|
3492
|
+
cwd: binding.cwd,
|
|
3493
|
+
homeDir: ctx.localAgent.homeDir("codex", binding.scope),
|
|
3494
|
+
childSession,
|
|
3495
|
+
parentSessionId: binding.parentSessionId,
|
|
3496
|
+
configuration: configuration.resolved
|
|
3497
|
+
}, signal);
|
|
3498
|
+
},
|
|
3499
|
+
name: "codex",
|
|
3500
|
+
displayName: "Codex",
|
|
3501
|
+
homeEnvVar: "CODEX_HOME",
|
|
3502
|
+
delegationProvider: "codex-local",
|
|
3503
|
+
modelBroker,
|
|
3504
|
+
login: { pty: {
|
|
3505
|
+
command: "codex",
|
|
3506
|
+
args: ["login"]
|
|
3507
|
+
} },
|
|
3508
|
+
records: { listSessions: (homeDir) => listCodexSessions(homeDir) },
|
|
3509
|
+
provision: (scopedHome) => provisionCodexConfig(scopedHome).then(() => {}),
|
|
3510
|
+
isAuthenticated: codexAuthenticated,
|
|
3511
|
+
credentialStamp: codexCredentialStamp,
|
|
3512
|
+
logout: codexLogout,
|
|
3513
|
+
effectiveSettings: async (scopedHome) => {
|
|
3514
|
+
const [reasoningEffort, baseUrl, scopedModel, cliVersion] = await Promise.all([
|
|
3515
|
+
readCodexReasoningEffort(scopedHome).catch(() => void 0),
|
|
3516
|
+
readCodexBaseUrl(scopedHome).catch(() => void 0),
|
|
3517
|
+
readCodexModel(scopedHome).catch(() => void 0),
|
|
3518
|
+
codexCliVersion(ctx, scopedHome).catch(() => void 0)
|
|
3519
|
+
]);
|
|
3520
|
+
const model = resolveModel() ?? scopedModel;
|
|
3521
|
+
const baseUrlHost = baseUrl !== void 0 ? endpointHost(baseUrl) : void 0;
|
|
3522
|
+
return {
|
|
3523
|
+
drive: face.get().live ? "live" : "exec",
|
|
3524
|
+
sandbox,
|
|
3525
|
+
...reasoningEffort !== void 0 ? { reasoningEffort } : {},
|
|
3526
|
+
baseUrlSet: baseUrl !== void 0,
|
|
3527
|
+
...baseUrlHost !== void 0 ? { baseUrlHost } : {},
|
|
3528
|
+
...model !== void 0 ? { model } : {},
|
|
3529
|
+
...cliVersion !== void 0 ? { cliVersion } : {}
|
|
3530
|
+
};
|
|
3531
|
+
},
|
|
3532
|
+
subcommand: (input, invocation) => {
|
|
3533
|
+
const [verb] = input.split(/\s+/);
|
|
3534
|
+
if (verb === "sessions") return listCodexSessions(invocation.agent.ctx.localAgent.homeDir("codex")).then((records) => ({
|
|
3535
|
+
kind: "success",
|
|
3536
|
+
text: records.length === 0 ? "No codex sessions in the scoped home yet." : records.map((r) => `${r.id} ${r.workDir}`).join("\n")
|
|
3537
|
+
}));
|
|
3538
|
+
}
|
|
3539
|
+
});
|
|
3540
|
+
return () => {
|
|
3541
|
+
clearTimeout(warmup);
|
|
3542
|
+
modelCatalog.dispose();
|
|
3543
|
+
disposeProvider();
|
|
3544
|
+
disposeHarness();
|
|
3545
|
+
liveSwitch.dispose();
|
|
3546
|
+
};
|
|
3547
|
+
}, "local-agent-codex: harness");
|
|
3548
|
+
}
|
|
3549
|
+
//#endregion
|
|
3550
|
+
export { CATALOG_WARMUP_DELAY_MS, CODEX_SETTINGS_NAMESPACE, Config, DEFAULT_SANDBOX, apply, inject, name };
|