@workerdeck/core 0.23.0 → 1.1.0
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/build/index.d.mts +82 -1213
- package/build/index.mjs +291 -1968
- package/build/index.mjs.map +1 -1
- package/package.json +14 -14
package/build/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { getSessionInfo, getSessionMessages, listSessions, query } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
-
import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, SUBAGENT_HISTORY, TOOL_RESULT_HEAD_CHARS, contextReading, imagePartRef, replayCoalesceKey, replayRetains, snapshotRetains, transcriptActivity, transcriptContent } from "@workerdeck/protocol";
|
|
4
|
+
import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, SUBAGENT_HISTORY, TOOL_RESULT_HEAD_CHARS, contextReading, imagePartRef, replayCoalesceKey, replayRetains, snapshotRetains, transcriptActivity, transcriptContent, transcriptProse } from "@workerdeck/protocol";
|
|
5
5
|
import { ToolLoopAgent, generateText, isStepCount, tool } from "ai";
|
|
6
6
|
import { execFile, spawn } from "node:child_process";
|
|
7
7
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -11,16 +11,13 @@ import { lookup } from "node:dns/promises";
|
|
|
11
11
|
import { homedir, tmpdir } from "node:os";
|
|
12
12
|
import { dirname, join, resolve, sep } from "node:path";
|
|
13
13
|
//#region src/lib/attachments.ts
|
|
14
|
-
|
|
15
|
-
* native photo format, which clients must transcode before upload. */
|
|
16
|
-
const IMAGE_TYPES = new Set([
|
|
14
|
+
const IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
17
15
|
"image/jpeg",
|
|
18
16
|
"image/png",
|
|
19
17
|
"image/gif",
|
|
20
18
|
"image/webp"
|
|
21
19
|
]);
|
|
22
|
-
|
|
23
|
-
const TEXT_TYPES = new Set([
|
|
20
|
+
const TEXT_TYPES = /* @__PURE__ */ new Set([
|
|
24
21
|
"application/json",
|
|
25
22
|
"application/xml",
|
|
26
23
|
"application/yaml",
|
|
@@ -32,11 +29,9 @@ const TEXT_TYPES = new Set([
|
|
|
32
29
|
"application/x-httpd-php",
|
|
33
30
|
"application/sql"
|
|
34
31
|
]);
|
|
35
|
-
/** Strips any `; charset=…` parameter and lowercases. */
|
|
36
32
|
function normalizeMediaType(mediaType) {
|
|
37
33
|
return mediaType.split(";")[0].trim().toLowerCase();
|
|
38
34
|
}
|
|
39
|
-
/** How this media type can be sent, or null if it can't be. */
|
|
40
35
|
function attachmentKind(mediaType) {
|
|
41
36
|
const type = normalizeMediaType(mediaType);
|
|
42
37
|
if (IMAGE_TYPES.has(type)) return "image";
|
|
@@ -44,23 +39,11 @@ function attachmentKind(mediaType) {
|
|
|
44
39
|
if (type.startsWith("text/") || TEXT_TYPES.has(type)) return "text";
|
|
45
40
|
return null;
|
|
46
41
|
}
|
|
47
|
-
/** Human-readable list for the 415 an unsupported upload gets. */
|
|
48
42
|
const SUPPORTED_ATTACHMENT_TYPES = [
|
|
49
43
|
...IMAGE_TYPES,
|
|
50
44
|
"application/pdf",
|
|
51
45
|
"text/*"
|
|
52
46
|
].join(", ");
|
|
53
|
-
/**
|
|
54
|
-
* Anthropic content blocks for a set of attachments, in the given order.
|
|
55
|
-
*
|
|
56
|
-
* Blocks lead the message and the user's text follows: the model reads the
|
|
57
|
-
* picture, then the instruction about it. Text files are inlined in a named
|
|
58
|
-
* envelope rather than as a bare block, so "here is my config" doesn't read as
|
|
59
|
-
* something the user typed.
|
|
60
|
-
*
|
|
61
|
-
* Structurally typed — `packages/core` models Anthropic content the way
|
|
62
|
-
* `packages/protocol` does, and the caller casts into the SDK's own param type.
|
|
63
|
-
*/
|
|
64
47
|
function attachmentContentBlocks(attachments) {
|
|
65
48
|
return attachments.map((attachment) => {
|
|
66
49
|
const mediaType = normalizeMediaType(attachment.mediaType);
|
|
@@ -90,7 +73,6 @@ function attachmentContentBlocks(attachments) {
|
|
|
90
73
|
}
|
|
91
74
|
});
|
|
92
75
|
}
|
|
93
|
-
/** Strip the bytes: the log-safe half of an attachment. */
|
|
94
76
|
function attachmentRef(attachment) {
|
|
95
77
|
return {
|
|
96
78
|
id: attachment.id,
|
|
@@ -104,10 +86,6 @@ function decodeText(base64) {
|
|
|
104
86
|
}
|
|
105
87
|
//#endregion
|
|
106
88
|
//#region src/lib/input-queue.ts
|
|
107
|
-
/**
|
|
108
|
-
* Push-based single-consumer AsyncIterable bridging imperative sendMessage() calls
|
|
109
|
-
* into the streaming `prompt` the Agent SDK consumes.
|
|
110
|
-
*/
|
|
111
89
|
var InputQueue = class {
|
|
112
90
|
#buffer = [];
|
|
113
91
|
#waiter = null;
|
|
@@ -163,30 +141,8 @@ var InputQueue = class {
|
|
|
163
141
|
};
|
|
164
142
|
//#endregion
|
|
165
143
|
//#region src/lib/patch.ts
|
|
166
|
-
/**
|
|
167
|
-
* Turning an engine's edit output into the wire's {@link FilePatch}.
|
|
168
|
-
*
|
|
169
|
-
* Both engines know exactly which lines of which file changed, and both say so
|
|
170
|
-
* in their own vocabulary: the Claude SDK hands over a `structuredPatch` array
|
|
171
|
-
* on `SDKUserMessage.tool_use_result`, codex puts a unified diff string on each
|
|
172
|
-
* `fileChange` item. A client can reconstruct neither — it has never seen the
|
|
173
|
-
* file — so anything not normalized here is a diff that renders without line
|
|
174
|
-
* numbers.
|
|
175
|
-
*
|
|
176
|
-
* Normalizing in the runner rather than in each client is the point: one shape
|
|
177
|
-
* reaches the wire, and the dashboard, the extension and the phone all render
|
|
178
|
-
* from it without a per-engine branch or a diff parser of their own.
|
|
179
|
-
*/
|
|
180
|
-
/**
|
|
181
|
-
* The most lines a patch may put on the wire.
|
|
182
|
-
*
|
|
183
|
-
* A patch is replayed on every attach and captured into parking snapshots, so
|
|
184
|
-
* "the diff is big" must not become "this session is expensive to open forever".
|
|
185
|
-
* Whole hunks are kept or dropped — half a hunk has misleading line numbers —
|
|
186
|
-
* and the drop is flagged so a renderer can say the diff is partial instead of
|
|
187
|
-
* presenting it as the whole change.
|
|
188
|
-
*/
|
|
189
144
|
const MAX_PATCH_LINES = 400;
|
|
145
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
190
146
|
function capHunks(hunks) {
|
|
191
147
|
const kept = [];
|
|
192
148
|
let lines = 0;
|
|
@@ -200,25 +156,15 @@ function capHunks(hunks) {
|
|
|
200
156
|
}
|
|
201
157
|
return { hunks: kept };
|
|
202
158
|
}
|
|
203
|
-
/** Structural, not `instanceof`: this reads a field the SDK types as `unknown`,
|
|
204
|
-
* and a shape check is the only honest way to know what arrived. */
|
|
205
159
|
function isHunk(value) {
|
|
206
160
|
const hunk = value;
|
|
207
161
|
return !!hunk && typeof hunk.oldStart === "number" && typeof hunk.oldLines === "number" && typeof hunk.newStart === "number" && typeof hunk.newLines === "number" && Array.isArray(hunk.lines) && hunk.lines.every((line) => typeof line === "string");
|
|
208
162
|
}
|
|
209
|
-
/**
|
|
210
|
-
* A {@link FilePatch} from the Claude SDK's structured tool output
|
|
211
|
-
* (`SDKUserMessage.tool_use_result` for Edit/Write/NotebookEdit).
|
|
212
|
-
*
|
|
213
|
-
* Everything else on that object is deliberately left behind — `originalFile`
|
|
214
|
-
* alone is the entire pre-edit file, which is precisely what must not be logged
|
|
215
|
-
* (see `FilePatch`'s own note).
|
|
216
|
-
*/
|
|
217
163
|
function filePatchFromToolResult(result) {
|
|
218
164
|
const output = result;
|
|
219
|
-
if (!output || !Array.isArray(output.structuredPatch)) return
|
|
165
|
+
if (!output || !Array.isArray(output.structuredPatch)) return;
|
|
220
166
|
const hunks = output.structuredPatch.filter(isHunk);
|
|
221
|
-
if (hunks.length === 0) return
|
|
167
|
+
if (hunks.length === 0) return;
|
|
222
168
|
const { hunks: kept, truncated } = capHunks(hunks);
|
|
223
169
|
return {
|
|
224
170
|
...typeof output.filePath === "string" && { path: output.filePath },
|
|
@@ -227,20 +173,6 @@ function filePatchFromToolResult(result) {
|
|
|
227
173
|
...truncated && { truncated }
|
|
228
174
|
};
|
|
229
175
|
}
|
|
230
|
-
/** `@@ -oldStart,oldLines +newStart,newLines @@` — the counts are optional and
|
|
231
|
-
* mean 1 when absent, which is what a single-line hunk looks like. */
|
|
232
|
-
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
233
|
-
/**
|
|
234
|
-
* A {@link FilePatch} from a unified diff — codex's `fileChange.diff`.
|
|
235
|
-
*
|
|
236
|
-
* Only the hunks are read. A diff's `---`/`+++` header names the file, but codex
|
|
237
|
-
* already reports the path on the change itself, and a header path is often
|
|
238
|
-
* relative or `/dev/null`, so the caller's path is the one worth trusting.
|
|
239
|
-
*
|
|
240
|
-
* Returns undefined when there is no hunk header at all: that is not a unified
|
|
241
|
-
* diff, and inventing hunk numbers for it would put wrong line numbers on screen
|
|
242
|
-
* — worse than none.
|
|
243
|
-
*/
|
|
244
176
|
function parseUnifiedDiff(diff, path) {
|
|
245
177
|
const hunks = [];
|
|
246
178
|
let current;
|
|
@@ -262,7 +194,7 @@ function parseUnifiedDiff(diff, path) {
|
|
|
262
194
|
else if (line === "") current.lines.push(" ");
|
|
263
195
|
else current = void 0;
|
|
264
196
|
}
|
|
265
|
-
if (hunks.length === 0) return
|
|
197
|
+
if (hunks.length === 0) return;
|
|
266
198
|
const { hunks: kept, truncated } = capHunks(hunks);
|
|
267
199
|
return {
|
|
268
200
|
...path && { path },
|
|
@@ -272,43 +204,18 @@ function parseUnifiedDiff(diff, path) {
|
|
|
272
204
|
}
|
|
273
205
|
//#endregion
|
|
274
206
|
//#region src/lib/normalize.ts
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
207
|
+
const FAMILY_ORDER = [
|
|
208
|
+
"fable",
|
|
209
|
+
"opus",
|
|
210
|
+
"sonnet",
|
|
211
|
+
"haiku"
|
|
212
|
+
];
|
|
278
213
|
function singleToolResult(message) {
|
|
279
214
|
const content = message.content;
|
|
280
215
|
if (!Array.isArray(content)) return false;
|
|
281
216
|
return content.filter((block) => block.type === "tool_result").length === 1;
|
|
282
217
|
}
|
|
283
|
-
/**
|
|
284
|
-
* The wrappers the CLI writes into the transcript when the *harness* is talking
|
|
285
|
-
* to the model rather than a person talking to the session.
|
|
286
|
-
*
|
|
287
|
-
* Deliberately a text test, and only these two. The live path has structure to
|
|
288
|
-
* go on (`isSynthetic`, `origin.kind`), but **the resumed path has none**: the
|
|
289
|
-
* SDK's `SessionMessage` carries exactly `message`, `uuid`, `session_id`,
|
|
290
|
-
* `parent_tool_use_id`, `parent_agent_id` and `timestamp` — every one of
|
|
291
|
-
* `isMeta`, `isSidechain`, `promptSource` and `origin` is dropped between the
|
|
292
|
-
* stored JSONL and what `getSessionMessages` hands back (verified against real
|
|
293
|
-
* transcripts). So on resume this is the only signal there is, and without it a
|
|
294
|
-
* `<task-notification>` blob comes back as a blue user row and a scrubber mark,
|
|
295
|
-
* as if someone had typed it.
|
|
296
|
-
*
|
|
297
|
-
* `<local-command-caveat>` is here for symmetry and cheap insurance: the SDK
|
|
298
|
-
* filters `isMeta` entries out of a resumed transcript itself today, which is
|
|
299
|
-
* not a contract anyone wrote down.
|
|
300
|
-
*
|
|
301
|
-
* What is *not* here matters as much:
|
|
302
|
-
* - `<local-command-stdout>` — the reducer turns it into a notice row on
|
|
303
|
-
* purpose; marking it synthetic would delete a row both paths show.
|
|
304
|
-
* - `<command-name>` — that is a person running a slash command. The reducer
|
|
305
|
-
* renders it as the command line they typed; hiding it would erase the turn's
|
|
306
|
-
* cause.
|
|
307
|
-
*/
|
|
308
218
|
const SYNTHETIC_USER_PREFIXES = ["<task-notification>", "<local-command-caveat>"];
|
|
309
|
-
/** First text block's leading tag, for the test above. Tool results and images
|
|
310
|
-
* carry no text and are never synthetic by this rule (a tool result is already
|
|
311
|
-
* a tool result to every renderer). */
|
|
312
219
|
function isSyntheticUserText(message) {
|
|
313
220
|
const content = message.content;
|
|
314
221
|
const text = typeof content === "string" ? content : Array.isArray(content) ? content.find((block) => block.type === "text")?.text : void 0;
|
|
@@ -326,20 +233,6 @@ function toApiMessage(message) {
|
|
|
326
233
|
usage: m.usage
|
|
327
234
|
};
|
|
328
235
|
}
|
|
329
|
-
/**
|
|
330
|
-
* Plan rate-limit windows from the CLI's structured `/usage` data, as `rate_limit`
|
|
331
|
-
* events — the same shape a live `rate_limit_event` produces.
|
|
332
|
-
*
|
|
333
|
-
* Without this a client shows no usage at all until a window *changes*, which the
|
|
334
|
-
* CLI only reports after a turn moves the needle, and never for a session that is
|
|
335
|
-
* only being watched. Polling the snapshot and forwarding it through the existing
|
|
336
|
-
* event means replay, the dashboard and the iOS app all get it for free, with no
|
|
337
|
-
* new protocol surface.
|
|
338
|
-
*
|
|
339
|
-
* `status` is not per-window in the usage payload — 'allowed' is what a session
|
|
340
|
-
* the CLI is running for us is, by construction. A window with no utilization is
|
|
341
|
-
* unknown, not zero, and is dropped rather than reported at 0%.
|
|
342
|
-
*/
|
|
343
236
|
function rateLimitEventsFromUsage(usage) {
|
|
344
237
|
if (!usage.rate_limits_available || !usage.rate_limits) return [];
|
|
345
238
|
const limits = usage.rate_limits;
|
|
@@ -370,15 +263,6 @@ function rateLimitEventsFromUsage(usage) {
|
|
|
370
263
|
}
|
|
371
264
|
return events;
|
|
372
265
|
}
|
|
373
|
-
/**
|
|
374
|
-
* The CLI's MCP status, as `McpServerStatusInfo`.
|
|
375
|
-
*
|
|
376
|
-
* The narrowing is the point: the SDK's config object carries `env` for stdio
|
|
377
|
-
* servers and `headers` for HTTP ones, and both routinely hold API tokens. This
|
|
378
|
-
* is the one place they are dropped, so no client — dashboard, phone, or a host
|
|
379
|
-
* app reading the REST route — can turn "show me my MCP servers" into a
|
|
380
|
-
* credential dump. Only the connection's identity survives.
|
|
381
|
-
*/
|
|
382
266
|
function mcpStatusInfo(status) {
|
|
383
267
|
const config = status.config;
|
|
384
268
|
const transport = config?.type ?? (config?.command ? "stdio" : void 0);
|
|
@@ -399,26 +283,6 @@ function mcpStatusInfo(status) {
|
|
|
399
283
|
}))
|
|
400
284
|
};
|
|
401
285
|
}
|
|
402
|
-
/**
|
|
403
|
-
* The CLI's model list, as `ModelOption[]`.
|
|
404
|
-
*
|
|
405
|
-
* Two decisions live here rather than in each client:
|
|
406
|
-
*
|
|
407
|
-
* - **`default` is dropped.** The CLI offers a row whose id is literally
|
|
408
|
-
* `default` ("Default (recommended)"), meaning "whatever I would have picked".
|
|
409
|
-
* It is a legal id to send, but it is not a model: a session running on it
|
|
410
|
-
* reports a real model, so a picker showing it has a row that can never be
|
|
411
|
-
* checked, and a status bar naming it would say "Default" for a session
|
|
412
|
-
* answering as Opus. Which model the default resolved to is a different
|
|
413
|
-
* question, and `system_init` answers it.
|
|
414
|
-
* - **`primary` is derived.** The CLI reports one flat list; Claude Code's own
|
|
415
|
-
* picker shows the newest of each family and files the rest under "more
|
|
416
|
-
* models". The list arrives newest-first, so the first row of each family is
|
|
417
|
-
* the primary one. A heuristic, but a stable one — and doing it once here
|
|
418
|
-
* means the dashboard and the phone group identically.
|
|
419
|
-
*/
|
|
420
|
-
/** What the CLI's `default` row resolves to — the model a session will answer as
|
|
421
|
-
* before it has answered anything. Dropped from the list, kept as this. */
|
|
422
286
|
function defaultModelFromSdk(models) {
|
|
423
287
|
return models.find((model) => model.value === "default")?.resolvedModel;
|
|
424
288
|
}
|
|
@@ -452,50 +316,26 @@ function modelOptionsFromSdk(models) {
|
|
|
452
316
|
return rankA === rankB ? a.index - b.index : rankA - rankB;
|
|
453
317
|
}).map(({ option }) => option);
|
|
454
318
|
}
|
|
455
|
-
const FAMILY_ORDER = [
|
|
456
|
-
"fable",
|
|
457
|
-
"opus",
|
|
458
|
-
"sonnet",
|
|
459
|
-
"haiku"
|
|
460
|
-
];
|
|
461
319
|
function familyRank(option) {
|
|
462
320
|
const rank = FAMILY_ORDER.indexOf(modelFamily(option.resolvedModel ?? option.value));
|
|
463
321
|
return rank === -1 ? FAMILY_ORDER.length : rank;
|
|
464
322
|
}
|
|
465
|
-
|
|
466
|
-
* The name a person says, from a wire model id: 'claude-opus-5[1m]' → "Opus 5",
|
|
467
|
-
* 'claude-haiku-4-5-20251001' → "Haiku 4.5".
|
|
468
|
-
*
|
|
469
|
-
* The CLI's own `displayName` is the family alone ("Opus", "Haiku") or carries a
|
|
470
|
-
* variant instead of a version ("Opus (1M context)"), and the version is the part
|
|
471
|
-
* that answers "is this the current one". It is only ever in the id, so it is
|
|
472
|
-
* read from there. Returns null when the id has no version to read — a bare
|
|
473
|
-
* alias like 'sonnet' — and the CLI's name stands.
|
|
474
|
-
*/
|
|
475
|
-
function friendlyModelName(id) {
|
|
323
|
+
function modelIdParts(id) {
|
|
476
324
|
const parts = (id.split("[")[0] ?? id).toLowerCase().split("-").filter(Boolean);
|
|
477
325
|
if (parts[0] === "claude") parts.shift();
|
|
326
|
+
return parts;
|
|
327
|
+
}
|
|
328
|
+
function friendlyModelName(id) {
|
|
329
|
+
const parts = modelIdParts(id);
|
|
478
330
|
const family = parts.shift();
|
|
479
331
|
if (!family) return null;
|
|
480
332
|
const version = parts.filter((part) => !/^\d{8}$/.test(part));
|
|
481
333
|
if (version.length === 0 || version.some((part) => !/^\d+$/.test(part))) return null;
|
|
482
334
|
return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join(".")}`;
|
|
483
335
|
}
|
|
484
|
-
/** 'claude-opus-4-8[1m]' → "opus". The vendor prefix, the context-window suffix
|
|
485
|
-
* and the version tail are all dropped; what is left is the family a person
|
|
486
|
-
* names. Unrecognisable ids become their own family, so a model this rule has
|
|
487
|
-
* never seen lands in the main list rather than being hidden. */
|
|
488
336
|
function modelFamily(id) {
|
|
489
|
-
|
|
490
|
-
const parts = withoutVariant.toLowerCase().split("-");
|
|
491
|
-
if (parts[0] === "claude") parts.shift();
|
|
492
|
-
return parts[0] ?? withoutVariant;
|
|
337
|
+
return modelIdParts(id)[0] ?? id;
|
|
493
338
|
}
|
|
494
|
-
/**
|
|
495
|
-
* Map one SDKMessage to a wire-protocol event body, or null for messages the runner
|
|
496
|
-
* consumes itself (system_init and session-state changes carry runner state and are
|
|
497
|
-
* emitted by the runner with extra context).
|
|
498
|
-
*/
|
|
499
339
|
function normalizeSdkMessage(msg) {
|
|
500
340
|
switch (msg.type) {
|
|
501
341
|
case "assistant": return {
|
|
@@ -560,26 +400,73 @@ function normalizeSdkMessage(msg) {
|
|
|
560
400
|
}
|
|
561
401
|
}
|
|
562
402
|
//#endregion
|
|
403
|
+
//#region src/lib/event-log.ts
|
|
404
|
+
var EventLog = class {
|
|
405
|
+
#events = [];
|
|
406
|
+
#seq = 0;
|
|
407
|
+
#activityCount = 0;
|
|
408
|
+
#proseCount = 0;
|
|
409
|
+
#contextUsage;
|
|
410
|
+
#resetSeq = 0;
|
|
411
|
+
#lastActivityAt;
|
|
412
|
+
get events() {
|
|
413
|
+
return this.#events;
|
|
414
|
+
}
|
|
415
|
+
get seq() {
|
|
416
|
+
return this.#seq;
|
|
417
|
+
}
|
|
418
|
+
get activityCount() {
|
|
419
|
+
return this.#activityCount;
|
|
420
|
+
}
|
|
421
|
+
/** The unread badge's unit — see `transcriptProse`. Folded here so a restored log recomputes it. */
|
|
422
|
+
get proseCount() {
|
|
423
|
+
return this.#proseCount;
|
|
424
|
+
}
|
|
425
|
+
get contextUsage() {
|
|
426
|
+
return this.#contextUsage;
|
|
427
|
+
}
|
|
428
|
+
get resetSeq() {
|
|
429
|
+
return this.#resetSeq;
|
|
430
|
+
}
|
|
431
|
+
get lastActivityAt() {
|
|
432
|
+
return this.#lastActivityAt;
|
|
433
|
+
}
|
|
434
|
+
append(body) {
|
|
435
|
+
const event = {
|
|
436
|
+
...body,
|
|
437
|
+
seq: ++this.#seq,
|
|
438
|
+
ts: Date.now()
|
|
439
|
+
};
|
|
440
|
+
this.#lastActivityAt = event.ts;
|
|
441
|
+
this.#fold(event);
|
|
442
|
+
this.#events.push(event);
|
|
443
|
+
return event;
|
|
444
|
+
}
|
|
445
|
+
at(seq) {
|
|
446
|
+
return this.#events.find((event) => event.seq === seq);
|
|
447
|
+
}
|
|
448
|
+
restore(events, seq, lastActivityAt) {
|
|
449
|
+
this.#events = [...events];
|
|
450
|
+
this.#seq = seq;
|
|
451
|
+
this.#activityCount = 0;
|
|
452
|
+
this.#proseCount = 0;
|
|
453
|
+
this.#contextUsage = void 0;
|
|
454
|
+
this.#resetSeq = 0;
|
|
455
|
+
for (const event of this.#events) this.#fold(event);
|
|
456
|
+
this.#lastActivityAt = lastActivityAt;
|
|
457
|
+
}
|
|
458
|
+
#fold(event) {
|
|
459
|
+
this.#activityCount += transcriptActivity(event);
|
|
460
|
+
this.#proseCount += transcriptProse(event);
|
|
461
|
+
this.#contextUsage = contextReading(event) ?? this.#contextUsage;
|
|
462
|
+
if (event.type === "conversation_reset") {
|
|
463
|
+
this.#resetSeq = event.seq;
|
|
464
|
+
this.#contextUsage = void 0;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
//#endregion
|
|
563
469
|
//#region src/lib/replay.ts
|
|
564
|
-
/**
|
|
565
|
-
* Which buffered events a coalesced replay should skip: everything superseded
|
|
566
|
-
* by a later event with the same {@link replayCoalesceKey}.
|
|
567
|
-
*
|
|
568
|
-
* A **backwards** scan, keeping the first occurrence of each key — which is the
|
|
569
|
-
* whole trick. Walking forwards would need a second pass to know which of the
|
|
570
|
-
* fifty context readings was the last one; walking backwards, the first one you
|
|
571
|
-
* meet *is* the last one, and everything after it (in scan order) is history.
|
|
572
|
-
*
|
|
573
|
-
* Note what this does **not** do: it never reorders and never touches an event
|
|
574
|
-
* with no key. Transcript content is an ordered fold — a stream delta
|
|
575
|
-
* accumulates onto a message, a tool result attaches to a call that came
|
|
576
|
-
* earlier, a turn result finalizes — so it must arrive exactly as it was
|
|
577
|
-
* emitted. Only last-write-wins *state* is eligible, and `replayCoalesceKey`
|
|
578
|
-
* is where that judgement lives.
|
|
579
|
-
*
|
|
580
|
-
* `afterSeq` is honoured so the scan agrees with the caller's replay window: an
|
|
581
|
-
* event the caller was never going to send must not suppress one it was.
|
|
582
|
-
*/
|
|
583
470
|
function staleReplaySeqs(events, afterSeq) {
|
|
584
471
|
const stale = /* @__PURE__ */ new Set();
|
|
585
472
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -593,45 +480,6 @@ function staleReplaySeqs(events, afterSeq) {
|
|
|
593
480
|
}
|
|
594
481
|
return stale;
|
|
595
482
|
}
|
|
596
|
-
/**
|
|
597
|
-
* The one replay body, and what a socket receives from it.
|
|
598
|
-
*
|
|
599
|
-
* Every runner had a byte-identical copy of this loop — three spellings of four
|
|
600
|
-
* rules, one of which ("never drop the highest-seq event, whatever the rule
|
|
601
|
-
* says") is load-bearing and was three copies of a comment. Not a base class:
|
|
602
|
-
* the runners share nothing else, and a base class would have to own `#emit`,
|
|
603
|
-
* the most engine-specific method each of them has.
|
|
604
|
-
*
|
|
605
|
-
* The rules, in the order they are applied:
|
|
606
|
-
*
|
|
607
|
-
* 1. `afterSeq` — the caller already holds everything at or below it.
|
|
608
|
-
* 2. `resetSeq` — transcript *content* strictly below the latest
|
|
609
|
-
* `conversation_reset` is skipped, so a re-attach cannot resurrect a cleared
|
|
610
|
-
* conversation while state events still replay. **Every engine that can emit
|
|
611
|
-
* a reset must track it and pass it** — this was Claude's alone only for as
|
|
612
|
-
* long as Claude's was the only engine that could produce the event, and the
|
|
613
|
-
* failure when a runner forgets is quiet: the end state is right for a
|
|
614
|
-
* current reducer, so nothing looks broken while every attach re-sends the
|
|
615
|
-
* whole cleared conversation for the process's lifetime.
|
|
616
|
-
* 3. `coalesceReplay` — last-write-wins state readings superseded later in the
|
|
617
|
-
* same replay (`staleReplaySeqs`), plus everything `replayRetains` says the
|
|
618
|
-
* reducer reads and discards. Opt-in, and only sound for a consumer whose
|
|
619
|
-
* handling of those events is last-write-wins.
|
|
620
|
-
* 4. `imageRefs` — a base64 image part is delivered as an `image_ref` address
|
|
621
|
-
* (protocol's {@link imagePartRef}), its bytes one REST fetch away. Applied
|
|
622
|
-
* **before** rule 5, because it stamps indices from the stored part array
|
|
623
|
-
* which rule 5 then reshapes. Unlike rule 5 this also applies to the live
|
|
624
|
-
* path (see `SubscriberSet`), which is the one place these two rules differ.
|
|
625
|
-
* 5. `truncateResults` — a huge `tool_result` block is delivered as its head
|
|
626
|
-
* plus the markers that say so. **Never mutates the stored event**: the live
|
|
627
|
-
* path, the parking snapshot and the fetch route all need the whole thing,
|
|
628
|
-
* so this builds a copy and the log stays the log.
|
|
629
|
-
*
|
|
630
|
-
* The highest-seq event is delivered whatever rules 2 and 3 say — a client's
|
|
631
|
-
* replay hold waits for `state.lastSeq` to reach the attach's and would
|
|
632
|
-
* otherwise hang forever — but it is still *truncated* when rule 4 applies. A
|
|
633
|
-
* session that ends on a `find /` puts its 641 KB frame exactly there.
|
|
634
|
-
*/
|
|
635
483
|
function replaySlice(events, options) {
|
|
636
484
|
const { afterSeq, resetSeq = 0, coalesceReplay, truncateResults, imageRefs } = options;
|
|
637
485
|
const stale = coalesceReplay ? staleReplaySeqs(events, afterSeq) : void 0;
|
|
@@ -649,16 +497,6 @@ function replaySlice(events, options) {
|
|
|
649
497
|
}
|
|
650
498
|
return out;
|
|
651
499
|
}
|
|
652
|
-
/**
|
|
653
|
-
* A copy of `event` whose oversized `tool_result` blocks carry their head and
|
|
654
|
-
* say so — or `event` itself, unchanged and un-copied, when nothing is over the
|
|
655
|
-
* budget. That identity matters: an attach is mostly small events, and a fresh
|
|
656
|
-
* object for every one of them would cost more than the feature saves.
|
|
657
|
-
*
|
|
658
|
-
* Blocks are measured and cut **individually**. A message answering three calls
|
|
659
|
-
* where one is a `find /` keeps the two small results whole, which is what makes
|
|
660
|
-
* the per-block marker (rather than a per-event one) honest.
|
|
661
|
-
*/
|
|
662
500
|
function truncateResultBlocks(event) {
|
|
663
501
|
if (event.type !== "user_message") return event;
|
|
664
502
|
const content = event.message.content;
|
|
@@ -687,22 +525,11 @@ function truncateResultBlocks(event) {
|
|
|
687
525
|
}
|
|
688
526
|
};
|
|
689
527
|
}
|
|
690
|
-
/** Characters in a result's content, in the same terms a reader sees it: the
|
|
691
|
-
* string itself, or every text part of a block list joined by newlines — which
|
|
692
|
-
* is exactly what `blockText` in the reducer builds. Non-text parts (an image
|
|
693
|
-
* block) contribute nothing, because they are not what is large here and
|
|
694
|
-
* slicing them would corrupt them. */
|
|
695
528
|
function resultChars(content) {
|
|
696
529
|
if (typeof content === "string") return content.length;
|
|
697
530
|
if (!Array.isArray(content)) return 0;
|
|
698
531
|
return content.reduce((total, part, index) => total + (typeof part.text === "string" ? part.text.length + (index > 0 ? 1 : 0) : 0), 0);
|
|
699
532
|
}
|
|
700
|
-
/** The first `chars` characters, in the content's own shape — a string stays a
|
|
701
|
-
* string, a block list stays a block list (cut at the part that crosses the
|
|
702
|
-
* budget, with the remaining parts dropped). Shape-preserving on purpose: the
|
|
703
|
-
* reducer, both renderers and the copy button all read this the same way they
|
|
704
|
-
* read a whole one, so truncation is a shorter result and never a different
|
|
705
|
-
* kind of one. */
|
|
706
533
|
function headOf(content, chars) {
|
|
707
534
|
if (typeof content === "string") return content.slice(0, chars);
|
|
708
535
|
if (!Array.isArray(content)) return content;
|
|
@@ -724,23 +551,6 @@ function headOf(content, chars) {
|
|
|
724
551
|
}
|
|
725
552
|
return parts;
|
|
726
553
|
}
|
|
727
|
-
/**
|
|
728
|
-
* A copy of `event` whose `tool_result` blocks carry `image_ref` addresses in
|
|
729
|
-
* place of their base64 image parts — or `event` itself, unchanged and
|
|
730
|
-
* un-copied, when it holds none. Same identity rule as
|
|
731
|
-
* {@link truncateResultBlocks}, and it matters more here: an event carrying an
|
|
732
|
-
* image at all is the exception, so the common path must not allocate.
|
|
733
|
-
*
|
|
734
|
-
* **Never mutates the stored event.** The log is what the parking snapshot
|
|
735
|
-
* embeds, what `Runner.eventAt` reads, and therefore what the fetch route
|
|
736
|
-
* serves the bytes back from — a drop that reached the log would 404 the very
|
|
737
|
-
* lazy-load this rule promises.
|
|
738
|
-
*
|
|
739
|
-
* Indices are stamped from the **stored** array, which is why this runs *before*
|
|
740
|
-
* truncation rather than after: `headOf` reshapes a block's parts, so an address
|
|
741
|
-
* computed on its output would name the wrong part of the stored block. That
|
|
742
|
-
* ordering is asserted in `replay-image-ref.test.ts`, not merely intended.
|
|
743
|
-
*/
|
|
744
554
|
function refImageParts(event) {
|
|
745
555
|
if (event.type !== "user_message") return event;
|
|
746
556
|
const content = event.message.content;
|
|
@@ -778,14 +588,6 @@ function refImageParts(event) {
|
|
|
778
588
|
//#region src/lib/subscribers.ts
|
|
779
589
|
var SubscriberSet = class {
|
|
780
590
|
#listeners = /* @__PURE__ */ new Map();
|
|
781
|
-
/**
|
|
782
|
-
* Replay `events` to `listener` under `options`, then hold it for live
|
|
783
|
-
* delivery. Returns the unsubscribe.
|
|
784
|
-
*
|
|
785
|
-
* The replay runs *before* the listener joins the set, which is the ordering
|
|
786
|
-
* every runner already had and is load-bearing: joining first would deliver a
|
|
787
|
-
* live event emitted mid-replay ahead of the buffered events preceding it.
|
|
788
|
-
*/
|
|
789
591
|
subscribe(events, listener, afterSeq = 0, options, resetSeq = 0) {
|
|
790
592
|
const asked = options ?? {};
|
|
791
593
|
for (const event of replaySlice(events, {
|
|
@@ -798,11 +600,9 @@ var SubscriberSet = class {
|
|
|
798
600
|
this.#listeners.delete(listener);
|
|
799
601
|
};
|
|
800
602
|
}
|
|
801
|
-
/** Drop every subscriber — a park, which ends the session's live stream. */
|
|
802
603
|
clear() {
|
|
803
604
|
this.#listeners.clear();
|
|
804
605
|
}
|
|
805
|
-
/** Fan one event out, transformed per subscriber. */
|
|
806
606
|
emit(event) {
|
|
807
607
|
for (const [listener, asked] of this.#listeners) try {
|
|
808
608
|
listener(asked.imageRefs ? refImageParts(event) : event);
|
|
@@ -810,71 +610,34 @@ var SubscriberSet = class {
|
|
|
810
610
|
}
|
|
811
611
|
};
|
|
812
612
|
//#endregion
|
|
613
|
+
//#region src/lib/title.ts
|
|
614
|
+
function hostTitle(meta) {
|
|
615
|
+
const title = meta?.title;
|
|
616
|
+
return typeof title === "string" && title.length > 0 ? title : void 0;
|
|
617
|
+
}
|
|
618
|
+
function sessionTitle(config, engineTitle) {
|
|
619
|
+
const host = hostTitle(config.meta);
|
|
620
|
+
if (host) return host;
|
|
621
|
+
if (engineTitle) return engineTitle;
|
|
622
|
+
const prompt = config.prompt;
|
|
623
|
+
if (!prompt) return;
|
|
624
|
+
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
625
|
+
}
|
|
626
|
+
function withTitle(config, title) {
|
|
627
|
+
const meta = { ...config.meta };
|
|
628
|
+
if (title) meta.title = title;
|
|
629
|
+
else delete meta.title;
|
|
630
|
+
return {
|
|
631
|
+
...config,
|
|
632
|
+
meta
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
//#endregion
|
|
813
636
|
//#region src/engines/claude/subagents.ts
|
|
814
|
-
|
|
815
|
-
* The rollup behind `SessionInfo.subagents` — what a sessions list (which never
|
|
816
|
-
* attaches) can know about the sub-agents running inside a session. Fed from
|
|
817
|
-
* `SessionRunner.#emit`, the one chokepoint every event passes through, so the
|
|
818
|
-
* resume backfill — which replays history through the same path — reconstructs
|
|
819
|
-
* it with no persistence of its own. Grouping is by Task id throughout, never
|
|
820
|
-
* adjacency: parallel sub-agents interleave in the stream, the same fact that
|
|
821
|
-
* broke the terminal theme's positional row model.
|
|
822
|
-
*
|
|
823
|
-
* Three decisions live here rather than in the protocol doc:
|
|
824
|
-
*
|
|
825
|
-
* **What counts as a spawn.** A record opens when a *top-level* assistant
|
|
826
|
-
* message carries a `tool_use` named `Task` or `Agent` — the moment the
|
|
827
|
-
* sub-agent exists, so a just-spawned agent is visible before its first nested
|
|
828
|
-
* event, with the block's input in hand for its labels. Both names are observed
|
|
829
|
-
* SDK spellings (`Task` synchronous, `Agent` async), and the name is a
|
|
830
|
-
* convention, not a law — a session that spawned three `Agent`s under a tracker
|
|
831
|
-
* that only knew `Task` reported all three as label-less failures. So three
|
|
832
|
-
* more openers back the allowlist up: the CLI's own `task_started` system event
|
|
833
|
-
* (which positively names the `tool_use_id` an agent runs under, with the brief
|
|
834
|
-
* as labels), the launch acknowledgement (below), and — as before — any nested
|
|
835
|
-
* event whose `parentToolUseId` has no record: an id that events demonstrably
|
|
836
|
-
* nest under *is* a sub-agent, whatever the spawning call was named. A fallback
|
|
837
|
-
* record never saw an input, so it stays label-less until a named signal fills
|
|
838
|
-
* it in rather than resetting an accumulated count.
|
|
839
|
-
*
|
|
840
|
-
* **A background agent's `tool_result` is a launch receipt, not a verdict.**
|
|
841
|
-
* An async agent's spawn call resolves seconds after the spawn with "Async
|
|
842
|
-
* agent launched successfully. (This tool result is internal metadata …)" —
|
|
843
|
-
* long before the agent has done anything — and its actual outcome travels on
|
|
844
|
-
* a `task_notification` system event instead (`status: 'completed'` is `done`,
|
|
845
|
-
* any other way of stopping is `failed`: the report the notification exists to
|
|
846
|
-
* deliver never came). Settling on the receipt would read "0 of 3 agents
|
|
847
|
-
* running" while three agents burn tokens, so a non-error result on a record
|
|
848
|
-
* known to be background never settles it. Known how: the `task_started` event
|
|
849
|
-
* live, or the receipt's own wrapper text on a resume — the stored transcript
|
|
850
|
-
* carries none of the CLI's system events, so, exactly as
|
|
851
|
-
* `isSyntheticUserText` documents for the `<task-notification>` blob, the text
|
|
852
|
-
* is the only signal the replayed path has.
|
|
853
|
-
*
|
|
854
|
-
* **What an interrupted turn leaves behind.** A Task whose `tool_result` never
|
|
855
|
-
* arrives — interrupt, session error, a turn or budget cap — would otherwise
|
|
856
|
-
* read `running` on an idle session forever, a lie a list re-renders at every
|
|
857
|
-
* poll. So the end of a turn settles every still-running record as `failed`:
|
|
858
|
-
* the report never came, which is the one thing `done` could have claimed. The
|
|
859
|
-
* sweep keys on `turn_result`, on the status coming to rest (`idle` — which is
|
|
860
|
-
* how a resumed history that ends mid-Task settles, since the backfill replays
|
|
861
|
-
* no `turn_result` — or a terminal state), and on the session closing. A real
|
|
862
|
-
* verdict arriving anyway outranks the sweep's inference. The sweep's premise
|
|
863
|
-
* — "the turn ended, so anything still running was cut off" — is false for a
|
|
864
|
-
* background agent, which is *designed* to outlive its turn: the real session
|
|
865
|
-
* behind this file ended three turns while its agents ran, and every
|
|
866
|
-
* `turn_result` re-branded live, working agents as failures. So the turn and
|
|
867
|
-
* idle sweeps spare a record marked background by a **live** signal. They do
|
|
868
|
-
* not spare one whose only evidence is replayed: the backfill describes a
|
|
869
|
-
* process that is gone, and a background agent the old process died inside can
|
|
870
|
-
* never notify — `running` would be the forever-lie again. `session_closed`
|
|
871
|
-
* and the terminal statuses settle everything, background included, for the
|
|
872
|
-
* same reason: the process hosting those agents is gone.
|
|
873
|
-
*/
|
|
637
|
+
const SPAWNER_NAMES = /* @__PURE__ */ new Set(["Task", "Agent"]);
|
|
874
638
|
var SubagentTracker = class {
|
|
875
639
|
#records = /* @__PURE__ */ new Map();
|
|
876
640
|
#settleCounter = 0;
|
|
877
|
-
/** Fold one emitted event body into the rollup, in log order. */
|
|
878
641
|
observe(body, ts) {
|
|
879
642
|
switch (body.type) {
|
|
880
643
|
case "assistant_message":
|
|
@@ -954,14 +717,8 @@ var SubagentTracker = class {
|
|
|
954
717
|
default: return;
|
|
955
718
|
}
|
|
956
719
|
}
|
|
957
|
-
/**
|
|
958
|
-
* The rollup as `SessionInfo.subagents` serves it: spawn order (the
|
|
959
|
-
* transcript's own), fresh objects, and `undefined` when there is nothing to
|
|
960
|
-
* say — absent and empty mean the same thing to a client, and an empty array
|
|
961
|
-
* on every row of a 1.2s-polled list is bytes spent saying nothing.
|
|
962
|
-
*/
|
|
963
720
|
list() {
|
|
964
|
-
if (this.#records.size === 0) return
|
|
721
|
+
if (this.#records.size === 0) return;
|
|
965
722
|
const out = [];
|
|
966
723
|
for (const r of this.#records.values()) out.push({
|
|
967
724
|
toolUseId: r.toolUseId,
|
|
@@ -992,13 +749,6 @@ var SubagentTracker = class {
|
|
|
992
749
|
record.agentType ??= cleaned(input?.subagent_type);
|
|
993
750
|
record.description ??= cleaned(input?.description);
|
|
994
751
|
}
|
|
995
|
-
/**
|
|
996
|
-
* End of turn (`final: false`): anything still running was cut off before
|
|
997
|
-
* its report — except a background agent the live process still hosts, which
|
|
998
|
-
* is designed to outlive the turn and settles by notification instead. End
|
|
999
|
-
* of session (`final: true`): everything, background included, because the
|
|
1000
|
-
* process those agents lived in is gone.
|
|
1001
|
-
*/
|
|
1002
752
|
#sweep(final) {
|
|
1003
753
|
for (const record of this.#records.values()) {
|
|
1004
754
|
if (record.status !== "running") continue;
|
|
@@ -1025,53 +775,32 @@ var SubagentTracker = class {
|
|
|
1025
775
|
}
|
|
1026
776
|
}
|
|
1027
777
|
};
|
|
1028
|
-
|
|
1029
|
-
* turn, `Agent` launches it in the background. Deliberately just these two —
|
|
1030
|
-
* a third spelling is caught by `task_started`, the launch receipt, or the
|
|
1031
|
-
* nested-event fallback, so widening this to every tool would only turn
|
|
1032
|
-
* ordinary calls into phantom agents. */
|
|
1033
|
-
const SPAWNER_NAMES = new Set(["Task", "Agent"]);
|
|
1034
|
-
/** The async spawn's immediate `tool_result` — "Async agent launched
|
|
1035
|
-
* successfully. (This tool result is internal metadata …)" — recognized by its
|
|
1036
|
-
* wrapper text because on a resume that text is the only signal there is (the
|
|
1037
|
-
* `SYNTHETIC_USER_PREFIXES` argument; the CLI's system events are not stored).
|
|
1038
|
-
* Live, `task_started` marks the record first and this is redundant armor. */
|
|
1039
|
-
const isLaunchAck = (content) => {
|
|
778
|
+
function isLaunchAck(content) {
|
|
1040
779
|
const text = typeof content === "string" ? content : firstText(Array.isArray(content) ? content : []);
|
|
1041
780
|
return typeof text === "string" && text.trimStart().startsWith("Async agent launched");
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
* `tool-use-id` (this rollup's key) and the `status` verdict are read. */
|
|
1046
|
-
const parseTaskNotification = (text) => {
|
|
1047
|
-
if (text === void 0 || !text.trimStart().startsWith("<task-notification>")) return void 0;
|
|
781
|
+
}
|
|
782
|
+
function parseTaskNotification(text) {
|
|
783
|
+
if (text === void 0 || !text.trimStart().startsWith("<task-notification>")) return;
|
|
1048
784
|
const toolUseId = /<tool-use-id>\s*([^<\s]+)\s*<\/tool-use-id>/.exec(text)?.[1];
|
|
1049
|
-
if (toolUseId === void 0) return
|
|
785
|
+
if (toolUseId === void 0) return;
|
|
1050
786
|
return {
|
|
1051
787
|
toolUseId,
|
|
1052
788
|
status: /<status>\s*([^<]*?)\s*<\/status>/.exec(text)?.[1] ?? ""
|
|
1053
789
|
};
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
* stored transcript uses bare strings, the live stream uses blocks. */
|
|
1057
|
-
const firstText = (content) => {
|
|
790
|
+
}
|
|
791
|
+
function firstText(content) {
|
|
1058
792
|
if (typeof content === "string") return content;
|
|
1059
793
|
for (const block of content) {
|
|
1060
794
|
const b = block;
|
|
1061
795
|
if (b?.type === "text" && typeof b.text === "string") return b.text;
|
|
1062
796
|
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
* bounded here rather than trusted — a 10KB `description` would be paid for at
|
|
1067
|
-
* every poll. */
|
|
1068
|
-
const cleaned = (value) => {
|
|
1069
|
-
if (typeof value !== "string") return void 0;
|
|
797
|
+
}
|
|
798
|
+
function cleaned(value) {
|
|
799
|
+
if (typeof value !== "string") return;
|
|
1070
800
|
const text = value.trim();
|
|
1071
|
-
if (text === "") return
|
|
801
|
+
if (text === "") return;
|
|
1072
802
|
return text.length > 80 ? text.slice(0, 79) + "…" : text;
|
|
1073
|
-
}
|
|
1074
|
-
/** The `tool_use` blocks of a message body, however the content is spelled. */
|
|
803
|
+
}
|
|
1075
804
|
function toolUseBlocks(content) {
|
|
1076
805
|
if (typeof content === "string") return [];
|
|
1077
806
|
const blocks = [];
|
|
@@ -1090,37 +819,13 @@ function toolUseBlocks(content) {
|
|
|
1090
819
|
//#endregion
|
|
1091
820
|
//#region src/engines/claude/runner.ts
|
|
1092
821
|
const DEFAULT_APPROVAL_TIMEOUT_MS$1 = 3e5;
|
|
1093
|
-
/**
|
|
1094
|
-
* One live Agent SDK session: owns the query() call, the streaming input queue, the
|
|
1095
|
-
* pending-approval table, and a seq-numbered event log that subscribers can replay.
|
|
1096
|
-
* No transport — the server (or any host) subscribes and bridges to the wire.
|
|
1097
|
-
*/
|
|
1098
822
|
var SessionRunner = class {
|
|
1099
823
|
id;
|
|
1100
824
|
createdAt;
|
|
1101
825
|
#config;
|
|
1102
|
-
/** {@link SessionRunnerConfig.cwd}, checked once in the constructor. */
|
|
1103
826
|
#cwd;
|
|
1104
|
-
#
|
|
827
|
+
#log = new EventLog();
|
|
1105
828
|
#subscribers = new SubscriberSet();
|
|
1106
|
-
#seq = 0;
|
|
1107
|
-
/**
|
|
1108
|
-
* Latest context-window reading, retained from the last `context_usage` this
|
|
1109
|
-
* runner emitted so `GET /sessions` can answer it without an attach — see
|
|
1110
|
-
* `SessionInfo.contextUsage`. Folded in the emit path, so it is by
|
|
1111
|
-
* construction the same number the transcript last drew.
|
|
1112
|
-
*/
|
|
1113
|
-
#contextUsage;
|
|
1114
|
-
#activityCount = 0;
|
|
1115
|
-
/**
|
|
1116
|
-
* Seq of the latest `conversation_reset` event, 0 when none. The log itself is
|
|
1117
|
-
* never truncated — it still carries the state-bearing events (`capabilities`,
|
|
1118
|
-
* `system_init`, …) a fresh attacher depends on and which are not re-emitted —
|
|
1119
|
-
* but `subscribe()` skips transcript *content* strictly below this mark, so a
|
|
1120
|
-
* replay does not resurrect a cleared conversation. A later reset supersedes
|
|
1121
|
-
* an earlier one by overwriting it.
|
|
1122
|
-
*/
|
|
1123
|
-
#resetSeq = 0;
|
|
1124
829
|
#status = "starting";
|
|
1125
830
|
#statusDetail;
|
|
1126
831
|
#sdkSessionId;
|
|
@@ -1128,39 +833,14 @@ var SessionRunner = class {
|
|
|
1128
833
|
#apiKeySource;
|
|
1129
834
|
#permissionMode;
|
|
1130
835
|
#pending = /* @__PURE__ */ new Map();
|
|
1131
|
-
/**
|
|
1132
|
-
* The turn ended while an approval was standing, and nothing has started a
|
|
1133
|
-
* new one since.
|
|
1134
|
-
*
|
|
1135
|
-
* `awaiting_approval` rightly outranks `idle` for display, so a turn-over
|
|
1136
|
-
* signal arriving under a standing approval cannot be applied when it lands.
|
|
1137
|
-
* It used to be **discarded** for that reason, which is a different thing
|
|
1138
|
-
* from outranked: the settle path then asserted `running` on the assumption
|
|
1139
|
-
* that an answered approval means work resumes, and when the turn was already
|
|
1140
|
-
* over — an interrupt, a timeout — the session claimed to be running one that
|
|
1141
|
-
* had produced its result. Status is purely edge-driven here, with no poll and
|
|
1142
|
-
* no reconciliation anywhere, so that single dropped edge never came back and
|
|
1143
|
-
* every client rendered it faithfully for the life of the session.
|
|
1144
|
-
*
|
|
1145
|
-
* So the fact is *deferred* rather than dropped, and it is deliberately
|
|
1146
|
-
* cleared the moment work genuinely resumes — a turn-over belongs to the turn
|
|
1147
|
-
* that produced it and must not settle the next one.
|
|
1148
|
-
*/
|
|
1149
836
|
#turnOverWhileBlocked = false;
|
|
1150
|
-
/** The read-time sub-agent rollup (`SessionInfo.subagents`), fed from #emit —
|
|
1151
|
-
* the one chokepoint — so the resume backfill reconstructs it for free. */
|
|
1152
837
|
#subagents = new SubagentTracker();
|
|
1153
838
|
#totalCostUsd;
|
|
1154
839
|
#numTurns;
|
|
1155
|
-
#lastActivityAt;
|
|
1156
840
|
#input = new InputQueue();
|
|
1157
841
|
#query;
|
|
1158
842
|
#capabilitiesEmitted = false;
|
|
1159
|
-
/** Last plan reported by the usage poll, so `plan_info` is emitted on change
|
|
1160
|
-
* rather than once per turn. */
|
|
1161
843
|
#subscriptionType;
|
|
1162
|
-
/** The title the CLI gave this thread (see `#fetchEngineTitle`). Undefined
|
|
1163
|
-
* until it has one — a session gets its summary a turn or two in. */
|
|
1164
844
|
#engineTitle;
|
|
1165
845
|
#started = false;
|
|
1166
846
|
#closed = false;
|
|
@@ -1180,9 +860,8 @@ var SessionRunner = class {
|
|
|
1180
860
|
return this.#sdkSessionId;
|
|
1181
861
|
}
|
|
1182
862
|
get lastSeq() {
|
|
1183
|
-
return this.#seq;
|
|
863
|
+
return this.#log.seq;
|
|
1184
864
|
}
|
|
1185
|
-
/** 'oauth' = claude.ai subscription credentials; other values are API-key provenance. */
|
|
1186
865
|
get apiKeySource() {
|
|
1187
866
|
return this.#apiKeySource;
|
|
1188
867
|
}
|
|
@@ -1203,49 +882,23 @@ var SessionRunner = class {
|
|
|
1203
882
|
canBypassPermissions: this.#config.permissionMode === "bypassPermissions" || this.#config.allowDangerouslySkipPermissions === true,
|
|
1204
883
|
apiKeySource: this.#apiKeySource,
|
|
1205
884
|
createdAt: this.createdAt,
|
|
1206
|
-
lastSeq: this.#seq,
|
|
1207
|
-
activityCount: this.#activityCount,
|
|
1208
|
-
|
|
885
|
+
lastSeq: this.#log.seq,
|
|
886
|
+
activityCount: this.#log.activityCount,
|
|
887
|
+
proseCount: this.#log.proseCount,
|
|
888
|
+
contextUsage: this.#log.contextUsage,
|
|
1209
889
|
pendingPermissionCount: this.#pending.size,
|
|
1210
890
|
subagents: this.#subagents.list(),
|
|
1211
891
|
meta: this.#config.meta,
|
|
1212
892
|
scope: this.#config.scope,
|
|
1213
|
-
title: this.#
|
|
893
|
+
title: sessionTitle(this.#config, this.#engineTitle),
|
|
1214
894
|
totalCostUsd: this.#totalCostUsd,
|
|
1215
895
|
numTurns: this.#numTurns,
|
|
1216
|
-
lastActivityAt: this.#lastActivityAt
|
|
896
|
+
lastActivityAt: this.#log.lastActivityAt
|
|
1217
897
|
};
|
|
1218
898
|
}
|
|
1219
|
-
/**
|
|
1220
|
-
* Three sources, most-deliberate first: the host's own rename (`meta.title`),
|
|
1221
|
-
* the title the CLI gave this thread (`#engineTitle`), then the first prompt
|
|
1222
|
-
* truncated.
|
|
1223
|
-
*
|
|
1224
|
-
* The rename outranks everything by design — a person naming a session must
|
|
1225
|
-
* not have it renamed under them by a model — which is also why the engine
|
|
1226
|
-
* title is *only ever read* while `meta.title` is unset (see
|
|
1227
|
-
* `#fetchEngineTitle`), rather than read and then discarded here.
|
|
1228
|
-
*/
|
|
1229
|
-
#title() {
|
|
1230
|
-
const metaTitle = this.#config.meta?.title;
|
|
1231
|
-
if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
|
|
1232
|
-
if (this.#engineTitle) return this.#engineTitle;
|
|
1233
|
-
const prompt = this.#config.prompt;
|
|
1234
|
-
if (!prompt) return void 0;
|
|
1235
|
-
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
1236
|
-
}
|
|
1237
|
-
/** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
|
|
1238
|
-
* it (undefined) restores the derived title. The engine is never told. */
|
|
1239
899
|
setTitle(title) {
|
|
1240
|
-
|
|
1241
|
-
if (title) meta.title = title;
|
|
1242
|
-
else delete meta.title;
|
|
1243
|
-
this.#config = {
|
|
1244
|
-
...this.#config,
|
|
1245
|
-
meta
|
|
1246
|
-
};
|
|
900
|
+
this.#config = withTitle(this.#config, title);
|
|
1247
901
|
}
|
|
1248
|
-
/** Begin the session. Idempotent; returns the run promise (resolves when the query ends). */
|
|
1249
902
|
start() {
|
|
1250
903
|
if (this.#started) return this.#runPromise;
|
|
1251
904
|
this.#started = true;
|
|
@@ -1253,11 +906,6 @@ var SessionRunner = class {
|
|
|
1253
906
|
this.#runPromise = this.#run();
|
|
1254
907
|
return this.#runPromise;
|
|
1255
908
|
}
|
|
1256
|
-
/** Queue a user message for the session (starts the next turn when idle).
|
|
1257
|
-
*
|
|
1258
|
-
* `attachments` carry their own bytes; they reach the CLI as content blocks and
|
|
1259
|
-
* are logged as references. A message may be attachments alone — an empty text
|
|
1260
|
-
* block is not valid API input, so the text is only added when there is some. */
|
|
1261
909
|
sendMessage(text, attachments) {
|
|
1262
910
|
if (this.#closed) throw new Error("session is closed");
|
|
1263
911
|
const blocks = attachments?.length ? attachmentContentBlocks(attachments) : [];
|
|
@@ -1285,12 +933,9 @@ var SessionRunner = class {
|
|
|
1285
933
|
uuid: randomUUID()
|
|
1286
934
|
});
|
|
1287
935
|
}
|
|
1288
|
-
/** Live MCP server status, straight from the CLI. Undefined when the engine
|
|
1289
|
-
* can't answer (an injected fake query in tests) — the caller 501s rather than
|
|
1290
|
-
* pretending the session has no servers. */
|
|
1291
936
|
async mcpServers() {
|
|
1292
937
|
const query = this.#query;
|
|
1293
|
-
if (typeof query?.mcpServerStatus !== "function") return
|
|
938
|
+
if (typeof query?.mcpServerStatus !== "function") return;
|
|
1294
939
|
return (await query.mcpServerStatus()).map(mcpStatusInfo);
|
|
1295
940
|
}
|
|
1296
941
|
async reconnectMcpServer(name) {
|
|
@@ -1303,7 +948,6 @@ var SessionRunner = class {
|
|
|
1303
948
|
if (typeof query?.toggleMcpServer !== "function") throw new Error("this session cannot enable or disable MCP servers");
|
|
1304
949
|
await query.toggleMcpServer(name, enabled);
|
|
1305
950
|
}
|
|
1306
|
-
/** Resolve a pending permission request. Returns false if the id is unknown (e.g. timed out). */
|
|
1307
951
|
resolvePermission(requestId, decision) {
|
|
1308
952
|
const pending = this.#pending.get(requestId);
|
|
1309
953
|
if (!pending) return false;
|
|
@@ -1313,24 +957,6 @@ var SessionRunner = class {
|
|
|
1313
957
|
async interrupt() {
|
|
1314
958
|
await this.#query?.interrupt();
|
|
1315
959
|
}
|
|
1316
|
-
/**
|
|
1317
|
-
* Reset the conversation by sending the `/clear` the CLI already honors.
|
|
1318
|
-
*
|
|
1319
|
-
* Deliberately not a second mechanism: this engine's reset arrives *from the
|
|
1320
|
-
* SDK*, and `normalizeSdkMessage` turns the CLI's report of it into the
|
|
1321
|
-
* `conversation_reset` event (adopting the new conversation id and re-polling
|
|
1322
|
-
* context usage on the way through). Reimplementing the clear here would give
|
|
1323
|
-
* one engine two ways to reach the same state, and only one of them would get
|
|
1324
|
-
* the id adoption right. So the command and the composer's `/clear` are one
|
|
1325
|
-
* behaviour, and this method is the thin end of it.
|
|
1326
|
-
*
|
|
1327
|
-
* The one place it differs from the other two engines: this resolves when the
|
|
1328
|
-
* `/clear` has been **handed to the CLI**, not when the reset has happened —
|
|
1329
|
-
* the CLI queues its own streamed input, so waiting is its job, and there is
|
|
1330
|
-
* no chain here to ride. The observable contract is the same (a clear sent
|
|
1331
|
-
* mid-turn queues rather than cutting the turn short); only the moment the
|
|
1332
|
-
* promise settles is weaker, and no caller depends on it.
|
|
1333
|
-
*/
|
|
1334
960
|
async clearContext() {
|
|
1335
961
|
if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
|
|
1336
962
|
this.sendMessage("/clear");
|
|
@@ -1343,7 +969,6 @@ var SessionRunner = class {
|
|
|
1343
969
|
mode
|
|
1344
970
|
});
|
|
1345
971
|
}
|
|
1346
|
-
/** Switch the model for subsequent responses; undefined = back to the default. */
|
|
1347
972
|
async setModel(model) {
|
|
1348
973
|
await this.#query?.setModel(model);
|
|
1349
974
|
this.#model = model;
|
|
@@ -1352,7 +977,6 @@ var SessionRunner = class {
|
|
|
1352
977
|
model
|
|
1353
978
|
});
|
|
1354
979
|
}
|
|
1355
|
-
/** Emit a session_error and terminate. For host-enforced policy (e.g. requireApiKey). */
|
|
1356
980
|
fail(message) {
|
|
1357
981
|
if (this.#closed) return;
|
|
1358
982
|
this.#emit({
|
|
@@ -1362,7 +986,6 @@ var SessionRunner = class {
|
|
|
1362
986
|
this.#setStatus("failed");
|
|
1363
987
|
this.close("error");
|
|
1364
988
|
}
|
|
1365
|
-
/** Terminate the session and the underlying CLI subprocess. */
|
|
1366
989
|
close(reason = "client") {
|
|
1367
990
|
if (this.#closed) return;
|
|
1368
991
|
this.#closed = true;
|
|
@@ -1378,27 +1001,11 @@ var SessionRunner = class {
|
|
|
1378
1001
|
});
|
|
1379
1002
|
this.#setStatus("closed");
|
|
1380
1003
|
}
|
|
1381
|
-
/** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
|
|
1382
|
-
* "show everything" on one row, so a per-runner seq index would be a map
|
|
1383
|
-
* maintained on every emit to save a walk nobody makes twice a minute. */
|
|
1384
1004
|
eventAt(seq) {
|
|
1385
|
-
return this.#
|
|
1005
|
+
return this.#log.at(seq);
|
|
1386
1006
|
}
|
|
1387
|
-
/**
|
|
1388
|
-
* Replay buffered events with seq > afterSeq, then deliver live events.
|
|
1389
|
-
* Returns an unsubscribe function.
|
|
1390
|
-
*
|
|
1391
|
-
* Replay honours the reset watermark: transcript content below the latest
|
|
1392
|
-
* `conversation_reset` is skipped (the reducer would clear it again anyway,
|
|
1393
|
-
* and a pre-reset client that never learned the reducer's case would render
|
|
1394
|
-
* a conversation the engine has discarded), while state-bearing events —
|
|
1395
|
-
* which are emitted once and never again — always replay. The reset event
|
|
1396
|
-
* itself replays (the skip is strictly-below), which is what clears a
|
|
1397
|
-
* reconnecting client still holding pre-reset rows; superseded resets are
|
|
1398
|
-
* content below the newer one and are skipped with what they cleared.
|
|
1399
|
-
*/
|
|
1400
1007
|
subscribe(listener, afterSeq = 0, options) {
|
|
1401
|
-
return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
|
|
1008
|
+
return this.#subscribers.subscribe(this.#log.events, listener, afterSeq, options, this.#log.resetSeq);
|
|
1402
1009
|
}
|
|
1403
1010
|
async #run() {
|
|
1404
1011
|
const queryFn = this.#config.queryFn ?? query;
|
|
@@ -1436,12 +1043,6 @@ var SessionRunner = class {
|
|
|
1436
1043
|
}
|
|
1437
1044
|
}
|
|
1438
1045
|
}
|
|
1439
|
-
/**
|
|
1440
|
-
* On resume, emit the prior session's transcript as replay events (seq'd before any
|
|
1441
|
-
* live event). The SDK only re-streams *user* messages on resume; assistant history
|
|
1442
|
-
* would otherwise be lost to clients attaching after a server restart. Duplicated
|
|
1443
|
-
* user messages are deduped client-side by uuid.
|
|
1444
|
-
*/
|
|
1445
1046
|
async #backfillHistory() {
|
|
1446
1047
|
const c = this.#config;
|
|
1447
1048
|
if (!c.resume || c.backfillHistory === false) return;
|
|
@@ -1552,11 +1153,6 @@ var SessionRunner = class {
|
|
|
1552
1153
|
}
|
|
1553
1154
|
}
|
|
1554
1155
|
}
|
|
1555
|
-
/** Ask the CLI what models/commands it supports and surface them as an event
|
|
1556
|
-
* (replayed to late attachers). Called eagerly for promptless sessions and again
|
|
1557
|
-
* on init — the flag keeps it a single emit. Optional-chained: injected fake
|
|
1558
|
-
* queries in tests may not implement these, and a failure must not affect the
|
|
1559
|
-
* session. */
|
|
1560
1156
|
async #fetchCapabilities() {
|
|
1561
1157
|
if (this.#capabilitiesEmitted) return;
|
|
1562
1158
|
const query = this.#query;
|
|
@@ -1578,33 +1174,8 @@ var SessionRunner = class {
|
|
|
1578
1174
|
});
|
|
1579
1175
|
} catch {}
|
|
1580
1176
|
}
|
|
1581
|
-
/**
|
|
1582
|
-
* Adopt the title the CLI gave this thread — the "friendly title" it writes a
|
|
1583
|
-
* turn or two into a session, and the name a resumed thread already carries.
|
|
1584
|
-
*
|
|
1585
|
-
* A **poll, not an observation**, and unavoidably so: no member of the SDK's
|
|
1586
|
-
* `SDKMessage` union carries it (the whole union was checked). It lives on
|
|
1587
|
-
* `SDKSessionInfo`, which only `getSessionInfo` / `listSessions` return — the
|
|
1588
|
-
* same record `GET /sdk-sessions` already serves as `SdkSessionSummary`. So it
|
|
1589
|
-
* is read at init and after each turn, which is also roughly the rate at which
|
|
1590
|
-
* it changes.
|
|
1591
|
-
*
|
|
1592
|
-
* Two rules:
|
|
1593
|
-
* - **Never while `meta.title` is set.** A rename is a person's decision and a
|
|
1594
|
-
* generated summary must not overwrite it. Not read at all in that case, so
|
|
1595
|
-
* there is no stored value waiting to resurface if the rename is cleared —
|
|
1596
|
-
* the next turn simply fetches it again.
|
|
1597
|
-
* - `summary` falls back to the first prompt when the session has no real
|
|
1598
|
-
* title yet, so it is taken only when it *differs* from `firstPrompt`.
|
|
1599
|
-
* Otherwise `#title()`'s own prompt fallback covers it, and the two would
|
|
1600
|
-
* disagree only in how they truncate.
|
|
1601
|
-
*
|
|
1602
|
-
* Best-effort throughout: an unreadable transcript, a session file that is not
|
|
1603
|
-
* there yet, an SDK without the function — all leave the title as it was.
|
|
1604
|
-
*/
|
|
1605
1177
|
async #fetchEngineTitle() {
|
|
1606
|
-
|
|
1607
|
-
if (typeof metaTitle === "string" && metaTitle.length > 0) return;
|
|
1178
|
+
if (hostTitle(this.#config.meta)) return;
|
|
1608
1179
|
const sdkSessionId = this.#sdkSessionId;
|
|
1609
1180
|
if (!sdkSessionId) return;
|
|
1610
1181
|
const read = this.#config.sessionInfoFn ?? getSessionInfo;
|
|
@@ -1616,8 +1187,6 @@ var SessionRunner = class {
|
|
|
1616
1187
|
if (title) this.#engineTitle = title;
|
|
1617
1188
|
} catch {}
|
|
1618
1189
|
}
|
|
1619
|
-
/** Snapshot the context window after a turn and surface it as an event. Optional-chained
|
|
1620
|
-
* and best-effort for the same reasons as #fetchCapabilities. */
|
|
1621
1190
|
async #fetchContextUsage() {
|
|
1622
1191
|
const query = this.#query;
|
|
1623
1192
|
if (typeof query?.getContextUsage !== "function") return;
|
|
@@ -1640,17 +1209,6 @@ var SessionRunner = class {
|
|
|
1640
1209
|
});
|
|
1641
1210
|
} catch {}
|
|
1642
1211
|
}
|
|
1643
|
-
/**
|
|
1644
|
-
* Snapshot the plan's rate-limit windows and surface them as `rate_limit`
|
|
1645
|
-
* events — the same event a live `rate_limit_event` produces, so clients need
|
|
1646
|
-
* nothing new to render it.
|
|
1647
|
-
*
|
|
1648
|
-
* The CLI only *pushes* a window when it changes, which for a session being
|
|
1649
|
-
* watched rather than driven can be never; polling is what makes usage show up
|
|
1650
|
-
* at all. The control request is marked experimental in the SDK, name included,
|
|
1651
|
-
* so it is probed for by name and every failure is silent — one more reason
|
|
1652
|
-
* this can only ever be decoration.
|
|
1653
|
-
*/
|
|
1654
1212
|
async #fetchRateLimits() {
|
|
1655
1213
|
const query = this.#query;
|
|
1656
1214
|
const fetchUsage = query?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
|
|
@@ -1716,10 +1274,6 @@ var SessionRunner = class {
|
|
|
1716
1274
|
this.#setStatus("awaiting_approval");
|
|
1717
1275
|
});
|
|
1718
1276
|
};
|
|
1719
|
-
/** 'auto'/'deny' sessions settle AskUserQuestion synchronously instead of pending:
|
|
1720
|
-
* 'auto' picks each question's first (recommended) option, 'deny' sends the model
|
|
1721
|
-
* back to decide for itself. Request/resolved events still fire so transcripts and
|
|
1722
|
-
* job webhooks show what was chosen. */
|
|
1723
1277
|
#resolveQuestionByPolicy(request, mode) {
|
|
1724
1278
|
this.#emit({
|
|
1725
1279
|
type: "permission_requested",
|
|
@@ -1795,26 +1349,11 @@ var SessionRunner = class {
|
|
|
1795
1349
|
});
|
|
1796
1350
|
}
|
|
1797
1351
|
#emit(body) {
|
|
1798
|
-
const event =
|
|
1799
|
-
...body,
|
|
1800
|
-
seq: ++this.#seq,
|
|
1801
|
-
ts: Date.now()
|
|
1802
|
-
};
|
|
1803
|
-
this.#lastActivityAt = event.ts;
|
|
1804
|
-
this.#activityCount += transcriptActivity(body);
|
|
1805
|
-
this.#contextUsage = contextReading(body) ?? this.#contextUsage;
|
|
1806
|
-
if (body.type === "conversation_reset") {
|
|
1807
|
-
this.#resetSeq = event.seq;
|
|
1808
|
-
this.#contextUsage = void 0;
|
|
1809
|
-
}
|
|
1352
|
+
const event = this.#log.append(body);
|
|
1810
1353
|
this.#subagents.observe(body, event.ts);
|
|
1811
|
-
this.#events.push(event);
|
|
1812
1354
|
this.#subscribers.emit(event);
|
|
1813
1355
|
}
|
|
1814
1356
|
};
|
|
1815
|
-
/** Answer each AskUserQuestion question with its first option's label — the tool's
|
|
1816
|
-
* convention puts the recommended choice first. Keyed by question text, the shape the
|
|
1817
|
-
* CLI expects back in `updatedInput.answers`. */
|
|
1818
1357
|
function recommendedAnswers(input) {
|
|
1819
1358
|
const answers = {};
|
|
1820
1359
|
const questions = Array.isArray(input.questions) ? input.questions : [];
|
|
@@ -1828,60 +1367,26 @@ function recommendedAnswers(input) {
|
|
|
1828
1367
|
}
|
|
1829
1368
|
//#endregion
|
|
1830
1369
|
//#region src/engines/provider/runner.ts
|
|
1831
|
-
/** Permission modes this engine can honor. The rest of the protocol vocabulary
|
|
1832
|
-
* (acceptEdits/plan/auto) is Claude Code CLI semantics with no meaning here —
|
|
1833
|
-
* setPermissionMode rejects them, which the server surfaces as protocol_error. */
|
|
1834
1370
|
const SUPPORTED_PERMISSION_MODES = [
|
|
1835
1371
|
"default",
|
|
1836
1372
|
"bypassPermissions",
|
|
1837
1373
|
"dontAsk"
|
|
1838
1374
|
];
|
|
1839
|
-
/**
|
|
1840
|
-
* Model-agnostic runner over the AI SDK v7 ToolLoopAgent. The session's durable
|
|
1841
|
-
* state is its ModelMessage history: every turn — including continuation after an
|
|
1842
|
-
* externally-executed tool call — is a fresh streamed call over that history
|
|
1843
|
-
* (message-state replay; the loop cannot be suspended). Output is emitted as it
|
|
1844
|
-
* happens: `stream_delta` per token (unless includePartialMessages is false) and
|
|
1845
|
-
* assistant/tool messages per step. Emits the same seq-numbered SessionEvent log
|
|
1846
|
-
* as SessionRunner; engine-specific CLI telemetry (system_init, capabilities,
|
|
1847
|
-
* rate_limit, ...) is simply never emitted.
|
|
1848
|
-
*/
|
|
1849
1375
|
var AiSdkRunner = class {
|
|
1850
1376
|
id;
|
|
1851
1377
|
createdAt;
|
|
1852
1378
|
#config;
|
|
1853
1379
|
#model;
|
|
1854
|
-
#
|
|
1380
|
+
#log = new EventLog();
|
|
1855
1381
|
#subscribers = new SubscriberSet();
|
|
1856
|
-
#seq = 0;
|
|
1857
|
-
/**
|
|
1858
|
-
* Latest context-window reading, retained from the last `context_usage` this
|
|
1859
|
-
* runner emitted so `GET /sessions` can answer it without an attach — see
|
|
1860
|
-
* `SessionInfo.contextUsage`. Folded in the emit path, so it is by
|
|
1861
|
-
* construction the same number the transcript last drew.
|
|
1862
|
-
*/
|
|
1863
|
-
#contextUsage;
|
|
1864
|
-
#activityCount = 0;
|
|
1865
|
-
/**
|
|
1866
|
-
* Seq of the latest `conversation_reset` event, 0 when none. The log itself is
|
|
1867
|
-
* never truncated — it still carries the state-bearing events (`capabilities`,
|
|
1868
|
-
* `system_init`, …) a fresh attacher depends on and which are not re-emitted —
|
|
1869
|
-
* but `subscribe()` skips transcript *content* strictly below this mark, so a
|
|
1870
|
-
* replay does not resurrect a cleared conversation. A later reset supersedes
|
|
1871
|
-
* an earlier one by overwriting it.
|
|
1872
|
-
*/
|
|
1873
|
-
#resetSeq = 0;
|
|
1874
1382
|
#status = "starting";
|
|
1383
|
+
#statusDetail;
|
|
1875
1384
|
#permissionMode;
|
|
1876
1385
|
#messages = [];
|
|
1877
1386
|
#pendingToolCalls = /* @__PURE__ */ new Map();
|
|
1878
|
-
/** Calls already handed to the executor, so a re-park never double-dispatches. */
|
|
1879
1387
|
#dispatched = /* @__PURE__ */ new Set();
|
|
1880
1388
|
#turnChain = Promise.resolve();
|
|
1881
1389
|
#abort;
|
|
1882
|
-
/** Accumulates across every leg of one turn. A turn that parks on external
|
|
1883
|
-
* tool calls spans several generate() calls; usage and elapsed time must
|
|
1884
|
-
* cover all of them, not just the leg that happens to finish. */
|
|
1885
1390
|
#turnAccum;
|
|
1886
1391
|
#numTurns = 0;
|
|
1887
1392
|
#totalUsage = {
|
|
@@ -1890,16 +1395,10 @@ var AiSdkRunner = class {
|
|
|
1890
1395
|
cacheWrite: 0,
|
|
1891
1396
|
cacheRead: 0
|
|
1892
1397
|
};
|
|
1893
|
-
#lastActivityAt;
|
|
1894
1398
|
#started = false;
|
|
1895
1399
|
#closed = false;
|
|
1896
|
-
/** Parked: state has been snapshotted and this instance is inert. Not closed —
|
|
1897
|
-
* the session lives on in the snapshot and resumes as a new instance. */
|
|
1898
1400
|
#parked = false;
|
|
1899
|
-
/** Model alias as requested (not the resolved provider id) — what set_model was
|
|
1900
|
-
* given, so a rehydrated session can re-resolve the same choice. */
|
|
1901
1401
|
#modelAlias;
|
|
1902
|
-
/** Permission prompts awaiting a client decision. Keyed by request id. */
|
|
1903
1402
|
#pendingApprovals = /* @__PURE__ */ new Map();
|
|
1904
1403
|
constructor(config, id = randomUUID()) {
|
|
1905
1404
|
const mode = config.permissionMode ?? "default";
|
|
@@ -1912,23 +1411,11 @@ var AiSdkRunner = class {
|
|
|
1912
1411
|
this.createdAt = config.restore?.createdAt ?? Date.now();
|
|
1913
1412
|
if (config.restore) this.#restore(config.restore);
|
|
1914
1413
|
}
|
|
1915
|
-
/** Adopt a parked session's state. The event log and seq counter come back
|
|
1916
|
-
* verbatim: a client reattaching with `afterSeq` must see one unbroken stream
|
|
1917
|
-
* across the teardown, not a second session that restarts at 1. */
|
|
1918
1414
|
#restore(snapshot) {
|
|
1919
1415
|
if (snapshot.engine !== "provider") throw new Error(`cannot restore a '${snapshot.engine}' snapshot into the AI SDK engine`);
|
|
1920
1416
|
const state = snapshot.state;
|
|
1921
1417
|
if (!state || !Array.isArray(state.messages)) throw new Error("session snapshot is missing its provider-engine state");
|
|
1922
|
-
this.#
|
|
1923
|
-
this.#events = [...snapshot.events];
|
|
1924
|
-
this.#activityCount = 0;
|
|
1925
|
-
for (const event of this.#events) {
|
|
1926
|
-
this.#activityCount += transcriptActivity(event);
|
|
1927
|
-
if (event.type === "conversation_reset") {
|
|
1928
|
-
this.#resetSeq = event.seq;
|
|
1929
|
-
this.#contextUsage = void 0;
|
|
1930
|
-
} else this.#contextUsage = contextReading(event) ?? this.#contextUsage;
|
|
1931
|
-
}
|
|
1418
|
+
this.#log.restore(snapshot.events, snapshot.seq, state.lastActivityAt);
|
|
1932
1419
|
this.#messages = [...state.messages];
|
|
1933
1420
|
for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
|
|
1934
1421
|
this.#dispatched = new Set(state.dispatched);
|
|
@@ -1937,7 +1424,6 @@ var AiSdkRunner = class {
|
|
|
1937
1424
|
this.#turnAccum = state.turnAccum ? { ...state.turnAccum } : void 0;
|
|
1938
1425
|
if (this.#turnAccum && state.parkedAt !== void 0) this.#turnAccum.startedAt += Date.now() - state.parkedAt;
|
|
1939
1426
|
this.#permissionMode = state.permissionMode;
|
|
1940
|
-
this.#lastActivityAt = state.lastActivityAt;
|
|
1941
1427
|
this.#status = this.#pendingToolCalls.size > 0 ? "parked" : "idle";
|
|
1942
1428
|
if (state.model !== void 0 && state.model !== this.#modelAlias && this.#config.resolveModel) {
|
|
1943
1429
|
this.#modelAlias = state.model;
|
|
@@ -1948,21 +1434,17 @@ var AiSdkRunner = class {
|
|
|
1948
1434
|
return this.#status;
|
|
1949
1435
|
}
|
|
1950
1436
|
get lastSeq() {
|
|
1951
|
-
return this.#seq;
|
|
1437
|
+
return this.#log.seq;
|
|
1952
1438
|
}
|
|
1953
|
-
/** The session's durable state — persist to park, replay to rehydrate. */
|
|
1954
1439
|
get messages() {
|
|
1955
1440
|
return [...this.#messages];
|
|
1956
1441
|
}
|
|
1957
|
-
/** External tool calls the loop is currently parked on. */
|
|
1958
1442
|
get pendingToolCalls() {
|
|
1959
1443
|
return [...this.#pendingToolCalls.values()];
|
|
1960
1444
|
}
|
|
1961
1445
|
get pendingApprovals() {
|
|
1962
1446
|
return [...this.#pendingApprovals.values()].map((a) => a.request);
|
|
1963
1447
|
}
|
|
1964
|
-
/** The session's scratch filesystem (see Runner.vfs) — the server's file
|
|
1965
|
-
* routes serve deliverables straight from it. */
|
|
1966
1448
|
get vfs() {
|
|
1967
1449
|
return this.#config.vfs;
|
|
1968
1450
|
}
|
|
@@ -1980,15 +1462,16 @@ var AiSdkRunner = class {
|
|
|
1980
1462
|
model: this.#modelId(),
|
|
1981
1463
|
permissionMode: this.#permissionMode,
|
|
1982
1464
|
createdAt: this.createdAt,
|
|
1983
|
-
lastSeq: this.#seq,
|
|
1984
|
-
activityCount: this.#activityCount,
|
|
1985
|
-
|
|
1465
|
+
lastSeq: this.#log.seq,
|
|
1466
|
+
activityCount: this.#log.activityCount,
|
|
1467
|
+
proseCount: this.#log.proseCount,
|
|
1468
|
+
contextUsage: this.#log.contextUsage,
|
|
1986
1469
|
pendingPermissionCount: this.#pendingApprovals.size,
|
|
1987
1470
|
meta: this.#config.meta,
|
|
1988
1471
|
scope: this.#config.scope,
|
|
1989
|
-
title: this.#
|
|
1472
|
+
title: sessionTitle(this.#config),
|
|
1990
1473
|
numTurns: this.#numTurns || void 0,
|
|
1991
|
-
lastActivityAt: this.#lastActivityAt
|
|
1474
|
+
lastActivityAt: this.#log.lastActivityAt
|
|
1992
1475
|
};
|
|
1993
1476
|
}
|
|
1994
1477
|
start() {
|
|
@@ -1999,15 +1482,9 @@ var AiSdkRunner = class {
|
|
|
1999
1482
|
if (this.#config.prompt) this.sendMessage(this.#config.prompt);
|
|
2000
1483
|
return this.#turnChain;
|
|
2001
1484
|
}
|
|
2002
|
-
/**
|
|
2003
|
-
* Snapshot durable state, release engine resources, and go inert — the session
|
|
2004
|
-
* continues in the snapshot, not in this object. Returns undefined when parking
|
|
2005
|
-
* would lose work or has nothing to wait for: a turn in flight, no parked call,
|
|
2006
|
-
* or an already-closed/parked runner.
|
|
2007
|
-
*/
|
|
2008
1485
|
park() {
|
|
2009
|
-
if (this.#closed || this.#parked) return
|
|
2010
|
-
if (this.#abort || !this.#restingOnDeferred()) return
|
|
1486
|
+
if (this.#closed || this.#parked) return;
|
|
1487
|
+
if (this.#abort || !this.#restingOnDeferred()) return;
|
|
2011
1488
|
this.#setStatus("parked");
|
|
2012
1489
|
const snapshot = this.#buildSnapshot();
|
|
2013
1490
|
this.#parked = true;
|
|
@@ -2017,52 +1494,11 @@ var AiSdkRunner = class {
|
|
|
2017
1494
|
} catch {}
|
|
2018
1495
|
return snapshot;
|
|
2019
1496
|
}
|
|
2020
|
-
/**
|
|
2021
|
-
* The same snapshot, taken without ending anything.
|
|
2022
|
-
*
|
|
2023
|
-
* `park()` and this are two operations that happen to produce the same value,
|
|
2024
|
-
* and the difference is the whole point: `park()` *ends* the live runner
|
|
2025
|
-
* (inert, listeners dropped, `onClose` called), which is right for deferred
|
|
2026
|
-
* execution — the session has nothing to do for possibly days — and wrong for
|
|
2027
|
-
* restart-survival, where the session is active and someone is mid-
|
|
2028
|
-
* conversation. This one changes nothing at all: no status emit, no listener
|
|
2029
|
-
* clear, no disposer. The host writes the value through to durable storage
|
|
2030
|
-
* after each turn and keeps the runner live and warm, so a restart rebuilds
|
|
2031
|
-
* from the last write through the existing `restore` path and the next message
|
|
2032
|
-
* costs no wake.
|
|
2033
|
-
*
|
|
2034
|
-
* The gate is `park()`'s minus the requirement that there be something parked:
|
|
2035
|
-
*
|
|
2036
|
-
* - `#abort` set is refused for the reason it always was — a `generate()` in
|
|
2037
|
-
* flight has produced messages that are not in the history yet, so the
|
|
2038
|
-
* snapshot would be of a turn that half-happened.
|
|
2039
|
-
* - Pending calls that are **not** all deferred are refused, which is
|
|
2040
|
-
* `park()`'s rule wearing a different hat. An in-process execution's result
|
|
2041
|
-
* is coming back to *this* runner and dies with the process; a restore would
|
|
2042
|
-
* wait on it forever, and `state.dispatched` is what would stop the rebuilt
|
|
2043
|
-
* runner from simply calling it again.
|
|
2044
|
-
* - Idle with nothing pending — the case `park()` exists to refuse — is
|
|
2045
|
-
* exactly the case this exists to allow.
|
|
2046
|
-
*/
|
|
2047
1497
|
snapshot() {
|
|
2048
|
-
if (this.#closed || this.#parked || this.#abort) return
|
|
2049
|
-
if (this.#pendingToolCalls.size > 0 && !this.#restingOnDeferred()) return
|
|
1498
|
+
if (this.#closed || this.#parked || this.#abort) return;
|
|
1499
|
+
if (this.#pendingToolCalls.size > 0 && !this.#restingOnDeferred()) return;
|
|
2050
1500
|
return this.#buildSnapshot();
|
|
2051
1501
|
}
|
|
2052
|
-
/**
|
|
2053
|
-
* The snapshot value itself, shared so a park and a write-through cannot
|
|
2054
|
-
* disagree about what a session *is*.
|
|
2055
|
-
*
|
|
2056
|
-
* The event log is filtered through {@link snapshotRetains} — the persisted
|
|
2057
|
-
* log drops stream deltas, which are superseded by the `assistant_message`
|
|
2058
|
-
* that flushes them and would otherwise be tens of times the size of the text
|
|
2059
|
-
* they spell. Parks get it too, and should: a park sits on disk for days.
|
|
2060
|
-
*
|
|
2061
|
-
* The `parked` list and `state.parkedAt` are honest under both callers. An
|
|
2062
|
-
* idle write-through has no pending calls, so `parked` is empty and the host
|
|
2063
|
-
* arms no watchdogs; `parkedAt` is "when this was taken", which is what
|
|
2064
|
-
* `#restore` needs to discount a turn's clock either way.
|
|
2065
|
-
*/
|
|
2066
1502
|
#buildSnapshot() {
|
|
2067
1503
|
const parked = [...this.#pendingToolCalls.values()].map((call) => ({
|
|
2068
1504
|
executionId: call.toolCallId,
|
|
@@ -2078,15 +1514,15 @@ var AiSdkRunner = class {
|
|
|
2078
1514
|
turnAccum: this.#turnAccum ? { ...this.#turnAccum } : void 0,
|
|
2079
1515
|
permissionMode: this.#permissionMode,
|
|
2080
1516
|
model: this.#modelAlias,
|
|
2081
|
-
lastActivityAt: this.#lastActivityAt,
|
|
1517
|
+
lastActivityAt: this.#log.lastActivityAt,
|
|
2082
1518
|
parkedAt: Date.now()
|
|
2083
1519
|
};
|
|
2084
1520
|
return {
|
|
2085
1521
|
engine: "provider",
|
|
2086
1522
|
id: this.id,
|
|
2087
1523
|
createdAt: this.createdAt,
|
|
2088
|
-
seq: this.#seq,
|
|
2089
|
-
events: this.#events.filter((event) => snapshotRetains(event)),
|
|
1524
|
+
seq: this.#log.seq,
|
|
1525
|
+
events: this.#log.events.filter((event) => snapshotRetains(event)),
|
|
2090
1526
|
vfs: this.#config.vfs?.snapshot(),
|
|
2091
1527
|
parked,
|
|
2092
1528
|
state
|
|
@@ -2120,19 +1556,11 @@ var AiSdkRunner = class {
|
|
|
2120
1556
|
});
|
|
2121
1557
|
this.#scheduleTurn();
|
|
2122
1558
|
}
|
|
2123
|
-
/**
|
|
2124
|
-
* Deliver the result of an external (execute-less) tool call. Appends the
|
|
2125
|
-
* tool-result message and, once no calls remain pending, re-enters the loop.
|
|
2126
|
-
* Idempotent per toolCallId: unknown/already-settled ids return false.
|
|
2127
|
-
*/
|
|
2128
1559
|
resolveToolCall(toolCallId, output, options) {
|
|
2129
1560
|
if (!this.#settlePendingCall(toolCallId, output, options?.isError === true)) return false;
|
|
2130
1561
|
if (this.#pendingToolCalls.size === 0) this.#scheduleTurn();
|
|
2131
1562
|
return true;
|
|
2132
1563
|
}
|
|
2133
|
-
/** Record a parked call's outcome into the message history (so it stays
|
|
2134
|
-
* replayable — a dangling tool call without a result is invalid input for
|
|
2135
|
-
* providers) and the event log. Does NOT re-enter the loop. */
|
|
2136
1564
|
#settlePendingCall(toolCallId, output, isError) {
|
|
2137
1565
|
const pending = this.#pendingToolCalls.get(toolCallId);
|
|
2138
1566
|
if (!pending || this.#closed || this.#parked) return false;
|
|
@@ -2198,11 +1626,8 @@ var AiSdkRunner = class {
|
|
|
2198
1626
|
});
|
|
2199
1627
|
if (decision.interrupt) this.interrupt();
|
|
2200
1628
|
}
|
|
2201
|
-
if (this.#pendingApprovals.size === 0 && this.#pendingToolCalls.size === 0) {}
|
|
2202
1629
|
return true;
|
|
2203
1630
|
}
|
|
2204
|
-
/** Emit `file_delivered` — the deliver_file tool's hand-over event (wired by
|
|
2205
|
-
* createEngineSession via ToolContextOptions.onFileDelivered). */
|
|
2206
1631
|
emitFileDelivered(file) {
|
|
2207
1632
|
if (this.#closed || this.#parked) return;
|
|
2208
1633
|
this.#emit({
|
|
@@ -2210,11 +1635,6 @@ var AiSdkRunner = class {
|
|
|
2210
1635
|
...file
|
|
2211
1636
|
});
|
|
2212
1637
|
}
|
|
2213
|
-
/**
|
|
2214
|
-
* One plain generateText over the session's current model, billed into the
|
|
2215
|
-
* running turn's usage accumulator — the web_fetch digest pass uses this so
|
|
2216
|
-
* its tokens are never lost from the turn's accounting.
|
|
2217
|
-
*/
|
|
2218
1638
|
async generateDigest(prompt) {
|
|
2219
1639
|
const result = await generateText({
|
|
2220
1640
|
model: this.#model,
|
|
@@ -2230,19 +1650,6 @@ var AiSdkRunner = class {
|
|
|
2230
1650
|
}
|
|
2231
1651
|
return result.text;
|
|
2232
1652
|
}
|
|
2233
|
-
/**
|
|
2234
|
-
* Reset the conversation: drop the message array the next turn would have
|
|
2235
|
-
* been built from. There is no engine round trip — this runner *is* where the
|
|
2236
|
-
* transcript lives, so clearing it is the whole operation.
|
|
2237
|
-
*
|
|
2238
|
-
* Two things ride along, both already written elsewhere and both load-bearing
|
|
2239
|
-
* here. `#emit`'s `conversation_reset` arm retires `#contextUsage` (the
|
|
2240
|
-
* reading described a conversation that no longer exists), and the same arm
|
|
2241
|
-
* in `restore` keeps a parked session that comes back after a clear from
|
|
2242
|
-
* resurrecting it. Pending tool calls are NOT swept: a parked call is work a
|
|
2243
|
-
* backend still owes an answer for, and a clear is not an interrupt — the
|
|
2244
|
-
* refusal below is what keeps the two apart.
|
|
2245
|
-
*/
|
|
2246
1653
|
async clearContext() {
|
|
2247
1654
|
if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
|
|
2248
1655
|
const run = this.#turnChain.then(() => {
|
|
@@ -2329,31 +1736,21 @@ var AiSdkRunner = class {
|
|
|
2329
1736
|
Promise.resolve(this.#config.onClose?.()).catch(() => {});
|
|
2330
1737
|
} catch {}
|
|
2331
1738
|
}
|
|
2332
|
-
/** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
|
|
2333
|
-
* "show everything" on one row, so a per-runner seq index would be a map
|
|
2334
|
-
* maintained on every emit to save a walk nobody makes twice a minute. */
|
|
2335
1739
|
eventAt(seq) {
|
|
2336
|
-
return this.#
|
|
1740
|
+
return this.#log.at(seq);
|
|
2337
1741
|
}
|
|
2338
1742
|
subscribe(listener, afterSeq = 0, options) {
|
|
2339
|
-
return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
|
|
1743
|
+
return this.#subscribers.subscribe(this.#log.events, listener, afterSeq, options, this.#log.resetSeq);
|
|
2340
1744
|
}
|
|
2341
1745
|
#scheduleTurn() {
|
|
2342
1746
|
this.#turnChain = this.#turnChain.then(() => this.#runTurn());
|
|
2343
1747
|
}
|
|
2344
|
-
/**
|
|
2345
|
-
* Deliver the result of an execution this runner dispatched. Used by the host
|
|
2346
|
-
* when a backend settled out-of-band (a browser bridge answering later, a
|
|
2347
|
-
* deferred executor). Idempotent by executionId.
|
|
2348
|
-
*/
|
|
2349
1748
|
settleExecution(executionId, result) {
|
|
2350
1749
|
if (this.#closed || this.#parked) return false;
|
|
2351
1750
|
if (!this.#pendingToolCalls.has(executionId)) return false;
|
|
2352
1751
|
this.#applyExecutionResult(executionId, result);
|
|
2353
1752
|
return true;
|
|
2354
1753
|
}
|
|
2355
|
-
/** Hand every parked call the executor owns to it, gating on approval when
|
|
2356
|
-
* the permission mode requires it. */
|
|
2357
1754
|
#dispatchPending() {
|
|
2358
1755
|
const executor = this.#config.executor;
|
|
2359
1756
|
if (!executor) return;
|
|
@@ -2411,7 +1808,6 @@ var AiSdkRunner = class {
|
|
|
2411
1808
|
if (anyAwaiting) this.#setStatus("awaiting_approval");
|
|
2412
1809
|
if (anyDeferred) Promise.allSettled(inFlight).then(() => this.#announceParked());
|
|
2413
1810
|
}
|
|
2414
|
-
/** Dispatch a single tool call that was held behind an approval gate. */
|
|
2415
1811
|
#dispatchSingle(toolCallId) {
|
|
2416
1812
|
const executor = this.#config.executor;
|
|
2417
1813
|
if (!executor) return;
|
|
@@ -2421,7 +1817,6 @@ var AiSdkRunner = class {
|
|
|
2421
1817
|
const dispatched = this.#dispatchCall(executor, call);
|
|
2422
1818
|
if (dispatched.deferred) dispatched.promise.then(() => this.#announceParked());
|
|
2423
1819
|
}
|
|
2424
|
-
/** The actual dispatch + event emission for one tool call. */
|
|
2425
1820
|
#dispatchCall(executor, call) {
|
|
2426
1821
|
const toolCall = {
|
|
2427
1822
|
executionId: call.toolCallId,
|
|
@@ -2457,25 +1852,15 @@ var AiSdkRunner = class {
|
|
|
2457
1852
|
promise
|
|
2458
1853
|
};
|
|
2459
1854
|
}
|
|
2460
|
-
/**
|
|
2461
|
-
* The turn has come to rest on deferred executions: nothing is in flight, and
|
|
2462
|
-
* only a host-delivered result can move it. `status_changed: 'parked'` is the
|
|
2463
|
-
* host's cue to snapshot via {@link park} — a single, correctly-timed signal
|
|
2464
|
-
* rather than an inference from individual dispatch events.
|
|
2465
|
-
*/
|
|
2466
1855
|
#announceParked() {
|
|
2467
1856
|
if (this.#closed || this.#parked || this.#abort) return;
|
|
2468
1857
|
if (this.#restingOnDeferred()) this.#setStatus("parked");
|
|
2469
1858
|
}
|
|
2470
|
-
/** The loop is waiting, and everything it waits on can only be answered from
|
|
2471
|
-
* outside this process. One still-live in-process execution means a result is
|
|
2472
|
-
* coming back to THIS runner, and tearing it down would strand it. */
|
|
2473
1859
|
#restingOnDeferred() {
|
|
2474
1860
|
if (this.#pendingToolCalls.size === 0) return false;
|
|
2475
1861
|
for (const call of this.#pendingToolCalls.values()) if (call.deferred !== true) return false;
|
|
2476
1862
|
return true;
|
|
2477
1863
|
}
|
|
2478
|
-
/** Fold an execution's outcome back into the loop, whichever way it went. */
|
|
2479
1864
|
#applyExecutionResult(executionId, result) {
|
|
2480
1865
|
if (this.#closed || this.#parked) return;
|
|
2481
1866
|
this.#dispatched.delete(executionId);
|
|
@@ -2637,10 +2022,7 @@ var AiSdkRunner = class {
|
|
|
2637
2022
|
case "finish-step":
|
|
2638
2023
|
flush();
|
|
2639
2024
|
break;
|
|
2640
|
-
case "error":
|
|
2641
|
-
streamError ??= part.error;
|
|
2642
|
-
break;
|
|
2643
|
-
default: break;
|
|
2025
|
+
case "error": streamError ??= part.error;
|
|
2644
2026
|
}
|
|
2645
2027
|
}
|
|
2646
2028
|
flush();
|
|
@@ -2705,9 +2087,6 @@ var AiSdkRunner = class {
|
|
|
2705
2087
|
if (this.#abort === abort) this.#abort = void 0;
|
|
2706
2088
|
}
|
|
2707
2089
|
}
|
|
2708
|
-
/** Emit the turn's result from the whole-turn accumulator, so a turn that
|
|
2709
|
-
* parked on external tool calls reports every leg's tokens and the full
|
|
2710
|
-
* elapsed time (including the time spent executing those tools). */
|
|
2711
2090
|
#finishTurn(text) {
|
|
2712
2091
|
const accum = this.#turnAccum ?? {
|
|
2713
2092
|
startedAt: Date.now(),
|
|
@@ -2739,39 +2118,17 @@ var AiSdkRunner = class {
|
|
|
2739
2118
|
if (typeof model === "string") return model;
|
|
2740
2119
|
return model.modelId;
|
|
2741
2120
|
}
|
|
2742
|
-
#title() {
|
|
2743
|
-
const metaTitle = this.#config.meta?.title;
|
|
2744
|
-
if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
|
|
2745
|
-
const prompt = this.#config.prompt;
|
|
2746
|
-
if (!prompt) return void 0;
|
|
2747
|
-
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
2748
|
-
}
|
|
2749
|
-
/**
|
|
2750
|
-
* This session's MCP servers, as the host assembled them.
|
|
2751
|
-
*
|
|
2752
|
-
* Always answers — an empty list when no MCP was wired — because the
|
|
2753
|
-
* alternative (undefined, which the server turns into a 501) says "this
|
|
2754
|
-
* engine cannot tell you", and this engine can: the host that built the
|
|
2755
|
-
* session is the only party who knows, and it has been asked.
|
|
2756
|
-
*/
|
|
2757
2121
|
async mcpServers() {
|
|
2758
2122
|
return await this.#config.reportMcpServers?.() ?? [];
|
|
2759
2123
|
}
|
|
2760
|
-
/** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
|
|
2761
|
-
* it (undefined) restores the derived title. The engine is never told. */
|
|
2762
2124
|
setTitle(title) {
|
|
2763
|
-
|
|
2764
|
-
if (title) meta.title = title;
|
|
2765
|
-
else delete meta.title;
|
|
2766
|
-
this.#config = {
|
|
2767
|
-
...this.#config,
|
|
2768
|
-
meta
|
|
2769
|
-
};
|
|
2125
|
+
this.#config = withTitle(this.#config, title);
|
|
2770
2126
|
}
|
|
2771
2127
|
#setStatus(status, detail) {
|
|
2772
|
-
if (this.#status === status) return;
|
|
2128
|
+
if (this.#status === status && this.#statusDetail === detail) return;
|
|
2773
2129
|
if (this.#status === "closed" || this.#status === "failed") return;
|
|
2774
2130
|
this.#status = status;
|
|
2131
|
+
this.#statusDetail = detail;
|
|
2775
2132
|
this.#emit({
|
|
2776
2133
|
type: "status_changed",
|
|
2777
2134
|
status,
|
|
@@ -2779,20 +2136,7 @@ var AiSdkRunner = class {
|
|
|
2779
2136
|
});
|
|
2780
2137
|
}
|
|
2781
2138
|
#emit(body) {
|
|
2782
|
-
|
|
2783
|
-
...body,
|
|
2784
|
-
seq: ++this.#seq,
|
|
2785
|
-
ts: Date.now()
|
|
2786
|
-
};
|
|
2787
|
-
this.#lastActivityAt = event.ts;
|
|
2788
|
-
this.#activityCount += transcriptActivity(body);
|
|
2789
|
-
this.#contextUsage = contextReading(body) ?? this.#contextUsage;
|
|
2790
|
-
if (body.type === "conversation_reset") {
|
|
2791
|
-
this.#resetSeq = event.seq;
|
|
2792
|
-
this.#contextUsage = void 0;
|
|
2793
|
-
}
|
|
2794
|
-
this.#events.push(event);
|
|
2795
|
-
this.#subscribers.emit(event);
|
|
2139
|
+
this.#subscribers.emit(this.#log.append(body));
|
|
2796
2140
|
}
|
|
2797
2141
|
};
|
|
2798
2142
|
function turnUsage(accum) {
|
|
@@ -2811,19 +2155,10 @@ function errorText(error) {
|
|
|
2811
2155
|
}
|
|
2812
2156
|
//#endregion
|
|
2813
2157
|
//#region src/engines/claude/auth.ts
|
|
2814
|
-
/**
|
|
2815
|
-
* The native Claude Code binary the Agent SDK itself spawns, resolved the way
|
|
2816
|
-
* the SDK resolves it: the platform-specific optional dependency installed next
|
|
2817
|
-
* to the SDK package (`@anthropic-ai/claude-agent-sdk-<platform>-<arch>/claude`).
|
|
2818
|
-
* Probing this binary rather than whatever `claude` is on PATH means an auth
|
|
2819
|
-
* check answers for the executable sessions will actually run — the two can be
|
|
2820
|
-
* different versions logged into different places. Returns undefined when it
|
|
2821
|
-
* can't be found (optional dep skipped, unsupported platform); callers degrade
|
|
2822
|
-
* to 'unknown', and the SDK surfaces its own error if a session is created.
|
|
2823
|
-
*/
|
|
2824
2158
|
function resolveBundledClaudeExecutable() {
|
|
2825
2159
|
try {
|
|
2826
|
-
const
|
|
2160
|
+
const fromHere = createRequire(import.meta.url);
|
|
2161
|
+
const fromSdk = createRequire(fromHere.resolve("@anthropic-ai/claude-agent-sdk"));
|
|
2827
2162
|
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
2828
2163
|
const platforms = process.platform === "linux" ? [`linux-${process.arch}`, `linux-${process.arch}-musl`] : [`${process.platform}-${process.arch}`];
|
|
2829
2164
|
for (const platform of platforms) try {
|
|
@@ -2832,18 +2167,6 @@ function resolveBundledClaudeExecutable() {
|
|
|
2832
2167
|
} catch {}
|
|
2833
2168
|
} catch {}
|
|
2834
2169
|
}
|
|
2835
|
-
/**
|
|
2836
|
-
* Ask the CLI whether `env` holds usable credentials: `claude auth status`
|
|
2837
|
-
* prints a JSON verdict covering every source the CLI itself consults for that
|
|
2838
|
-
* environment — `<CLAUDE_CONFIG_DIR>/.credentials.json`, the macOS login
|
|
2839
|
-
* Keychain, ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, Bedrock/Vertex
|
|
2840
|
-
* (verified against 2.1.217). Only the `loggedIn` boolean is ever read; the
|
|
2841
|
-
* identity fields in the payload (email, org, subscription) never leave the
|
|
2842
|
-
* parse. The exit code is deliberately ignored — 2.1.217 exits 1 on a
|
|
2843
|
-
* logged-out verdict where other versions exit 0 — and anything that doesn't
|
|
2844
|
-
* parse to a `loggedIn` boolean is 'unknown', because `auth status` is not a
|
|
2845
|
-
* stable contract. Never rejects.
|
|
2846
|
-
*/
|
|
2847
2170
|
function checkClaudeAuth(env, options = {}) {
|
|
2848
2171
|
const executable = options.executable ?? resolveBundledClaudeExecutable();
|
|
2849
2172
|
if (!executable) return Promise.resolve("unknown");
|
|
@@ -2865,11 +2188,6 @@ function checkClaudeAuth(env, options = {}) {
|
|
|
2865
2188
|
}
|
|
2866
2189
|
//#endregion
|
|
2867
2190
|
//#region src/executors/quickjs-executor.ts
|
|
2868
|
-
/**
|
|
2869
|
-
* In-process execution backend: runs a tool's untrusted script in the QuickJS
|
|
2870
|
-
* WASM guest. Always settles inline — nothing downstream assumes that, which is
|
|
2871
|
-
* what lets a deferred backend replace it behind the same seam.
|
|
2872
|
-
*/
|
|
2873
2191
|
var QuickJsExecutor = class {
|
|
2874
2192
|
#options;
|
|
2875
2193
|
constructor(options) {
|
|
@@ -2899,7 +2217,7 @@ var QuickJsExecutor = class {
|
|
|
2899
2217
|
vfs: call.vfs,
|
|
2900
2218
|
signal: call.signal,
|
|
2901
2219
|
timeoutMs: call.limits?.timeoutMs ?? this.#options.defaultTimeoutMs ?? 5e3,
|
|
2902
|
-
memoryLimitBytes: call.limits?.memoryLimitBytes ?? this.#options.defaultMemoryLimitBytes ??
|
|
2220
|
+
memoryLimitBytes: call.limits?.memoryLimitBytes ?? this.#options.defaultMemoryLimitBytes ?? 67108864,
|
|
2903
2221
|
fetchText: this.#allowsNetwork() ? (url) => this.#fetchText(url, call.signal) : void 0
|
|
2904
2222
|
});
|
|
2905
2223
|
const logs = result.logs.map((l) => `[${l.level}] ${l.text}`);
|
|
@@ -2943,8 +2261,6 @@ function safeHost(url) {
|
|
|
2943
2261
|
return;
|
|
2944
2262
|
}
|
|
2945
2263
|
}
|
|
2946
|
-
/** Exact hostname match, or a single leading `*.` wildcard covering subdomains
|
|
2947
|
-
* (not the bare parent). Only http(s) — no file:, data:, or other schemes. */
|
|
2948
2264
|
function isHostAllowed(url, allowedHosts) {
|
|
2949
2265
|
let parsed;
|
|
2950
2266
|
try {
|
|
@@ -2968,14 +2284,6 @@ var PendingRequestRegistry = class {
|
|
|
2968
2284
|
get size() {
|
|
2969
2285
|
return this.#slots.size;
|
|
2970
2286
|
}
|
|
2971
|
-
/**
|
|
2972
|
-
* Register a request and get a promise for its outcome. The promise **never
|
|
2973
|
-
* rejects**: a timeout or cancellation resolves with `ok: false` so callers
|
|
2974
|
-
* feed the failure back into the agent loop instead of unwinding it.
|
|
2975
|
-
*
|
|
2976
|
-
* Re-registering a live id throws — silently replacing it would strand the
|
|
2977
|
-
* first waiter forever.
|
|
2978
|
-
*/
|
|
2979
2287
|
register(options) {
|
|
2980
2288
|
if (this.#slots.has(options.id)) throw new Error(`pending request '${options.id}' is already registered`);
|
|
2981
2289
|
const entry = {
|
|
@@ -3007,8 +2315,6 @@ var PendingRequestRegistry = class {
|
|
|
3007
2315
|
this.#slots.set(options.id, slot);
|
|
3008
2316
|
});
|
|
3009
2317
|
}
|
|
3010
|
-
/** Deliver a result. Returns false for unknown or already-settled ids —
|
|
3011
|
-
* duplicate and late deliveries are no-ops, never a second application. */
|
|
3012
2318
|
settle(id, value, settledBy = "client") {
|
|
3013
2319
|
return this.#settle(id, {
|
|
3014
2320
|
ok: true,
|
|
@@ -3016,7 +2322,6 @@ var PendingRequestRegistry = class {
|
|
|
3016
2322
|
settledBy
|
|
3017
2323
|
});
|
|
3018
2324
|
}
|
|
3019
|
-
/** Fail a request. Same idempotence guarantee as {@link settle}. */
|
|
3020
2325
|
fail(id, reason, error, settledBy = "server") {
|
|
3021
2326
|
return this.#settle(id, {
|
|
3022
2327
|
ok: false,
|
|
@@ -3036,7 +2341,6 @@ var PendingRequestRegistry = class {
|
|
|
3036
2341
|
const entries = [...this.#slots.values()].map(toEntry);
|
|
3037
2342
|
return kind ? entries.filter((e) => e.kind === kind) : entries;
|
|
3038
2343
|
}
|
|
3039
|
-
/** Fail everything (optionally of one kind) — session close, turn interrupt. */
|
|
3040
2344
|
cancelAll(reason, error, kind) {
|
|
3041
2345
|
let canceled = 0;
|
|
3042
2346
|
for (const slot of Array.from(this.#slots.values())) {
|
|
@@ -3070,21 +2374,9 @@ function toEntry(slot) {
|
|
|
3070
2374
|
}
|
|
3071
2375
|
//#endregion
|
|
3072
2376
|
//#region src/executors/browser-bridge-executor.ts
|
|
3073
|
-
/**
|
|
3074
|
-
* Executes tool calls in the attached client's own sandbox. The first backend
|
|
3075
|
-
* that genuinely returns `pending`: dispatch puts a request on the wire and
|
|
3076
|
-
* returns, and the result arrives later through {@link resolve}.
|
|
3077
|
-
*
|
|
3078
|
-
* Data locality is the point — documents can stay in the browser and never
|
|
3079
|
-
* reach the server. The tradeoff is trust: whatever comes back is untrusted
|
|
3080
|
-
* input, fine for the user's own data but never a source for authoritative
|
|
3081
|
-
* server state (that is why MCP and secret-bearing tools are never bridged).
|
|
3082
|
-
*/
|
|
3083
2377
|
var BrowserBridgeExecutor = class {
|
|
3084
2378
|
registry;
|
|
3085
2379
|
#options;
|
|
3086
|
-
/** Results that arrive before dispatch registers them (fast client, slow
|
|
3087
|
-
* bookkeeping) would otherwise be dropped — hold them briefly. */
|
|
3088
2380
|
#early = /* @__PURE__ */ new Map();
|
|
3089
2381
|
constructor(options) {
|
|
3090
2382
|
this.#options = options;
|
|
@@ -3138,10 +2430,6 @@ var BrowserBridgeExecutor = class {
|
|
|
3138
2430
|
status: "pending"
|
|
3139
2431
|
};
|
|
3140
2432
|
}
|
|
3141
|
-
/**
|
|
3142
|
-
* Apply a client's answer. Returns false when the id is unknown or already
|
|
3143
|
-
* settled — a late result after a timeout must not re-open a settled call.
|
|
3144
|
-
*/
|
|
3145
2433
|
resolve(executionId, answer) {
|
|
3146
2434
|
if (!this.registry.has(executionId)) {
|
|
3147
2435
|
this.#early.set(executionId, answer);
|
|
@@ -3154,13 +2442,12 @@ var BrowserBridgeExecutor = class {
|
|
|
3154
2442
|
return "output" in answer ? this.registry.settle(executionId, answer, "client") : this.registry.fail(executionId, answer.reason, answer.error, "client");
|
|
3155
2443
|
}
|
|
3156
2444
|
};
|
|
3157
|
-
/** Map a registry outcome onto the executor's result contract. */
|
|
3158
2445
|
function toExecutionResult(outcome) {
|
|
3159
2446
|
if (outcome.ok && "output" in outcome.value) {
|
|
3160
2447
|
const { output, logs } = outcome.value;
|
|
3161
2448
|
return {
|
|
3162
2449
|
status: "ok",
|
|
3163
|
-
output: output.
|
|
2450
|
+
output: output.value,
|
|
3164
2451
|
logs
|
|
3165
2452
|
};
|
|
3166
2453
|
}
|
|
@@ -3181,17 +2468,6 @@ function toExecutionResult(outcome) {
|
|
|
3181
2468
|
}
|
|
3182
2469
|
//#endregion
|
|
3183
2470
|
//#region src/executors/deferred-executor.ts
|
|
3184
|
-
/**
|
|
3185
|
-
* The executor for work that outlives the session's process residency: dispatch
|
|
3186
|
-
* hands the call off and returns `pending` **without holding a promise**, because
|
|
3187
|
-
* the runner it would resolve into is about to be torn down. The result can only
|
|
3188
|
-
* come back through the host — the execution-result route → `settleExecution` on a
|
|
3189
|
-
* rehydrated runner — which is exactly what makes a park durable rather than a
|
|
3190
|
-
* long in-memory await.
|
|
3191
|
-
*
|
|
3192
|
-
* Contrast {@link BrowserBridgeExecutor}, which is also `pending` but keeps its
|
|
3193
|
-
* answer in memory for the ~60s the tab has to reply.
|
|
3194
|
-
*/
|
|
3195
2471
|
var DeferredExecutor = class {
|
|
3196
2472
|
backend;
|
|
3197
2473
|
timeoutMs;
|
|
@@ -3201,8 +2477,6 @@ var DeferredExecutor = class {
|
|
|
3201
2477
|
this.backend = options.backend ?? "remote";
|
|
3202
2478
|
this.timeoutMs = options.timeoutMs;
|
|
3203
2479
|
}
|
|
3204
|
-
/** Every call this executor takes is deferred — route only the tools that
|
|
3205
|
-
* belong on the remote side to it. */
|
|
3206
2480
|
describe() {
|
|
3207
2481
|
return {
|
|
3208
2482
|
backend: this.backend,
|
|
@@ -3228,16 +2502,7 @@ var DeferredExecutor = class {
|
|
|
3228
2502
|
};
|
|
3229
2503
|
//#endregion
|
|
3230
2504
|
//#region src/engines/provider/tools.ts
|
|
3231
|
-
const MAX_FILE_BYTES =
|
|
3232
|
-
/**
|
|
3233
|
-
* Build the capability-scoped tool set for a session.
|
|
3234
|
-
*
|
|
3235
|
-
* The agent's authority is exactly what is granted here — there are no built-in
|
|
3236
|
-
* filesystem or shell tools, and nothing reaches the host filesystem: `fs_*`
|
|
3237
|
-
* operate on an in-memory scratch VFS. Tools whose backend is not supplied are
|
|
3238
|
-
* simply absent rather than present-and-failing, so a model cannot be tempted
|
|
3239
|
-
* by a capability the operator did not grant.
|
|
3240
|
-
*/
|
|
2505
|
+
const MAX_FILE_BYTES = 1048576;
|
|
3241
2506
|
function createToolContext(options) {
|
|
3242
2507
|
const vfs = options.vfs ?? createVfs();
|
|
3243
2508
|
const definitions = [];
|
|
@@ -3390,29 +2655,12 @@ function createToolContext(options) {
|
|
|
3390
2655
|
sandboxedToolNames: definitions.filter((d) => d.trust === "sandboxed").map((d) => d.name)
|
|
3391
2656
|
};
|
|
3392
2657
|
}
|
|
3393
|
-
/** Add host-side MCP tools to a context. They are ALWAYS authoritative: they run
|
|
3394
|
-
* server-side with server credentials, and must never be handed to a browser. */
|
|
3395
2658
|
function withMcpTools(context, mcpTools) {
|
|
3396
2659
|
return withHostTools(context, Object.fromEntries(Object.entries(mcpTools).map(([name, mcpTool]) => [name, {
|
|
3397
2660
|
tool: mcpTool,
|
|
3398
2661
|
trust: "authoritative"
|
|
3399
2662
|
}])), "MCP tool");
|
|
3400
2663
|
}
|
|
3401
|
-
/**
|
|
3402
|
-
* Add host-supplied tools to a context at an explicit trust level.
|
|
3403
|
-
*
|
|
3404
|
-
* The trust level is the whole point of the seam: {@link withMcpTools} can only
|
|
3405
|
-
* produce authoritative tools, so a host tool that *should* be sandboxed — and
|
|
3406
|
-
* therefore executable in the browser tab that asked for it — had no way to be
|
|
3407
|
-
* expressed at all. Here the host says which it is, and the contradictions are
|
|
3408
|
-
* refused rather than silently resolved:
|
|
3409
|
-
*
|
|
3410
|
-
* - a `sandboxed` tool carrying `execute` would run inline in this process with
|
|
3411
|
-
* the gateway's ambient authority, which is exactly what sandboxing it was
|
|
3412
|
-
* meant to prevent;
|
|
3413
|
-
* - an `authoritative` tool *without* `execute` would park the turn on a call no
|
|
3414
|
-
* executor claims, and the session would simply stop.
|
|
3415
|
-
*/
|
|
3416
2664
|
function withHostTools(context, hostTools, kind = "host tool") {
|
|
3417
2665
|
const entries = Object.entries(hostTools);
|
|
3418
2666
|
if (entries.length === 0) return context;
|
|
@@ -3448,9 +2696,9 @@ const MAX_CACHE_ENTRIES = 64;
|
|
|
3448
2696
|
const MAX_REDIRECTS = 5;
|
|
3449
2697
|
function createWebFetch(options = {}) {
|
|
3450
2698
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
3451
|
-
const maxContentBytes = options.maxContentBytes ??
|
|
3452
|
-
const maxMarkdownBytes = options.maxMarkdownBytes ??
|
|
3453
|
-
const cacheTtlMs = options.cacheTtlMs ??
|
|
2699
|
+
const maxContentBytes = options.maxContentBytes ?? 1048576;
|
|
2700
|
+
const maxMarkdownBytes = options.maxMarkdownBytes ?? 51200;
|
|
2701
|
+
const cacheTtlMs = options.cacheTtlMs ?? 9e5;
|
|
3454
2702
|
const cache = /* @__PURE__ */ new Map();
|
|
3455
2703
|
const fetchPage = async (rawUrl) => {
|
|
3456
2704
|
const cached = cache.get(rawUrl);
|
|
@@ -3559,10 +2807,6 @@ function parseUrl(raw) {
|
|
|
3559
2807
|
return;
|
|
3560
2808
|
}
|
|
3561
2809
|
}
|
|
3562
|
-
/** SSRF guard: resolve the hostname and refuse private, loopback, and link-local
|
|
3563
|
-
* destinations. Checked per redirect hop. Resolution happens once here and again
|
|
3564
|
-
* inside fetch (a DNS-rebinding TOCTOU); this tier accepts that — operators who
|
|
3565
|
-
* need pinning can supply `fetchImpl` with a pinned agent. */
|
|
3566
2810
|
async function denyReason(url, allowedHosts) {
|
|
3567
2811
|
const host = url.hostname.toLowerCase();
|
|
3568
2812
|
if (allowedHosts && allowedHosts.length > 0 && !hostMatches(host, allowedHosts)) return `host not allowed: ${host}`;
|
|
@@ -3587,7 +2831,6 @@ function hostMatches(host, allowedHosts) {
|
|
|
3587
2831
|
return host === pattern;
|
|
3588
2832
|
});
|
|
3589
2833
|
}
|
|
3590
|
-
/** Private / loopback / link-local / unspecified, IPv4 and IPv6 (incl. v4-mapped). */
|
|
3591
2834
|
function isPrivateAddress(address) {
|
|
3592
2835
|
const ip = address.toLowerCase();
|
|
3593
2836
|
if (ip.includes(":")) {
|
|
@@ -3628,12 +2871,6 @@ async function readCapped(response, maxBytes) {
|
|
|
3628
2871
|
function looksLikeHtml(body) {
|
|
3629
2872
|
return /<(!doctype|html|head|body)[\s>]/i.test(body.slice(0, 1024));
|
|
3630
2873
|
}
|
|
3631
|
-
/**
|
|
3632
|
-
* Dependency-free HTML → markdown, tuned for "give the model readable text":
|
|
3633
|
-
* drops non-content subtrees, keeps headings/lists/links/emphasis/code, strips
|
|
3634
|
-
* everything else. Not a spec-grade converter on purpose — a small predictable
|
|
3635
|
-
* transform beats dragging a DOM into core.
|
|
3636
|
-
*/
|
|
3637
2874
|
function htmlToMarkdown(html) {
|
|
3638
2875
|
let text = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style|noscript|svg|template|iframe)\b[\s\S]*?<\/\1>/gi, "").replace(/<(head)\b[\s\S]*?<\/\1>/gi, "");
|
|
3639
2876
|
text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, body) => {
|
|
@@ -3656,25 +2893,12 @@ function decodeEntities(text) {
|
|
|
3656
2893
|
}
|
|
3657
2894
|
//#endregion
|
|
3658
2895
|
//#region src/engines/provider/session.ts
|
|
3659
|
-
/** Which capability a wired backend yields, for grant filtering. */
|
|
3660
2896
|
const CAPABILITY_TOOLS = {
|
|
3661
2897
|
search: "web_search",
|
|
3662
2898
|
download: "download",
|
|
3663
2899
|
webFetch: "web_fetch",
|
|
3664
2900
|
deliverFiles: "deliver_file"
|
|
3665
2901
|
};
|
|
3666
|
-
/**
|
|
3667
|
-
* Assemble a model-agnostic session: provider model, capability-scoped tools,
|
|
3668
|
-
* a scratch VFS, and the executor that runs the sandboxed ones.
|
|
3669
|
-
*
|
|
3670
|
-
* This is the piece an operator wires into the server's `createEngineRunner`.
|
|
3671
|
-
*
|
|
3672
|
-
* The host wires the *backends*; the profile and the session request decide which
|
|
3673
|
-
* of them are actually granted (`profile.session`, `config.capabilities`). A
|
|
3674
|
-
* backend that isn't granted is simply not built into the tool set, so withholding
|
|
3675
|
-
* a capability costs the host no branching. No declaration anywhere = everything
|
|
3676
|
-
* the host wired, which is what a host that ignores profiles gets.
|
|
3677
|
-
*/
|
|
3678
2902
|
function createEngineSession(options) {
|
|
3679
2903
|
const vfs = options.config.vfs ?? createVfs(options.config.restore ? options.config.restore.vfs : options.seedVfs);
|
|
3680
2904
|
const executor = options.selectExecutor.length > 0 ? {
|
|
@@ -3731,20 +2955,6 @@ function createEngineSession(options) {
|
|
|
3731
2955
|
}, options.id);
|
|
3732
2956
|
return runner;
|
|
3733
2957
|
}
|
|
3734
|
-
/**
|
|
3735
|
-
* Refuse to build a session whose profile names an MCP server that isn't there.
|
|
3736
|
-
*
|
|
3737
|
-
* A profile's `mcpServers` list is a **declaration**, not a filter: an embedder
|
|
3738
|
-
* who wrote it meant the agent to have those tools. Honouring it partially is
|
|
3739
|
-
* the worst failure mode this engine has — the session starts, reports healthy,
|
|
3740
|
-
* and the agent apologises its way through every request that needed the server,
|
|
3741
|
-
* with one warning line in a log nobody is reading.
|
|
3742
|
-
*
|
|
3743
|
-
* With a {@link McpConnection} the check is exact (did this server connect?).
|
|
3744
|
-
* With a bare tool set all we can see is whether any tool carries the server's
|
|
3745
|
-
* namespace, so a genuinely tool-less server would trip it — the fix there is to
|
|
3746
|
-
* pass `mcp` rather than to weaken this.
|
|
3747
|
-
*/
|
|
3748
2958
|
function requireDeclaredServers(profileName, declared, mcp, tools) {
|
|
3749
2959
|
if (!declared || declared.length === 0) return;
|
|
3750
2960
|
const missing = declared.filter((name) => {
|
|
@@ -3761,33 +2971,11 @@ function requireDeclaredServers(profileName, declared, mcp, tools) {
|
|
|
3761
2971
|
}).join(", ");
|
|
3762
2972
|
throw new Error(`profile '${profileName}' declares MCP server(s) that are not connected: ${reasons}. A session missing a declared server is a session whose agent silently cannot do its job.`);
|
|
3763
2973
|
}
|
|
3764
|
-
/**
|
|
3765
|
-
* Restrict a connected tool set to the MCP servers a profile grants, by the
|
|
3766
|
-
* `<server>__<tool>` namespace {@link connectMcpTools} assigns. Undefined `servers`
|
|
3767
|
-
* = no declaration, so every connected server passes through.
|
|
3768
|
-
*
|
|
3769
|
-
* This is how one process-wide MCP connection serves a mixed fleet: the host
|
|
3770
|
-
* connects everything once, each profile grants a subset. The transport configs —
|
|
3771
|
-
* and any credentials in their headers — never leave the host for a profile.
|
|
3772
|
-
*/
|
|
3773
2974
|
function selectMcpTools(tools, servers) {
|
|
3774
2975
|
if (!tools || servers === void 0) return tools;
|
|
3775
2976
|
const allowed = new Set(servers);
|
|
3776
2977
|
return Object.fromEntries(Object.entries(tools).filter(([name]) => allowed.has(name.split("__")[0])));
|
|
3777
2978
|
}
|
|
3778
|
-
/**
|
|
3779
|
-
* Connect to MCP servers and return their tools, ready for {@link withMcpTools}.
|
|
3780
|
-
*
|
|
3781
|
-
* Server-side only, with server credentials: these tools are authoritative and
|
|
3782
|
-
* must never be bridged to a browser. `@ai-sdk/mcp` is imported lazily and is an
|
|
3783
|
-
* optional dependency — an operator who wires no MCP servers never needs it.
|
|
3784
|
-
*
|
|
3785
|
-
* **A stateless MCP server must answer `GET` with 405.** The client opens the
|
|
3786
|
-
* SSE stream with a `GET` before it sends anything, and a POST-only server
|
|
3787
|
-
* mounted under a framework's default 404 makes the whole connect fail with an
|
|
3788
|
-
* error that names neither the method nor the route. This is the single most
|
|
3789
|
-
* common way an otherwise-correct MCP mount fails.
|
|
3790
|
-
*/
|
|
3791
2979
|
async function connectMcpTools(servers, options = {}) {
|
|
3792
2980
|
const entries = Object.entries(servers);
|
|
3793
2981
|
if (entries.length === 0) return {
|
|
@@ -3829,7 +3017,7 @@ async function connectMcpTools(servers, options = {}) {
|
|
|
3829
3017
|
options.onError?.(name, error);
|
|
3830
3018
|
if (options.required) {
|
|
3831
3019
|
await closeAll();
|
|
3832
|
-
throw new Error(`MCP server '${name}' failed to connect: ${message}
|
|
3020
|
+
throw new Error(`MCP server '${name}' failed to connect: ${message}`, { cause: error });
|
|
3833
3021
|
}
|
|
3834
3022
|
}
|
|
3835
3023
|
}
|
|
@@ -3839,7 +3027,6 @@ async function connectMcpTools(servers, options = {}) {
|
|
|
3839
3027
|
close: closeAll
|
|
3840
3028
|
};
|
|
3841
3029
|
}
|
|
3842
|
-
/** The connection's identity, minus its secrets — `headers` never travel. */
|
|
3843
3030
|
function describeServer(server) {
|
|
3844
3031
|
if ("url" in server) return {
|
|
3845
3032
|
transport: server.type === "sse" ? "sse" : "http",
|
|
@@ -3851,12 +3038,6 @@ function describeServer(server) {
|
|
|
3851
3038
|
args: server.args
|
|
3852
3039
|
};
|
|
3853
3040
|
}
|
|
3854
|
-
/**
|
|
3855
|
-
* The AI SDK hands back its own `Tool`, whose `inputSchema` may be a zod schema
|
|
3856
|
-
* or a `jsonSchema()` wrapper. Only the latter carries a JSON Schema document,
|
|
3857
|
-
* so that is the only case where parameters are reported — `McpServerToolInfo`
|
|
3858
|
-
* models the absence deliberately, and inventing one here would be worse.
|
|
3859
|
-
*/
|
|
3860
3041
|
function toToolInfo(name, mcpTool) {
|
|
3861
3042
|
const { description, inputSchema } = mcpTool ?? {};
|
|
3862
3043
|
return {
|
|
@@ -3865,12 +3046,6 @@ function toToolInfo(name, mcpTool) {
|
|
|
3865
3046
|
inputSchema: inputSchema?.jsonSchema
|
|
3866
3047
|
};
|
|
3867
3048
|
}
|
|
3868
|
-
/**
|
|
3869
|
-
* Only http/sse: the AI SDK's built-in transports are the remote ones, and its
|
|
3870
|
-
* own docs mark stdio local-only and not deployable. A stdio server here is a
|
|
3871
|
-
* misconfiguration worth surfacing rather than silently dropping — the Claude
|
|
3872
|
-
* engine still supports stdio, since the CLI spawns those itself.
|
|
3873
|
-
*/
|
|
3874
3049
|
function toTransport(server) {
|
|
3875
3050
|
if (!("url" in server)) throw new Error("stdio MCP servers are not supported by the model-agnostic engine (use an http or sse server, or run this session under a Claude profile)");
|
|
3876
3051
|
return server.type === "sse" ? {
|
|
@@ -3885,38 +3060,14 @@ function toTransport(server) {
|
|
|
3885
3060
|
}
|
|
3886
3061
|
//#endregion
|
|
3887
3062
|
//#region src/engines/claude/catalog.ts
|
|
3888
|
-
/**
|
|
3889
|
-
* The Claude engine's model catalog — what a create form offers before any
|
|
3890
|
-
* session has run.
|
|
3891
|
-
*
|
|
3892
|
-
* **Refresh procedure** (release checklist): run `supportedModels()` on a
|
|
3893
|
-
* throwaway SDK query (no tokens spent) and re-apply the shaping rules of
|
|
3894
|
-
* `modelOptionsFromSdk` (`src/normalize.ts`) at authoring time: drop the
|
|
3895
|
-
* `default` sentinel row, derive display names from resolved ids where
|
|
3896
|
-
* unambiguous, mark the newest of each family `primary`, sort by family rank.
|
|
3897
|
-
* A unit test replays the raw extraction through `modelOptionsFromSdk` and
|
|
3898
|
-
* asserts these rows match, so the rules cannot drift.
|
|
3899
|
-
*
|
|
3900
|
-
* Two things the live `capabilities` event can never offer:
|
|
3901
|
-
* - rows for **older models** the CLI no longer reports (hand-maintained, the
|
|
3902
|
-
* accepted cost of a static catalog; the CLI silently downgrades an effort a
|
|
3903
|
-
* model doesn't support, so `reasoningEfforts` is omitted on them and the
|
|
3904
|
-
* engine default set applies);
|
|
3905
|
-
* - an answer on a **cold server**. The live event still exists and remains
|
|
3906
|
-
* the in-session truth for the model switcher; this catalog is the
|
|
3907
|
-
* create-form truth.
|
|
3908
|
-
*
|
|
3909
|
-
* `defaultModel` is deliberately NOT here: a claude profile's default is the
|
|
3910
|
-
* operator's CLI config, unknowable statically.
|
|
3911
|
-
*/
|
|
3912
3063
|
const CLAUDE_CATALOG = {
|
|
3913
|
-
provenance: "supportedModels() of @anthropic-ai/claude-agent-sdk 0.3.
|
|
3064
|
+
provenance: "supportedModels() of @anthropic-ai/claude-agent-sdk 0.3.258 (Claude Code CLI), extracted 2026-09-02; older-model rows hand-maintained",
|
|
3914
3065
|
models: [
|
|
3915
3066
|
{
|
|
3916
|
-
value: "claude-fable-5[1m]",
|
|
3917
|
-
resolvedModel: "claude-fable-5",
|
|
3918
|
-
displayName: "Fable 5",
|
|
3919
|
-
description: "Fable 5 · Most capable for your hardest and longest-running tasks",
|
|
3067
|
+
value: "claude-fable-5-1[1m]",
|
|
3068
|
+
resolvedModel: "claude-fable-5-1",
|
|
3069
|
+
displayName: "Fable 5.1",
|
|
3070
|
+
description: "Fable 5.1 · Most capable for your hardest and longest-running tasks",
|
|
3920
3071
|
primary: true,
|
|
3921
3072
|
reasoningEfforts: [
|
|
3922
3073
|
"low",
|
|
@@ -3926,6 +3077,12 @@ const CLAUDE_CATALOG = {
|
|
|
3926
3077
|
"max"
|
|
3927
3078
|
]
|
|
3928
3079
|
},
|
|
3080
|
+
{
|
|
3081
|
+
value: "claude-fable-5[1m]",
|
|
3082
|
+
resolvedModel: "claude-fable-5",
|
|
3083
|
+
displayName: "Fable 5",
|
|
3084
|
+
description: "Fable 5 · Previous Fable generation"
|
|
3085
|
+
},
|
|
3929
3086
|
{
|
|
3930
3087
|
value: "opus[1m]",
|
|
3931
3088
|
resolvedModel: "claude-opus-5[1m]",
|
|
@@ -3978,13 +3135,6 @@ const CLAUDE_CATALOG = {
|
|
|
3978
3135
|
};
|
|
3979
3136
|
//#endregion
|
|
3980
3137
|
//#region src/engines/claude/adapter.ts
|
|
3981
|
-
/**
|
|
3982
|
-
* The Claude engine as an adapter — a thin, behaviourally inert wrapper:
|
|
3983
|
-
* `SessionRunner` unchanged, `checkClaudeAuth` as the probe, the static
|
|
3984
|
-
* catalog for create forms. Exists so catalogs, capabilities and availability
|
|
3985
|
-
* have one shape across engines; the runner itself is exactly what
|
|
3986
|
-
* `registry.prepare()` builds.
|
|
3987
|
-
*/
|
|
3988
3138
|
const claudeAdapter = {
|
|
3989
3139
|
engine: "claude",
|
|
3990
3140
|
capabilities: ENGINE_CAPABILITIES.claude,
|
|
@@ -4002,12 +3152,6 @@ const claudeAdapter = {
|
|
|
4002
3152
|
if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
|
|
4003
3153
|
return new SessionRunner(config, id);
|
|
4004
3154
|
},
|
|
4005
|
-
/**
|
|
4006
|
-
* The Agent SDK's on-disk session store, mapped browser-safe. The SDK reads
|
|
4007
|
-
* the store of the *process* environment — it takes no config dir — so a
|
|
4008
|
-
* profile pin cannot narrow this listing; that matches the route's
|
|
4009
|
-
* pre-adapter behavior exactly (the listing was always process-global).
|
|
4010
|
-
*/
|
|
4011
3155
|
async listSessions({ dir, limit, offset }) {
|
|
4012
3156
|
return (await listSessions({
|
|
4013
3157
|
dir,
|
|
@@ -4025,10 +3169,28 @@ const claudeAdapter = {
|
|
|
4025
3169
|
}));
|
|
4026
3170
|
}
|
|
4027
3171
|
};
|
|
3172
|
+
//#endregion
|
|
3173
|
+
//#region src/engines/codex/connect.ts
|
|
4028
3174
|
/**
|
|
4029
|
-
*
|
|
4030
|
-
*
|
|
3175
|
+
* The operator's environment reaches the child whole — WorkerDeck resolves no credential of its
|
|
3176
|
+
* own — and the profile's CODEX_HOME is the one key it pins, last, so a profile always wins over
|
|
3177
|
+
* an inherited value. Every path that spawns or connects to an app-server goes through here.
|
|
4031
3178
|
*/
|
|
3179
|
+
function codexChildEnv(base, codexHome) {
|
|
3180
|
+
const env = {};
|
|
3181
|
+
for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
|
|
3182
|
+
if (codexHome) env.CODEX_HOME = codexHome;
|
|
3183
|
+
return env;
|
|
3184
|
+
}
|
|
3185
|
+
/** `experimentalApi` gates the granular approval policy and there is no non-experimental fallback, so it is not per-call-site. */
|
|
3186
|
+
const INITIALIZE_PARAMS = {
|
|
3187
|
+
clientInfo: {
|
|
3188
|
+
name: "workerdeck",
|
|
3189
|
+
title: "WorkerDeck",
|
|
3190
|
+
version: `protocol-${PROTOCOL_VERSION}`
|
|
3191
|
+
},
|
|
3192
|
+
capabilities: { experimentalApi: true }
|
|
3193
|
+
};
|
|
4032
3194
|
var JsonRpcError = class extends Error {
|
|
4033
3195
|
code;
|
|
4034
3196
|
constructor(code, message) {
|
|
@@ -4037,18 +3199,6 @@ var JsonRpcError = class extends Error {
|
|
|
4037
3199
|
this.code = code;
|
|
4038
3200
|
}
|
|
4039
3201
|
};
|
|
4040
|
-
/**
|
|
4041
|
-
* JSON-RPC over a `codex app-server` child's stdio: **newline-delimited JSON**,
|
|
4042
|
-
* one message per line, and — verified against 0.146.0 — an envelope *without*
|
|
4043
|
-
* the `jsonrpc: "2.0"` field (`{id, method, params}` / `{id, result}` /
|
|
4044
|
-
* `{id, error}`; the binary's own schema marks only those required). Server→
|
|
4045
|
-
* client notifications additionally carry a top-level `emittedAtMs`, ignored
|
|
4046
|
-
* here.
|
|
4047
|
-
*
|
|
4048
|
-
* Transport only: no method knowledge, no process ownership. The process
|
|
4049
|
-
* wrapper (`process.ts`) owns the child and calls {@link fail} when it dies so
|
|
4050
|
-
* every in-flight request rejects instead of hanging.
|
|
4051
|
-
*/
|
|
4052
3202
|
var JsonRpcStdioConnection = class {
|
|
4053
3203
|
#output;
|
|
4054
3204
|
#nextId = 1;
|
|
@@ -4056,7 +3206,6 @@ var JsonRpcStdioConnection = class {
|
|
|
4056
3206
|
#buffer = "";
|
|
4057
3207
|
#closed = false;
|
|
4058
3208
|
#notificationHandler;
|
|
4059
|
-
/** Where {@link CODEX_TRACE_ENV} pointed, or undefined — read once. */
|
|
4060
3209
|
#trace;
|
|
4061
3210
|
#requestHandler;
|
|
4062
3211
|
constructor(options) {
|
|
@@ -4095,8 +3244,6 @@ var JsonRpcStdioConnection = class {
|
|
|
4095
3244
|
onRequest(handler) {
|
|
4096
3245
|
this.#requestHandler = handler;
|
|
4097
3246
|
}
|
|
4098
|
-
/** Reject everything in flight and refuse new traffic — the child is gone
|
|
4099
|
-
* (or the session is over). Idempotent. */
|
|
4100
3247
|
fail(message) {
|
|
4101
3248
|
if (this.#closed) return;
|
|
4102
3249
|
this.#closed = true;
|
|
@@ -4126,15 +3273,6 @@ var JsonRpcStdioConnection = class {
|
|
|
4126
3273
|
this.#dispatch(message);
|
|
4127
3274
|
}
|
|
4128
3275
|
}
|
|
4129
|
-
/**
|
|
4130
|
-
* Append one inbound message to the trace file, when the operator asked for
|
|
4131
|
-
* one. **Notifications and server→client requests only** — a response body is
|
|
4132
|
-
* not needed to answer the questions this exists for, and `account/*` results
|
|
4133
|
-
* are the one place app-server traffic can carry a masked credential
|
|
4134
|
-
* fragment, which nothing of ours writes to disk (see the auth red lines).
|
|
4135
|
-
* Best-effort and synchronous: a debug sink that loses lines proves nothing,
|
|
4136
|
-
* and a debug sink that throws must not take the session with it.
|
|
4137
|
-
*/
|
|
4138
3276
|
#traceLine(message) {
|
|
4139
3277
|
if (!this.#trace) return;
|
|
4140
3278
|
const method = message.method;
|
|
@@ -4183,50 +3321,12 @@ var JsonRpcStdioConnection = class {
|
|
|
4183
3321
|
};
|
|
4184
3322
|
//#endregion
|
|
4185
3323
|
//#region src/engines/codex/subagents.ts
|
|
4186
|
-
/**
|
|
4187
|
-
* The codex side of `SessionInfo.subagents` — and the attribution table that
|
|
4188
|
-
* gives every event a spawned agent produces its `parentToolUseId`.
|
|
4189
|
-
*
|
|
4190
|
-
* Codex's signal is stronger than the claude engine's, so this is deliberately
|
|
4191
|
-
* NOT that tracker generalised (`engines/claude/subagents.ts` infers spawns
|
|
4192
|
-
* from tool names and verdicts from result-text sniffing, ~290 lines of module
|
|
4193
|
-
* doc explaining the inference). Here nothing is inferred: `subAgentActivity
|
|
4194
|
-
* {kind: 'started'}` on the owning thread positively announces an agent, names
|
|
4195
|
-
* it (`agentPath`), keys it (`agentThreadId` — the id every one of its later
|
|
4196
|
-
* notifications carries) and hands over the model's own `spawn_agent` call id;
|
|
4197
|
-
* the agent's end is its own thread's `turn/completed`, status included. So a
|
|
4198
|
-
* record is keyed by **thread id** — the wire's handle — while exposing a
|
|
4199
|
-
* **tool-use id** — the protocol's: `parentToolUseId` on nested events must
|
|
4200
|
-
* equal the anchor `tool_use`'s id for `subagentItems` (the frame membership
|
|
4201
|
-
* rule every client shares) to reassemble the sidechain, and this map is where
|
|
4202
|
-
* the two vocabularies meet.
|
|
4203
|
-
*
|
|
4204
|
-
* Two decisions worth their prose:
|
|
4205
|
-
*
|
|
4206
|
-
* **A record survives the runner's turns.** Codex agents are designed to
|
|
4207
|
-
* outlive the root turn that spawned them (`sendInput`/`resumeAgent` address a
|
|
4208
|
-
* thread that kept existing), so — unlike a pending approval — nothing here is
|
|
4209
|
-
* swept when a root turn ends. What does end every agent is the app-server
|
|
4210
|
-
* process itself: the runner calls {@link sweep} when the child dies or the
|
|
4211
|
-
* session closes, because an agent whose host process is gone can never report,
|
|
4212
|
-
* and `running` on a closed session would be a lie a polled list re-renders
|
|
4213
|
-
* forever (the claude tracker's argument, inherited whole).
|
|
4214
|
-
*
|
|
4215
|
-
* **The settled tail is bounded, running records never are** — the same
|
|
4216
|
-
* {@link SUBAGENT_HISTORY} discipline as the claude tracker, and enforced at
|
|
4217
|
-
* settle time for the same reason: a settle happens once per agent, `list()`
|
|
4218
|
-
* once per row of a 1.2s-polled sessions list.
|
|
4219
|
-
*/
|
|
4220
3324
|
var CodexAgentTracker = class {
|
|
4221
3325
|
#byThread = /* @__PURE__ */ new Map();
|
|
4222
3326
|
#settleCounter = 0;
|
|
4223
|
-
/** The record whose thread this is — the attribution lookup. */
|
|
4224
3327
|
get(agentThreadId) {
|
|
4225
3328
|
return this.#byThread.get(agentThreadId);
|
|
4226
3329
|
}
|
|
4227
|
-
/** Open (or return) the record for a thread. Fill-in, never overwrite: a
|
|
4228
|
-
* label-less fallback record keeps its accumulated count and its already
|
|
4229
|
-
* published toolUseId when the announcing item arrives late. */
|
|
4230
3330
|
open(agentThreadId, toolUseId, agentType, ts) {
|
|
4231
3331
|
let record = this.#byThread.get(agentThreadId);
|
|
4232
3332
|
if (!record) {
|
|
@@ -4243,8 +3343,6 @@ var CodexAgentTracker = class {
|
|
|
4243
3343
|
record.agentType ??= agentType;
|
|
4244
3344
|
return record;
|
|
4245
3345
|
}
|
|
4246
|
-
/** The agent's thread ran again (`kind: 'interacted'`, or a fresh
|
|
4247
|
-
* `turn/started` on its thread): a settled verdict no longer describes it. */
|
|
4248
3346
|
revive(record) {
|
|
4249
3347
|
record.status = "running";
|
|
4250
3348
|
record.settledOrder = void 0;
|
|
@@ -4265,41 +3363,21 @@ var CodexAgentTracker = class {
|
|
|
4265
3363
|
settled--;
|
|
4266
3364
|
}
|
|
4267
3365
|
}
|
|
4268
|
-
/** A real verdict for one agent — its thread's `turn/completed`, or the
|
|
4269
|
-
* `interrupted` activity edge. */
|
|
4270
3366
|
settle(record, status) {
|
|
4271
3367
|
if (record.status === status) return;
|
|
4272
3368
|
this.#settle(record, status);
|
|
4273
3369
|
}
|
|
4274
|
-
/** The process the agents lived in is gone (child death, session close):
|
|
4275
|
-
* everything still running is settled as failed — the report can never come. */
|
|
4276
3370
|
sweep() {
|
|
4277
3371
|
for (const record of this.#byThread.values()) if (record.status === "running") this.#settle(record, "failed");
|
|
4278
3372
|
}
|
|
4279
|
-
/**
|
|
4280
|
-
* The conversation these agents belonged to is gone (a `conversation_reset`).
|
|
4281
|
-
*
|
|
4282
|
-
* Deliberately NOT {@link CodexAgentTracker.sweep}: that settles the running
|
|
4283
|
-
* ones as failed and keeps the rows, which is right when the *process* dies —
|
|
4284
|
-
* the transcript still holds the anchor `tool_use` each row points at, and a
|
|
4285
|
-
* row that vanished would leave that card unexplained. A clear is the other
|
|
4286
|
-
* way round. The anchors go with the transcript, so a surviving row would
|
|
4287
|
-
* publish a `toolUseId` that resolves to nothing — and clients key a
|
|
4288
|
-
* pressable, enterable agent line off exactly that id.
|
|
4289
|
-
*/
|
|
4290
3373
|
forget() {
|
|
4291
3374
|
this.#byThread.clear();
|
|
4292
3375
|
}
|
|
4293
|
-
/** The thread ids currently tracked — what a clear remembers so a still-running
|
|
4294
|
-
* agent's later traffic can be dropped rather than re-anchored. */
|
|
4295
3376
|
threadIds() {
|
|
4296
3377
|
return Array.from(this.#byThread.keys());
|
|
4297
3378
|
}
|
|
4298
|
-
/** The rollup as `SessionInfo.subagents` serves it — spawn order, fresh
|
|
4299
|
-
* objects, and `undefined` when there is nothing to say (absent and empty
|
|
4300
|
-
* mean the same thing to a client, and bytes on a polled list are paid for). */
|
|
4301
3379
|
list() {
|
|
4302
|
-
if (this.#byThread.size === 0) return
|
|
3380
|
+
if (this.#byThread.size === 0) return;
|
|
4303
3381
|
const out = [];
|
|
4304
3382
|
for (const r of this.#byThread.values()) out.push({
|
|
4305
3383
|
toolUseId: r.toolUseId,
|
|
@@ -4313,63 +3391,12 @@ var CodexAgentTracker = class {
|
|
|
4313
3391
|
};
|
|
4314
3392
|
//#endregion
|
|
4315
3393
|
//#region src/engines/codex/trust.ts
|
|
4316
|
-
/**
|
|
4317
|
-
* Codex project trust: will this session's cwd get its `.codex/config.toml`?
|
|
4318
|
-
*
|
|
4319
|
-
* Codex only layers a project's `.codex/config.toml` onto the operator's base
|
|
4320
|
-
* config when the project is *trusted* (a `[projects."<path>"]
|
|
4321
|
-
* trust_level = "trusted"` entry in `$CODEX_HOME/config.toml`), and the
|
|
4322
|
-
* app-server surface has no trust prompt — that lives in the TUI. So under
|
|
4323
|
-
* WorkerDeck an untrusted project's config, MCP servers included, is silently
|
|
4324
|
-
* ignored: no error, no notice, servers just missing. The runner asks this
|
|
4325
|
-
* module at session start whether that is about to happen, so the transcript
|
|
4326
|
-
* can say so.
|
|
4327
|
-
*
|
|
4328
|
-
* Semantics, all measured against both the bundled 0.146.0 and 0.149.0
|
|
4329
|
-
* (2026-08-22, via `codex mcp list` from probe cwds and via `thread/start` +
|
|
4330
|
-
* `mcpServerStatus/list` on the app-server surface — identical answers):
|
|
4331
|
-
*
|
|
4332
|
-
* - **Discovery**: config layers come from the cwd and its ancestors up to and
|
|
4333
|
-
* including the nearest directory containing `.git` (dir or file). With no
|
|
4334
|
-
* git anywhere above, the cwd alone is consulted. Directories above the
|
|
4335
|
-
* nearest git root never contribute, trusted or not.
|
|
4336
|
-
* - **Trust per layer**: an exact entry for the layer's own canonical path
|
|
4337
|
-
* decides (an explicit `"untrusted"` beats inherited trust); without one the
|
|
4338
|
-
* layer inherits from the chain's git root — trusted iff the git root has a
|
|
4339
|
-
* trusted entry, where a linked worktree's root also counts its main
|
|
4340
|
-
* repository's entry (the `.git` file's gitdir names it). A trusted
|
|
4341
|
-
* mid-chain directory does NOT trust its children, and plain path
|
|
4342
|
-
* containment without git confers nothing.
|
|
4343
|
-
* - **Canonical paths**: codex matches entries against the canonicalized cwd —
|
|
4344
|
-
* a macOS `/tmp/...` entry never matches the `/private/tmp/...` it points
|
|
4345
|
-
* at, while the reverse spelling works (and the app-server canonicalizes its
|
|
4346
|
-
* `cwd` param too). Both sides here are realpath'd, which can only err
|
|
4347
|
-
* toward silence.
|
|
4348
|
-
* - **The gate is sandbox-scoped**: `thread/start` under `workspace-write` or
|
|
4349
|
-
* `danger-full-access` (permission modes `acceptEdits`/`bypassPermissions`)
|
|
4350
|
-
* WRITES the trust entry itself and loads the config — only `read-only`
|
|
4351
|
-
* (mode `default`) leaves the project untrusted and the config ignored. A
|
|
4352
|
-
* later `turn/start` with a wider sandboxPolicy does not heal the thread
|
|
4353
|
-
* (measured): the caller probes `default`-mode sessions only, and the notice
|
|
4354
|
-
* stays true for the session it opens.
|
|
4355
|
-
* - `trust_level`'s vocabulary is exactly `trusted`/`untrusted`; any other
|
|
4356
|
-
* value fails codex's bootstrap outright ("unknown variant"), so a config
|
|
4357
|
-
* carrying one probes silent — that session announces its own failure.
|
|
4358
|
-
*
|
|
4359
|
-
* The correctness bar for every degrade path: a FALSE notice — warning about a
|
|
4360
|
-
* project codex actually trusts — is worse than a missed one. The narrow TOML
|
|
4361
|
-
* reader below refuses (→ silence) anything it cannot interpret with
|
|
4362
|
-
* certainty, rather than guessing.
|
|
4363
|
-
*/
|
|
4364
3394
|
const BARE_KEY = /[A-Za-z0-9_-]/;
|
|
4365
3395
|
function skipWs(text, pos) {
|
|
4366
3396
|
let i = pos;
|
|
4367
3397
|
while (i < text.length && (text[i] === " " || text[i] === " ")) i++;
|
|
4368
3398
|
return i;
|
|
4369
3399
|
}
|
|
4370
|
-
/** One-line TOML basic string starting at `pos` (which must be `"`). Undefined
|
|
4371
|
-
* on an escape TOML doesn't define or a close quote that never comes — the
|
|
4372
|
-
* caller refuses the file rather than guessing what codex would read. */
|
|
4373
3400
|
function parseBasicString(text, pos) {
|
|
4374
3401
|
let out = "";
|
|
4375
3402
|
let i = pos + 1;
|
|
@@ -4391,12 +3418,12 @@ function parseBasicString(text, pos) {
|
|
|
4391
3418
|
else if (esc === "u" || esc === "U") {
|
|
4392
3419
|
const width = esc === "u" ? 4 : 8;
|
|
4393
3420
|
const hex = text.slice(i + 2, i + 2 + width);
|
|
4394
|
-
if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return
|
|
3421
|
+
if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return;
|
|
4395
3422
|
const code = Number.parseInt(hex, 16);
|
|
4396
|
-
if (code > 1114111) return
|
|
3423
|
+
if (code > 1114111) return;
|
|
4397
3424
|
out += String.fromCodePoint(code);
|
|
4398
3425
|
i += width;
|
|
4399
|
-
} else return
|
|
3426
|
+
} else return;
|
|
4400
3427
|
i += 2;
|
|
4401
3428
|
continue;
|
|
4402
3429
|
}
|
|
@@ -4404,17 +3431,14 @@ function parseBasicString(text, pos) {
|
|
|
4404
3431
|
i++;
|
|
4405
3432
|
}
|
|
4406
3433
|
}
|
|
4407
|
-
/** One-line TOML literal string starting at `pos` (which must be `'`). */
|
|
4408
3434
|
function parseLiteralString(text, pos) {
|
|
4409
3435
|
const close = text.indexOf("'", pos + 1);
|
|
4410
|
-
if (close === -1) return
|
|
3436
|
+
if (close === -1) return;
|
|
4411
3437
|
return {
|
|
4412
3438
|
value: text.slice(pos + 1, close),
|
|
4413
3439
|
end: close + 1
|
|
4414
3440
|
};
|
|
4415
3441
|
}
|
|
4416
|
-
/** A dotted key path — bare, `"basic"` and `'literal'` keys, whitespace around
|
|
4417
|
-
* the dots — as found in table headers and on the left of assignments. */
|
|
4418
3442
|
function parseKeyPath(text, pos) {
|
|
4419
3443
|
const keys = [];
|
|
4420
3444
|
let i = pos;
|
|
@@ -4423,7 +3447,7 @@ function parseKeyPath(text, pos) {
|
|
|
4423
3447
|
const ch = text[i];
|
|
4424
3448
|
if (ch === "\"" || ch === "'") {
|
|
4425
3449
|
const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
|
|
4426
|
-
if (!str) return
|
|
3450
|
+
if (!str) return;
|
|
4427
3451
|
keys.push(str.value);
|
|
4428
3452
|
i = str.end;
|
|
4429
3453
|
} else if (ch !== void 0 && BARE_KEY.test(ch)) {
|
|
@@ -4440,23 +3464,14 @@ function parseKeyPath(text, pos) {
|
|
|
4440
3464
|
i++;
|
|
4441
3465
|
}
|
|
4442
3466
|
}
|
|
4443
|
-
/**
|
|
4444
|
-
* Scan an assignment's value (or the continuation line of a multi-line array),
|
|
4445
|
-
* confirming where it ends. Returns the bracket depth carried onto the next
|
|
4446
|
-
* line (0 = the value is complete) plus the string itself when the whole value
|
|
4447
|
-
* was one plain one-line string. Undefined refuses the file: multi-line
|
|
4448
|
-
* strings are where a line reader starts misreading string *content* as
|
|
4449
|
-
* sections and entries — the exact mistake that could flip a real trust entry
|
|
4450
|
-
* — so they are not parsed around, they end the attempt.
|
|
4451
|
-
*/
|
|
4452
3467
|
function scanValueLine(text, pos, depth) {
|
|
4453
3468
|
let i = skipWs(text, pos);
|
|
4454
3469
|
if (depth === 0 && (text[i] === "\"" || text[i] === "'")) {
|
|
4455
|
-
if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return
|
|
3470
|
+
if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return;
|
|
4456
3471
|
const str = text[i] === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
|
|
4457
|
-
if (!str) return
|
|
3472
|
+
if (!str) return;
|
|
4458
3473
|
const rest = skipWs(text, str.end);
|
|
4459
|
-
if (rest < text.length && text[rest] !== "#") return
|
|
3474
|
+
if (rest < text.length && text[rest] !== "#") return;
|
|
4460
3475
|
return {
|
|
4461
3476
|
depth: 0,
|
|
4462
3477
|
value: str.value
|
|
@@ -4466,33 +3481,21 @@ function scanValueLine(text, pos, depth) {
|
|
|
4466
3481
|
const ch = text[i];
|
|
4467
3482
|
if (ch === "#") break;
|
|
4468
3483
|
if (ch === "\"" || ch === "'") {
|
|
4469
|
-
if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return
|
|
3484
|
+
if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return;
|
|
4470
3485
|
const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
|
|
4471
|
-
if (!str) return
|
|
3486
|
+
if (!str) return;
|
|
4472
3487
|
i = str.end;
|
|
4473
3488
|
continue;
|
|
4474
3489
|
}
|
|
4475
3490
|
if (ch === "[" || ch === "{") depth++;
|
|
4476
3491
|
else if (ch === "]" || ch === "}") {
|
|
4477
3492
|
depth--;
|
|
4478
|
-
if (depth < 0) return
|
|
3493
|
+
if (depth < 0) return;
|
|
4479
3494
|
}
|
|
4480
3495
|
i++;
|
|
4481
3496
|
}
|
|
4482
3497
|
return { depth };
|
|
4483
3498
|
}
|
|
4484
|
-
/**
|
|
4485
|
-
* The `[projects."<path>"] trust_level = "..."` entries of a codex
|
|
4486
|
-
* `config.toml`, by a deliberately narrow reader (core takes no TOML
|
|
4487
|
-
* dependency for this). Handles what codex itself writes plus the reasonable
|
|
4488
|
-
* hand-edits — comments, CRLF, whitespace, quoted keys with escapes, literal
|
|
4489
|
-
* and bare keys, `[projects]`-with-dotted-keys and top-level dotted forms,
|
|
4490
|
-
* single-line inline tables, multi-line arrays — and returns **undefined for
|
|
4491
|
-
* anything else it meets anywhere in the file** (multi-line strings,
|
|
4492
|
-
* `projects` as an inline table, array-of-tables, junk): the caller treats
|
|
4493
|
-
* undefined as "cannot know" and stays silent. Conflicting duplicate entries
|
|
4494
|
-
* also refuse — invalid for TOML, and guessing wrong is a false notice.
|
|
4495
|
-
*/
|
|
4496
3499
|
function parseProjectTrustEntries(source) {
|
|
4497
3500
|
const entries = /* @__PURE__ */ new Map();
|
|
4498
3501
|
let section = [];
|
|
@@ -4501,7 +3504,7 @@ function parseProjectTrustEntries(source) {
|
|
|
4501
3504
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
4502
3505
|
if (carryDepth > 0) {
|
|
4503
3506
|
const scanned = scanValueLine(line, 0, carryDepth);
|
|
4504
|
-
if (!scanned) return
|
|
3507
|
+
if (!scanned) return;
|
|
4505
3508
|
carryDepth = scanned.depth;
|
|
4506
3509
|
continue;
|
|
4507
3510
|
}
|
|
@@ -4510,43 +3513,35 @@ function parseProjectTrustEntries(source) {
|
|
|
4510
3513
|
if (line[start] === "[") {
|
|
4511
3514
|
const array = line.startsWith("[[", start);
|
|
4512
3515
|
const path = parseKeyPath(line, start + (array ? 2 : 1));
|
|
4513
|
-
if (!path) return
|
|
3516
|
+
if (!path) return;
|
|
4514
3517
|
const close = array ? "]]" : "]";
|
|
4515
|
-
if (!line.startsWith(close, path.end)) return
|
|
3518
|
+
if (!line.startsWith(close, path.end)) return;
|
|
4516
3519
|
const rest = skipWs(line, path.end + close.length);
|
|
4517
|
-
if (rest < line.length && line[rest] !== "#") return
|
|
4518
|
-
if (array && path.value[0] === "projects") return
|
|
3520
|
+
if (rest < line.length && line[rest] !== "#") return;
|
|
3521
|
+
if (array && path.value[0] === "projects") return;
|
|
4519
3522
|
section = path.value;
|
|
4520
3523
|
continue;
|
|
4521
3524
|
}
|
|
4522
3525
|
const key = parseKeyPath(line, start);
|
|
4523
|
-
if (!key) return
|
|
4524
|
-
if (line[key.end] !== "=") return
|
|
3526
|
+
if (!key) return;
|
|
3527
|
+
if (line[key.end] !== "=") return;
|
|
4525
3528
|
const scanned = scanValueLine(line, key.end + 1, 0);
|
|
4526
|
-
if (!scanned) return
|
|
3529
|
+
if (!scanned) return;
|
|
4527
3530
|
carryDepth = scanned.depth;
|
|
4528
3531
|
const full = [...section, ...key.value];
|
|
4529
3532
|
if (full[0] !== "projects") continue;
|
|
4530
|
-
if (full.length < 3) return
|
|
3533
|
+
if (full.length < 3) return;
|
|
4531
3534
|
if (full.length === 3 && full[2] === "trust_level") {
|
|
4532
|
-
if (carryDepth !== 0 || scanned.value === void 0) return
|
|
3535
|
+
if (carryDepth !== 0 || scanned.value === void 0) return;
|
|
4533
3536
|
const project = full[1];
|
|
4534
3537
|
const existing = entries.get(project);
|
|
4535
|
-
if (existing !== void 0 && existing !== scanned.value) return
|
|
3538
|
+
if (existing !== void 0 && existing !== scanned.value) return;
|
|
4536
3539
|
entries.set(project, scanned.value);
|
|
4537
3540
|
}
|
|
4538
3541
|
}
|
|
4539
|
-
if (carryDepth > 0) return
|
|
3542
|
+
if (carryDepth > 0) return;
|
|
4540
3543
|
return entries;
|
|
4541
3544
|
}
|
|
4542
|
-
/**
|
|
4543
|
-
* A linked worktree inherits trust from its main repository's entry (measured:
|
|
4544
|
-
* trusting the main repo path loads the worktree's project config). The
|
|
4545
|
-
* worktree's `.git` is a FILE whose `gitdir:` line names
|
|
4546
|
-
* `<main>/.git/worktrees/<name>`; the directory owning that `.git` is the
|
|
4547
|
-
* anchor to look up. Anything unreadable or shaped differently resolves false
|
|
4548
|
-
* — this route can only ADD trust, i.e. silence, never a false notice.
|
|
4549
|
-
*/
|
|
4550
3545
|
function mainRepositoryTrusted(gitRootDir, canonical) {
|
|
4551
3546
|
const gitPath = join(gitRootDir, ".git");
|
|
4552
3547
|
try {
|
|
@@ -4565,15 +3560,6 @@ function mainRepositoryTrusted(gitRootDir, canonical) {
|
|
|
4565
3560
|
return false;
|
|
4566
3561
|
}
|
|
4567
3562
|
}
|
|
4568
|
-
/**
|
|
4569
|
-
* The notice for a codex session about to run on a cwd whose
|
|
4570
|
-
* `.codex/config.toml` codex will ignore, or undefined when there is nothing
|
|
4571
|
-
* to say — no project config anywhere codex would look, the project is
|
|
4572
|
-
* trusted, or the situation cannot be established with certainty. Read-only
|
|
4573
|
-
* throughout: WorkerDeck never writes trust entries (adjacent to the auth red
|
|
4574
|
-
* lines — trusting a directory is the operator's decision, made in codex's
|
|
4575
|
-
* own prompt or by their own hand).
|
|
4576
|
-
*/
|
|
4577
3563
|
function untrustedProjectNotice(options) {
|
|
4578
3564
|
let cwd;
|
|
4579
3565
|
try {
|
|
@@ -4604,17 +3590,17 @@ function untrustedProjectNotice(options) {
|
|
|
4604
3590
|
return false;
|
|
4605
3591
|
}
|
|
4606
3592
|
});
|
|
4607
|
-
if (layers.length === 0) return
|
|
3593
|
+
if (layers.length === 0) return;
|
|
4608
3594
|
const homeConfigPath = join(options.codexHome, "config.toml");
|
|
4609
3595
|
let source = "";
|
|
4610
3596
|
try {
|
|
4611
3597
|
source = readFileSync(homeConfigPath, "utf8");
|
|
4612
3598
|
} catch (error) {
|
|
4613
|
-
if (error.code !== "ENOENT") return
|
|
3599
|
+
if (error.code !== "ENOENT") return;
|
|
4614
3600
|
}
|
|
4615
3601
|
const entries = parseProjectTrustEntries(source);
|
|
4616
|
-
if (!entries) return
|
|
4617
|
-
for (const value of entries.values()) if (value !== "trusted" && value !== "untrusted") return
|
|
3602
|
+
if (!entries) return;
|
|
3603
|
+
for (const value of entries.values()) if (value !== "trusted" && value !== "untrusted") return;
|
|
4618
3604
|
const canonical = /* @__PURE__ */ new Map();
|
|
4619
3605
|
for (const [key, value] of entries) {
|
|
4620
3606
|
let path = key;
|
|
@@ -4630,59 +3616,25 @@ function untrustedProjectNotice(options) {
|
|
|
4630
3616
|
if (entry !== void 0) return entry !== "trusted";
|
|
4631
3617
|
return !rootTrusted;
|
|
4632
3618
|
});
|
|
4633
|
-
if (ignored.length === 0) return
|
|
3619
|
+
if (ignored.length === 0) return;
|
|
4634
3620
|
const trustDir = gitRoot ?? cwd;
|
|
4635
3621
|
const configs = ignored.map((layer) => join(layer, ".codex", "config.toml"));
|
|
4636
3622
|
return `codex does not trust this directory, so ${configs.length === 1 ? `its project config (${configs[0]}) is` : `its project configs (${configs.join(", ")}) are`} being ignored — MCP servers and settings declared there will be missing from this session. To trust it, run codex once in ${trustDir} and accept the trust prompt, or add [projects."${trustDir}"] with trust_level = "trusted" to ${homeConfigPath}.`;
|
|
4637
3623
|
}
|
|
4638
3624
|
//#endregion
|
|
4639
3625
|
//#region src/engines/codex/runner.ts
|
|
4640
|
-
/**
|
|
4641
|
-
* thread/start's sandbox axis (string form) — our permission modes as codex
|
|
4642
|
-
* sandbox modes: `default` → read-only (reads run; any mutation is refused by
|
|
4643
|
-
* the OS sandbox and — with the ask policy below — escalates to a real
|
|
4644
|
-
* question), `acceptEdits` → workspace-write (in-workspace writes sail
|
|
4645
|
-
* through, the acceptEdits grant), `bypassPermissions` → danger-full-access.
|
|
4646
|
-
* `auto` rides the SAME sandbox as acceptEdits — it is not a wider grant, it
|
|
4647
|
-
* only moves *who answers* the approvals (see {@link APPROVALS_REVIEWER_BY_MODE}).
|
|
4648
|
-
*/
|
|
4649
3626
|
const THREAD_SANDBOX_BY_MODE = {
|
|
4650
3627
|
default: "read-only",
|
|
4651
3628
|
acceptEdits: "workspace-write",
|
|
4652
3629
|
auto: "workspace-write",
|
|
4653
3630
|
bypassPermissions: "danger-full-access"
|
|
4654
3631
|
};
|
|
4655
|
-
/**
|
|
4656
|
-
* turn/start's sandboxPolicy axis (object form — same policy, second shape).
|
|
4657
|
-
*
|
|
4658
|
-
* The `workspaceWrite` entries here are a SHAPE, not the whole policy: every
|
|
4659
|
-
* unstated field of that variant is serde-defaulted by the app-server, so
|
|
4660
|
-
* sending it bare silently overrides the operator's `[sandbox_workspace_write]`
|
|
4661
|
-
* — `network_access` back to false, `writable_roots` back to empty — on every
|
|
4662
|
-
* turn. {@link CodexRunner.#turnSandboxPolicy} restates those fields from
|
|
4663
|
-
* `config/read`; nothing else may send this map's `workspaceWrite` entries
|
|
4664
|
-
* directly.
|
|
4665
|
-
*/
|
|
4666
3632
|
const TURN_SANDBOX_BY_MODE = {
|
|
4667
3633
|
default: { type: "readOnly" },
|
|
4668
3634
|
acceptEdits: { type: "workspaceWrite" },
|
|
4669
3635
|
auto: { type: "workspaceWrite" },
|
|
4670
3636
|
bypassPermissions: { type: "dangerFullAccess" }
|
|
4671
3637
|
};
|
|
4672
|
-
/**
|
|
4673
|
-
* The approval axis, stated as the GRANULAR object on both thread/start and
|
|
4674
|
-
* turn/start — never the string vocabulary, deliberately and unconditionally:
|
|
4675
|
-
* measured against 0.146.0, plain `'untrusted'` never asked anything (a
|
|
4676
|
-
* sandbox-violating write was silently refused, a safe echo auto-approved),
|
|
4677
|
-
* while the granular flags make a blocked action a real server→client
|
|
4678
|
-
* question. Granular policies are gated on `capabilities.experimentalApi` at
|
|
4679
|
-
* initialize; WorkerDeck declares it always and keeps NO non-experimental
|
|
4680
|
-
* fallback — a future binary that rejects either gate fails loudly (see
|
|
4681
|
-
* {@link CodexRunner.#ensureThread}) instead of quietly not asking.
|
|
4682
|
-
*
|
|
4683
|
-
* `default`/`acceptEdits` ask (all flags on — the sandbox axis above already
|
|
4684
|
-
* decides *what needs asking*); `bypassPermissions` asks nothing, same shape.
|
|
4685
|
-
*/
|
|
4686
3638
|
const GRANULAR_ASK = { granular: {
|
|
4687
3639
|
sandbox_approval: true,
|
|
4688
3640
|
rules: true,
|
|
@@ -4697,12 +3649,7 @@ const GRANULAR_NEVER = { granular: {
|
|
|
4697
3649
|
request_permissions: false,
|
|
4698
3650
|
skill_approval: false
|
|
4699
3651
|
} };
|
|
4700
|
-
|
|
4701
|
-
* Notifications whose meaning is scoped to ONE thread, and which are therefore
|
|
4702
|
-
* only ever read off the session's own. Everything else (items, deltas) is
|
|
4703
|
-
* accepted from any thread on the connection — see `#handleNotification`.
|
|
4704
|
-
*/
|
|
4705
|
-
const THREAD_SCOPED_NOTIFICATIONS = new Set([
|
|
3652
|
+
const THREAD_SCOPED_NOTIFICATIONS = /* @__PURE__ */ new Set([
|
|
4706
3653
|
"turn/started",
|
|
4707
3654
|
"turn/completed",
|
|
4708
3655
|
"thread/tokenUsage/updated"
|
|
@@ -4713,62 +3660,20 @@ const APPROVAL_POLICY_BY_MODE = {
|
|
|
4713
3660
|
auto: GRANULAR_ASK,
|
|
4714
3661
|
bypassPermissions: GRANULAR_NEVER
|
|
4715
3662
|
};
|
|
4716
|
-
/**
|
|
4717
|
-
* The THIRD approval axis — *who reviews*, independent of the sandbox axis and
|
|
4718
|
-
* the ask axis above. Codex's `approvalsReviewer` (thread/start and turn/start,
|
|
4719
|
-
* present since 0.146.0) routes every approval request either to the user
|
|
4720
|
-
* (`'user'`, codex's own default) or to `'auto_review'`: a prompted subagent
|
|
4721
|
-
* that gathers context and applies a risk framework before allowing or denying.
|
|
4722
|
-
* That is codex's "Approve for me" preset, and our `auto` mode is exactly it.
|
|
4723
|
-
*
|
|
4724
|
-
* Sent explicitly for EVERY mode rather than omitted for the default — a thread
|
|
4725
|
-
* inherits `approvalsReviewer` across turns ("this turn and subsequent turns"),
|
|
4726
|
-
* so leaving it unset would let a stale reviewer from an earlier turn survive a
|
|
4727
|
-
* mode switch back to a user-reviewed mode. Stating it every time makes the
|
|
4728
|
-
* mode the single source of truth.
|
|
4729
|
-
*
|
|
4730
|
-
* NOTE the asymmetry with the Claude engine's `auto`: that classifier is
|
|
4731
|
-
* operator-configurable (`autoMode.environment`, allow/soft_deny/hard_deny);
|
|
4732
|
-
* this reviewer has no configuration surface at all.
|
|
4733
|
-
*/
|
|
4734
3663
|
const APPROVALS_REVIEWER_BY_MODE = {
|
|
4735
3664
|
default: "user",
|
|
4736
3665
|
acceptEdits: "user",
|
|
4737
3666
|
auto: "auto_review",
|
|
4738
3667
|
bypassPermissions: "user"
|
|
4739
3668
|
};
|
|
4740
|
-
/** Fallback timeout for a pending approval nobody answers — the SessionRunner
|
|
4741
|
-
* default, so unattended codex sessions land the same way Claude ones do. */
|
|
4742
3669
|
const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
|
|
4743
|
-
/**
|
|
4744
|
-
* Tool name for codex's built-in `image_gen`. A stable string because it is a
|
|
4745
|
-
* rendering contract: both clients key an icon (and, where they can reach the
|
|
4746
|
-
* host filesystem, an inline preview) off it.
|
|
4747
|
-
*/
|
|
4748
3670
|
const CODEX_IMAGE_TOOL = "CodexImageGeneration";
|
|
4749
|
-
/**
|
|
4750
|
-
* Tool name for a spawned agent's anchor `tool_use` — the claude engine's
|
|
4751
|
-
* `Task` in this engine's vocabulary. Codex never sends such a call: the model's
|
|
4752
|
-
* `spawn_agent` surfaces only as the `subAgentActivity` marker item, so the
|
|
4753
|
-
* runner authors the call itself, because everything downstream is built on a
|
|
4754
|
-
* top-level `tool_use` existing — `terminalBlocks` absorbs a sidechain into the
|
|
4755
|
-
* call whose id its events carry as `parentToolUseId`, the takeover frames by
|
|
4756
|
-
* it, and `taskIdentity` labels it from the input's `subagent_type`. Not a new
|
|
4757
|
-
* wire idea, just a row: the same shape every other codex tool card uses.
|
|
4758
|
-
*/
|
|
4759
3671
|
const CODEX_AGENT_TOOL = "CodexAgent";
|
|
4760
|
-
/** Tool name for the model's collab-agent calls (`wait`, `sendInput`, …), the
|
|
4761
|
-
* `tool` field carried in the input. One name for the whole open axis rather
|
|
4762
|
-
* than a name per verb, so a future verb renders instead of vanishing. */
|
|
4763
3672
|
const CODEX_COLLAB_TOOL = "CodexCollab";
|
|
4764
|
-
/** An agent's name is its path's basename: '/root/date_one' → 'date_one'. */
|
|
4765
3673
|
function agentName(agentPath) {
|
|
4766
|
-
if (typeof agentPath !== "string") return
|
|
3674
|
+
if (typeof agentPath !== "string") return;
|
|
4767
3675
|
return agentPath.split("/").filter(Boolean).at(-1) || void 0;
|
|
4768
3676
|
}
|
|
4769
|
-
/** The collab card's input: the verb always, the rich fields only when codex
|
|
4770
|
-
* actually filled them (measured against 0.146.0 they arrive empty — the card
|
|
4771
|
-
* must not render five null columns to say 'wait'). */
|
|
4772
3677
|
function collabInput(item) {
|
|
4773
3678
|
return {
|
|
4774
3679
|
tool: item.tool,
|
|
@@ -4777,9 +3682,6 @@ function collabInput(item) {
|
|
|
4777
3682
|
...item.model ? { model: item.model } : {}
|
|
4778
3683
|
};
|
|
4779
3684
|
}
|
|
4780
|
-
/** A completed turn's answer, from its summary `items` page — the last
|
|
4781
|
-
* `agentMessage` text. For a sub-agent's thread this is the agent's report,
|
|
4782
|
-
* which is exactly what belongs in the anchor's `tool_result`. */
|
|
4783
3685
|
function turnReport(turn) {
|
|
4784
3686
|
const items = Array.isArray(turn.items) ? turn.items : [];
|
|
4785
3687
|
for (let index = items.length - 1; index >= 0; index--) {
|
|
@@ -4787,29 +3689,13 @@ function turnReport(turn) {
|
|
|
4787
3689
|
if (item?.type === "agentMessage" && typeof item.text === "string" && item.text) return item.text;
|
|
4788
3690
|
}
|
|
4789
3691
|
}
|
|
4790
|
-
/** Longest `result` worth putting in a tool card. The field is free-form and
|
|
4791
|
-
* undocumented; anything past this is assumed to be an encoded image rather
|
|
4792
|
-
* than a sentence, and encoded images do not go in the event log. */
|
|
4793
3692
|
const MAX_IMAGE_RESULT_CHARS = 512;
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
*
|
|
4798
|
-
* Two properties fall out of that and both are load-bearing: codex reports the
|
|
4799
|
-
* same `savedPath` on the progress item and again on the completed one, so a
|
|
4800
|
-
* derived id makes the second emission a no-op instead of a duplicate row; and
|
|
4801
|
-
* a session rebuilt from a snapshot re-derives the same ids, so a client's
|
|
4802
|
-
* cached URL still resolves after a park/restore.
|
|
4803
|
-
*/
|
|
3693
|
+
function shortResult(result) {
|
|
3694
|
+
return result.length > 0 && result.length <= MAX_IMAGE_RESULT_CHARS && !result.startsWith("data:");
|
|
3695
|
+
}
|
|
4804
3696
|
function producedFileId(path) {
|
|
4805
3697
|
return createHash("sha256").update(path).digest("hex").slice(0, 32);
|
|
4806
3698
|
}
|
|
4807
|
-
/** Media type from the extension, for the handful a client renders inline.
|
|
4808
|
-
* Undefined for everything else — the route sniffs, and guessing here is how a
|
|
4809
|
-
* text file ends up labelled `image/png`. */
|
|
4810
|
-
function producedMediaType(path) {
|
|
4811
|
-
return PRODUCED_MEDIA_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()];
|
|
4812
|
-
}
|
|
4813
3699
|
const PRODUCED_MEDIA_TYPES = {
|
|
4814
3700
|
png: "image/png",
|
|
4815
3701
|
jpg: "image/jpeg",
|
|
@@ -4819,12 +3705,10 @@ const PRODUCED_MEDIA_TYPES = {
|
|
|
4819
3705
|
svg: "image/svg+xml",
|
|
4820
3706
|
pdf: "application/pdf"
|
|
4821
3707
|
};
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
* considers live, and defaulting to false would hide working skills.
|
|
4827
|
-
*/
|
|
3708
|
+
function producedMediaType(path) {
|
|
3709
|
+
const extension = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
3710
|
+
return PRODUCED_MEDIA_TYPES[extension];
|
|
3711
|
+
}
|
|
4828
3712
|
function skillInfo(skill) {
|
|
4829
3713
|
return {
|
|
4830
3714
|
name: skill.name,
|
|
@@ -4836,19 +3720,6 @@ function skillInfo(skill) {
|
|
|
4836
3720
|
enabled: skill.enabled !== false
|
|
4837
3721
|
};
|
|
4838
3722
|
}
|
|
4839
|
-
/**
|
|
4840
|
-
* Codex's MCP status → the protocol's, which is Claude Code's vocabulary
|
|
4841
|
-
* ('connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled').
|
|
4842
|
-
*
|
|
4843
|
-
* Two inputs, and the auth one wins where it applies: a server that started
|
|
4844
|
-
* fine but has no credential is *needs-auth*, not connected, because that is
|
|
4845
|
-
* the thing the operator has to act on. `notLoggedIn` is the only auth value
|
|
4846
|
-
* that means "unusable" — `unsupported` is the normal answer for a stdio server
|
|
4847
|
-
* that has no auth concept at all.
|
|
4848
|
-
*
|
|
4849
|
-
* A server with no startup notification yet is 'pending', not 'connected':
|
|
4850
|
-
* `mcpServerStatus/list` alone only proves it is *configured*.
|
|
4851
|
-
*/
|
|
4852
3723
|
function mcpStatusOf(authStatus, update, hasTools) {
|
|
4853
3724
|
if (update?.status === "failed") return update.failureReason === "reauthenticationRequired" ? "needs-auth" : "failed";
|
|
4854
3725
|
if (update?.status === "cancelled") return "failed";
|
|
@@ -4857,7 +3728,6 @@ function mcpStatusOf(authStatus, update, hasTools) {
|
|
|
4857
3728
|
if (hasTools) return "connected";
|
|
4858
3729
|
return "pending";
|
|
4859
3730
|
}
|
|
4860
|
-
/** One `mcpServerStatus/list` entry as the protocol states it. */
|
|
4861
3731
|
function mcpServerInfo(server, update) {
|
|
4862
3732
|
const tools = Object.entries(server.tools ?? {}).flatMap(([key, tool]) => {
|
|
4863
3733
|
if (!tool) return [];
|
|
@@ -4884,55 +3754,26 @@ function mcpServerInfo(server, update) {
|
|
|
4884
3754
|
...tools.length > 0 ? { tools } : {}
|
|
4885
3755
|
};
|
|
4886
3756
|
}
|
|
4887
|
-
/** What the card shows while the picture is being made, and after. `savedPath`
|
|
4888
|
-
* only exists once it lands — a client keys its preview off it, so it is a
|
|
4889
|
-
* field rather than a sentence in the result text. */
|
|
4890
3757
|
function imageGenerationInput(item) {
|
|
4891
3758
|
return {
|
|
4892
3759
|
...item.revisedPrompt ? { prompt: item.revisedPrompt } : {},
|
|
4893
3760
|
...item.savedPath ? { savedPath: item.savedPath } : {}
|
|
4894
3761
|
};
|
|
4895
3762
|
}
|
|
4896
|
-
/**
|
|
4897
|
-
* The experimental per-request decision list, normalized to names: a string
|
|
4898
|
-
* entry is its own name, a structured entry (`{acceptWithExecpolicyAmendment:
|
|
4899
|
-
* …}`) is named by its key. Undefined = the request stated no list and the
|
|
4900
|
-
* channel's schema enum applies. Present only under `experimentalApi: true` —
|
|
4901
|
-
* which WorkerDeck always declares.
|
|
4902
|
-
*/
|
|
4903
3763
|
function offeredDecisions(params) {
|
|
4904
3764
|
const raw = params?.availableDecisions;
|
|
4905
|
-
if (!Array.isArray(raw)) return
|
|
3765
|
+
if (!Array.isArray(raw)) return;
|
|
4906
3766
|
const names = /* @__PURE__ */ new Set();
|
|
4907
3767
|
for (const entry of raw) if (typeof entry === "string") names.add(entry);
|
|
4908
3768
|
else if (entry && typeof entry === "object") for (const key of Object.keys(entry)) names.add(key);
|
|
4909
3769
|
return names.size > 0 ? names : void 0;
|
|
4910
3770
|
}
|
|
4911
|
-
/**
|
|
4912
|
-
* Decision picking for the `{decision: …}` channels (commandExecution,
|
|
4913
|
-
* fileChange), honoring the request's own `availableDecisions`:
|
|
4914
|
-
*
|
|
4915
|
-
* - allow → 'accept' when offered (or when no list was stated). A request
|
|
4916
|
-
* offering only the broader accepts ('acceptForSession',
|
|
4917
|
-
* 'acceptWithExecpolicyAmendment') yields undefined: a one-shot allow must
|
|
4918
|
-
* not be silently widened into a session-wide or persistent policy grant, so
|
|
4919
|
-
* the caller answers with the denial and says why.
|
|
4920
|
-
* - deny → 'decline', always: the response schema declares it unconditionally,
|
|
4921
|
-
* and it was verified live against 0.146.0 answering a request whose
|
|
4922
|
-
* availableDecisions omitted it — the turn completed cleanly. The list's job
|
|
4923
|
-
* is to gate the accept variants, not to take "no, but keep going" away
|
|
4924
|
-
* (its own alternative, 'cancel', would interrupt the whole turn).
|
|
4925
|
-
* - deny+interrupt → 'cancel' (codex's deny-and-interrupt) when offered;
|
|
4926
|
-
* otherwise 'decline', and the caller interrupts the turn itself.
|
|
4927
|
-
*/
|
|
4928
3771
|
function pickDecision(behavior, interrupt, offered) {
|
|
4929
3772
|
const has = (name) => !offered || offered.has(name);
|
|
4930
3773
|
if (behavior === "allow") return has("accept") ? "accept" : void 0;
|
|
4931
3774
|
if (interrupt && has("cancel")) return "cancel";
|
|
4932
3775
|
return "decline";
|
|
4933
3776
|
}
|
|
4934
|
-
/** Codex `requestUserInput` questions in the AskUserQuestion wire shape both
|
|
4935
|
-
* clients already render (QuestionPrompt / QuestionPromptView). */
|
|
4936
3777
|
function userQuestionsFromCodex(questions) {
|
|
4937
3778
|
return questions.map((question) => ({
|
|
4938
3779
|
question: question.question,
|
|
@@ -4943,18 +3784,6 @@ function userQuestionsFromCodex(questions) {
|
|
|
4943
3784
|
}))
|
|
4944
3785
|
}));
|
|
4945
3786
|
}
|
|
4946
|
-
/**
|
|
4947
|
-
* The text of a history `userMessage` item: its content entries' text parts
|
|
4948
|
-
* joined.
|
|
4949
|
-
*
|
|
4950
|
-
* Image parts have no replayable representation — the bytes went to the model,
|
|
4951
|
-
* not into the rollout we can render from — so they are named rather than
|
|
4952
|
-
* dropped. A prompt that was *only* an image used to produce an empty string,
|
|
4953
|
-
* which the caller read as "nothing to replay" and skipped: the turn lost its
|
|
4954
|
-
* user row and, with it, the prompt mark the scrubber navigates by, so a resumed
|
|
4955
|
-
* thread had answers with no visible question. A word in place of the picture is
|
|
4956
|
-
* a smaller lie than a turn that never happened.
|
|
4957
|
-
*/
|
|
4958
3787
|
function historyUserText(item) {
|
|
4959
3788
|
if (!Array.isArray(item.content)) return "";
|
|
4960
3789
|
let images = 0;
|
|
@@ -4967,9 +3796,6 @@ function historyUserText(item) {
|
|
|
4967
3796
|
if (text) return text;
|
|
4968
3797
|
return images > 0 ? `[${images === 1 ? "image" : `${images} images`}]` : "";
|
|
4969
3798
|
}
|
|
4970
|
-
/** The AskUserQuestion answer convention (question text → chosen label(s),
|
|
4971
|
-
* comma-joined) mapped back to codex's id-keyed shape. Questions the client
|
|
4972
|
-
* did not answer are absent, not empty. */
|
|
4973
3799
|
function codexAnswers(questions, answers) {
|
|
4974
3800
|
const out = {};
|
|
4975
3801
|
for (const question of questions) {
|
|
@@ -4978,7 +3804,6 @@ function codexAnswers(questions, answers) {
|
|
|
4978
3804
|
}
|
|
4979
3805
|
return out;
|
|
4980
3806
|
}
|
|
4981
|
-
/** The two channels whose response is `{decision: …}` share their pick logic. */
|
|
4982
3807
|
function decisionChannel(describe, itemId) {
|
|
4983
3808
|
return {
|
|
4984
3809
|
describe,
|
|
@@ -4999,11 +3824,6 @@ function decisionChannel(describe, itemId) {
|
|
|
4999
3824
|
}
|
|
5000
3825
|
};
|
|
5001
3826
|
}
|
|
5002
|
-
/**
|
|
5003
|
-
* The ask channels, wired to the permission surface. Anything not listed here
|
|
5004
|
-
* still gets a JSON-RPC -32601 — never a hang (an unanswered server request
|
|
5005
|
-
* wedges the turn).
|
|
5006
|
-
*/
|
|
5007
3827
|
const APPROVAL_CHANNELS = {
|
|
5008
3828
|
"item/commandExecution/requestApproval": decisionChannel((raw) => {
|
|
5009
3829
|
const params = raw;
|
|
@@ -5094,129 +3914,48 @@ const APPROVAL_CHANNELS = {
|
|
|
5094
3914
|
deny: (_raw, interrupt) => ({ response: { action: interrupt ? "cancel" : "decline" } })
|
|
5095
3915
|
}
|
|
5096
3916
|
};
|
|
5097
|
-
/**
|
|
5098
|
-
* Name a subscription window by its measured length, so codex's positional
|
|
5099
|
-
* windows land in the protocol's named vocabulary. The two names clients
|
|
5100
|
-
* already understand are exact matches for codex's durations (300 min = 5h,
|
|
5101
|
-
* 10080 min = 7d); anything else keeps a self-describing key rather than
|
|
5102
|
-
* borrowing a name that would size it wrongly.
|
|
5103
|
-
*/
|
|
5104
3917
|
function rateLimitWindowName(minutes) {
|
|
5105
|
-
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return
|
|
3918
|
+
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return;
|
|
5106
3919
|
if (minutes === 300) return "five_hour";
|
|
5107
3920
|
if (minutes === 10080) return "seven_day";
|
|
5108
3921
|
return `window_${minutes}m`;
|
|
5109
3922
|
}
|
|
5110
|
-
/**
|
|
5111
|
-
* The Codex engine, over the binary's `app-server` JSON-RPC surface: ONE
|
|
5112
|
-
* `codex app-server` child per *session* (spawned lazily, held across turns),
|
|
5113
|
-
* streaming `item/agentMessage/delta` and the reasoning deltas token-by-token
|
|
5114
|
-
* (`streaming: 'token'`). Follows `SessionRunner`'s event-log/seq/status
|
|
5115
|
-
* discipline with `AiSdkRunner`'s turn-chain (one turn at a time; sendMessage
|
|
5116
|
-
* queues). The first codex transport was `codex exec --experimental-json` (one
|
|
5117
|
-
* child per turn) — retired because its JSONL carries no partial messages, so
|
|
5118
|
-
* a turn could never stream.
|
|
5119
|
-
*
|
|
5120
|
-
* A dead child is a failed *turn*, not a failed session: the thread persists
|
|
5121
|
-
* on disk, the connection is dropped, and the next message spawns a fresh
|
|
5122
|
-
* child that `thread/resume`s the same thread id.
|
|
5123
|
-
*/
|
|
5124
3923
|
var CodexRunner = class {
|
|
5125
3924
|
id;
|
|
5126
3925
|
createdAt;
|
|
5127
3926
|
#config;
|
|
5128
|
-
/** {@link CodexRunnerConfig.cwd}, checked once in the constructor. */
|
|
5129
3927
|
#cwd;
|
|
5130
|
-
#
|
|
3928
|
+
#log = new EventLog();
|
|
5131
3929
|
#subscribers = new SubscriberSet();
|
|
5132
|
-
#seq = 0;
|
|
5133
|
-
/**
|
|
5134
|
-
* Latest context-window reading, retained from the last `context_usage` this
|
|
5135
|
-
* runner emitted so `GET /sessions` can answer it without an attach — see
|
|
5136
|
-
* `SessionInfo.contextUsage`. Folded in the emit path, so it is by
|
|
5137
|
-
* construction the same number the transcript last drew.
|
|
5138
|
-
*/
|
|
5139
|
-
#contextUsage;
|
|
5140
|
-
#activityCount = 0;
|
|
5141
|
-
/**
|
|
5142
|
-
* Seq of the latest `conversation_reset` event, 0 when none. The log itself is
|
|
5143
|
-
* never truncated — it still carries the state-bearing events (`capabilities`,
|
|
5144
|
-
* `system_init`, …) a fresh attacher depends on and which are not re-emitted —
|
|
5145
|
-
* but `subscribe()` skips transcript *content* strictly below this mark, so a
|
|
5146
|
-
* replay does not resurrect a cleared conversation. A later reset supersedes
|
|
5147
|
-
* an earlier one by overwriting it.
|
|
5148
|
-
*/
|
|
5149
|
-
#resetSeq = 0;
|
|
5150
3930
|
#status = "starting";
|
|
3931
|
+
#statusDetail;
|
|
5151
3932
|
#sdkSessionId;
|
|
5152
3933
|
#model;
|
|
5153
3934
|
#permissionMode;
|
|
5154
3935
|
#reasoningEffort;
|
|
5155
|
-
/** What the binary said the profile's defaults resolve to (thread/start
|
|
5156
|
-
* response) — lets `setModel(undefined)` mean "back to the default" even
|
|
5157
|
-
* though a turn/start override persists for subsequent turns. */
|
|
5158
3936
|
#resolvedModel;
|
|
5159
|
-
/** Last reported ChatGPT plan, so `plan_info` is emitted once per change. */
|
|
5160
3937
|
#planType;
|
|
5161
3938
|
#resolvedEffort;
|
|
5162
3939
|
#queue = [];
|
|
5163
3940
|
#turnChain = Promise.resolve();
|
|
5164
3941
|
#activeTurn;
|
|
5165
3942
|
#connection;
|
|
5166
|
-
/** Per-child, from `config/read`; undefined = read failed, send the bare shape. */
|
|
5167
3943
|
#workspaceWrite;
|
|
5168
3944
|
#threadLoaded = false;
|
|
5169
3945
|
#numTurns = 0;
|
|
5170
3946
|
#totalCostUsd;
|
|
5171
|
-
#lastActivityAt;
|
|
5172
3947
|
#started = false;
|
|
5173
3948
|
#closed = false;
|
|
5174
|
-
/** Session temp dir for image attachments (`localImage` takes host paths). */
|
|
5175
3949
|
#imageDir;
|
|
5176
|
-
/** Pending server→client approvals, keyed by the surfaced request id. */
|
|
5177
3950
|
#approvals = /* @__PURE__ */ new Map();
|
|
5178
|
-
/** True from start() until the resume backfill (the turn chain's first link)
|
|
5179
|
-
* settles — while set, sendMessage defers its user_message echo behind the
|
|
5180
|
-
* chain so a new turn can never precede or interleave the replayed history. */
|
|
5181
3951
|
#backfillPending = false;
|
|
5182
|
-
/** The resumed thread's prior turns, stashed by {@link #ensureThread} from
|
|
5183
|
-
* the ONE thread/resume the backfill consumes (`partial` = the response's
|
|
5184
|
-
* turnsBackwardsCursor said older turns exist beyond this page). A mid-life
|
|
5185
|
-
* reconnect also goes through thread/resume, but with no backfill pending
|
|
5186
|
-
* nothing is stashed — history is never replayed twice. */
|
|
5187
3952
|
#resumedHistory;
|
|
5188
|
-
/** Set around history replay: {@link #emit} stamps `replay: true` onto the
|
|
5189
|
-
* message events the live item mapping produces. */
|
|
5190
3953
|
#replayingHistory = false;
|
|
5191
|
-
/** Last `skills` payload emitted, serialized — the comparison that keeps a
|
|
5192
|
-
* `skills/changed` storm (the watcher fires per touched file) from filling
|
|
5193
|
-
* the event log with identical lists. */
|
|
5194
3954
|
#skillsFingerprint;
|
|
5195
|
-
/** In-flight `skills/list`, so a burst of `skills/changed` makes one call.
|
|
5196
|
-
* The pending promise is reused rather than queued: the request has no
|
|
5197
|
-
* arguments, so a second one would ask the same question. */
|
|
5198
3955
|
#skillsRefresh;
|
|
5199
|
-
/** Host paths already announced via `file_produced`, so the same picture
|
|
5200
|
-
* reported on both the progress and the completed item registers once. */
|
|
5201
3956
|
#producedPaths = /* @__PURE__ */ new Set();
|
|
5202
|
-
/** Per-server liveness, accumulated from `mcpServer/startupStatus/updated`.
|
|
5203
|
-
* `mcpServerStatus/list` does not carry a status field at all, so without
|
|
5204
|
-
* this every server would read as "configured" and never as up or down. */
|
|
5205
3957
|
#mcpStatus = /* @__PURE__ */ new Map();
|
|
5206
|
-
/** The spawned agents, keyed by their thread ids — the attribution table
|
|
5207
|
-
* behind `parentToolUseId` and the rollup behind `info().subagents`. Runner-
|
|
5208
|
-
* level, not per-turn: an agent's thread outlives the root turn that spawned
|
|
5209
|
-
* it, and only the child process dying (or the session closing) ends them
|
|
5210
|
-
* all — see the module doc in `subagents.ts`. */
|
|
5211
3958
|
#agents = new CodexAgentTracker();
|
|
5212
|
-
/** Threads that belonged to a conversation this session has cleared — the
|
|
5213
|
-
* agents that were still running when it happened. Their notifications keep
|
|
5214
|
-
* arriving on the same connection (a clear does not interrupt them and does
|
|
5215
|
-
* not drop the child), and without this {@link CodexRunner.#agentFor} would
|
|
5216
|
-
* mint them a fresh anchor and stream the cleared conversation's agent work
|
|
5217
|
-
* into the new one. Never pruned: it is a handful of uuids for the session's
|
|
5218
|
-
* life, and a late report from a long-dead agent is exactly what it exists to
|
|
5219
|
-
* catch. */
|
|
5220
3959
|
#clearedThreads = /* @__PURE__ */ new Set();
|
|
5221
3960
|
constructor(config, id = randomUUID()) {
|
|
5222
3961
|
const mode = config.permissionMode ?? "default";
|
|
@@ -5232,15 +3971,8 @@ var CodexRunner = class {
|
|
|
5232
3971
|
this.id = id;
|
|
5233
3972
|
this.createdAt = Date.now();
|
|
5234
3973
|
}
|
|
5235
|
-
/** The complete child environment — spawn env replaces process.env wholesale,
|
|
5236
|
-
* so this must carry everything a shell would, with the profile's CODEX_HOME
|
|
5237
|
-
* pin winning over operator env. */
|
|
5238
3974
|
#childEnv() {
|
|
5239
|
-
|
|
5240
|
-
const env = {};
|
|
5241
|
-
for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
|
|
5242
|
-
if (this.#config.codexHome) env.CODEX_HOME = this.#config.codexHome;
|
|
5243
|
-
return env;
|
|
3975
|
+
return codexChildEnv(this.#config.env ?? process.env, this.#config.codexHome);
|
|
5244
3976
|
}
|
|
5245
3977
|
get status() {
|
|
5246
3978
|
return this.#status;
|
|
@@ -5249,7 +3981,7 @@ var CodexRunner = class {
|
|
|
5249
3981
|
return this.#sdkSessionId;
|
|
5250
3982
|
}
|
|
5251
3983
|
get lastSeq() {
|
|
5252
|
-
return this.#seq;
|
|
3984
|
+
return this.#log.seq;
|
|
5253
3985
|
}
|
|
5254
3986
|
get pendingApprovals() {
|
|
5255
3987
|
return [...this.#approvals.values()].map((pending) => pending.request);
|
|
@@ -5267,36 +3999,22 @@ var CodexRunner = class {
|
|
|
5267
3999
|
permissionMode: this.#permissionMode,
|
|
5268
4000
|
canBypassPermissions: true,
|
|
5269
4001
|
createdAt: this.createdAt,
|
|
5270
|
-
lastSeq: this.#seq,
|
|
5271
|
-
activityCount: this.#activityCount,
|
|
5272
|
-
|
|
4002
|
+
lastSeq: this.#log.seq,
|
|
4003
|
+
activityCount: this.#log.activityCount,
|
|
4004
|
+
proseCount: this.#log.proseCount,
|
|
4005
|
+
contextUsage: this.#log.contextUsage,
|
|
5273
4006
|
pendingPermissionCount: this.#approvals.size,
|
|
5274
4007
|
meta: this.#config.meta,
|
|
5275
4008
|
scope: this.#config.scope,
|
|
5276
|
-
title: this.#
|
|
4009
|
+
title: sessionTitle(this.#config),
|
|
5277
4010
|
totalCostUsd: this.#totalCostUsd,
|
|
5278
4011
|
numTurns: this.#numTurns || void 0,
|
|
5279
|
-
lastActivityAt: this.#lastActivityAt,
|
|
4012
|
+
lastActivityAt: this.#log.lastActivityAt,
|
|
5280
4013
|
subagents: this.#agents.list()
|
|
5281
4014
|
};
|
|
5282
4015
|
}
|
|
5283
|
-
#title() {
|
|
5284
|
-
const metaTitle = this.#config.meta?.title;
|
|
5285
|
-
if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
|
|
5286
|
-
const prompt = this.#config.prompt;
|
|
5287
|
-
if (!prompt) return void 0;
|
|
5288
|
-
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
5289
|
-
}
|
|
5290
|
-
/** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
|
|
5291
|
-
* it (undefined) restores the derived title. The engine is never told. */
|
|
5292
4016
|
setTitle(title) {
|
|
5293
|
-
|
|
5294
|
-
if (title) meta.title = title;
|
|
5295
|
-
else delete meta.title;
|
|
5296
|
-
this.#config = {
|
|
5297
|
-
...this.#config,
|
|
5298
|
-
meta
|
|
5299
|
-
};
|
|
4017
|
+
this.#config = withTitle(this.#config, title);
|
|
5300
4018
|
}
|
|
5301
4019
|
start() {
|
|
5302
4020
|
if (this.#started) return this.#turnChain;
|
|
@@ -5310,20 +4028,6 @@ var CodexRunner = class {
|
|
|
5310
4028
|
if (!this.#config.prompt && !this.#config.resume) this.#probeSkills();
|
|
5311
4029
|
return this.#turnChain;
|
|
5312
4030
|
}
|
|
5313
|
-
/**
|
|
5314
|
-
* One-time transcript notice for the codex trust gap: a `default`-mode
|
|
5315
|
-
* session (read-only sandbox) on an untrusted cwd has its
|
|
5316
|
-
* `.codex/config.toml` — MCP servers included — silently ignored, and the
|
|
5317
|
-
* app-server surface has no trust prompt to say so (the TUI's prompt is
|
|
5318
|
-
* where the entry normally gets written). `acceptEdits`/`bypassPermissions`
|
|
5319
|
-
* sessions are exempt because their `thread/start` (workspace-write /
|
|
5320
|
-
* danger-full-access sandbox) writes the trust entry itself and loads the
|
|
5321
|
-
* config — measured against 0.146.0 and 0.149.0; a notice there would be
|
|
5322
|
-
* false. Emitted as `session_error`, which both clients render as an inline
|
|
5323
|
-
* notice while the session keeps running (the backfill-history precedent),
|
|
5324
|
-
* so nothing new rides the wire. Every degrade path is silence: a false
|
|
5325
|
-
* warning on a trusted project is worse than a missed one.
|
|
5326
|
-
*/
|
|
5327
4031
|
#warnUntrustedProject() {
|
|
5328
4032
|
if (this.#permissionMode !== "default") return;
|
|
5329
4033
|
try {
|
|
@@ -5341,20 +4045,6 @@ var CodexRunner = class {
|
|
|
5341
4045
|
});
|
|
5342
4046
|
} catch {}
|
|
5343
4047
|
}
|
|
5344
|
-
/**
|
|
5345
|
-
* List skills over a **throwaway** connection, for a session with nothing else
|
|
5346
|
-
* to do yet.
|
|
5347
|
-
*
|
|
5348
|
-
* `skills/list` needs a live child but not a thread, so this spawns one, asks,
|
|
5349
|
-
* and closes it — rather than bringing up the session's own child early and
|
|
5350
|
-
* leaving a codex process parked behind every session someone created and
|
|
5351
|
-
* never typed into. The session's real connection re-lists when it arrives;
|
|
5352
|
-
* the fingerprint compare in {@link #refreshSkills} makes that a no-op.
|
|
5353
|
-
*
|
|
5354
|
-
* Entirely best-effort and never awaited: a missing binary, a failed spawn or
|
|
5355
|
-
* a rejected handshake here must not turn a session that has not started into
|
|
5356
|
-
* a session that failed.
|
|
5357
|
-
*/
|
|
5358
4048
|
async #probeSkills() {
|
|
5359
4049
|
let connection;
|
|
5360
4050
|
try {
|
|
@@ -5365,28 +4055,10 @@ var CodexRunner = class {
|
|
|
5365
4055
|
connection?.close();
|
|
5366
4056
|
}
|
|
5367
4057
|
}
|
|
5368
|
-
/**
|
|
5369
|
-
* A handshaken child that is **not** the session's — for the questions a
|
|
5370
|
-
* client can ask before the session has anything to run (its skills, its MCP
|
|
5371
|
-
* servers). The caller owns it and must close it.
|
|
5372
|
-
*
|
|
5373
|
-
* No onNotification/onRequest/onClose wiring on purpose: this child answers
|
|
5374
|
-
* one question and goes away, so its notifications are noise and its death is
|
|
5375
|
-
* not the session's problem. The alternative — bringing the session's real
|
|
5376
|
-
* child up early — would park a codex process behind every session someone
|
|
5377
|
-
* created and never typed into.
|
|
5378
|
-
*/
|
|
5379
4058
|
async #openScratchConnection() {
|
|
5380
4059
|
const connection = this.#config.connectFn({ env: this.#childEnv() });
|
|
5381
4060
|
try {
|
|
5382
|
-
await connection.request("initialize",
|
|
5383
|
-
clientInfo: {
|
|
5384
|
-
name: "workerdeck",
|
|
5385
|
-
title: "WorkerDeck",
|
|
5386
|
-
version: `protocol-${PROTOCOL_VERSION}`
|
|
5387
|
-
},
|
|
5388
|
-
capabilities: { experimentalApi: true }
|
|
5389
|
-
});
|
|
4061
|
+
await connection.request("initialize", INITIALIZE_PARAMS);
|
|
5390
4062
|
connection.notify("initialized");
|
|
5391
4063
|
return connection;
|
|
5392
4064
|
} catch (error) {
|
|
@@ -5421,12 +4093,6 @@ var CodexRunner = class {
|
|
|
5421
4093
|
this.#queue.push({ input });
|
|
5422
4094
|
this.#scheduleTurn();
|
|
5423
4095
|
}
|
|
5424
|
-
/**
|
|
5425
|
-
* App-server input for a message with attachments: images land in a session
|
|
5426
|
-
* temp dir and travel as `localImage` host paths, text files inline into the
|
|
5427
|
-
* prompt in the shared named envelope, PDF has no representation (the
|
|
5428
|
-
* gateway's 415 normally refuses it first).
|
|
5429
|
-
*/
|
|
5430
4096
|
#buildInput(text, attachments) {
|
|
5431
4097
|
const parts = [];
|
|
5432
4098
|
for (const attachment of attachments) {
|
|
@@ -5459,8 +4125,6 @@ var CodexRunner = class {
|
|
|
5459
4125
|
});
|
|
5460
4126
|
return parts;
|
|
5461
4127
|
}
|
|
5462
|
-
/** Resolve a pending approval. Returns false if the id is unknown (e.g.
|
|
5463
|
-
* timed out, or already settled by codex itself). */
|
|
5464
4128
|
resolvePermission(requestId, decision) {
|
|
5465
4129
|
const pending = this.#approvals.get(requestId);
|
|
5466
4130
|
if (!pending) return false;
|
|
@@ -5476,42 +4140,12 @@ var CodexRunner = class {
|
|
|
5476
4140
|
await this.#interruptTurn();
|
|
5477
4141
|
await this.#turnChain;
|
|
5478
4142
|
}
|
|
5479
|
-
/**
|
|
5480
|
-
* Reset the conversation: a **fresh thread on the same session**.
|
|
5481
|
-
*
|
|
5482
|
-
* Codex has no clear/reset RPC — `thread/compact/start` summarises and
|
|
5483
|
-
* continues, `thread/fork` makes a second thread, and neither is "same
|
|
5484
|
-
* session, empty context". So the analog is to stop resuming the old thread
|
|
5485
|
-
* and start a new one, which is the path a dead child already takes minus the
|
|
5486
|
-
* resume. The old thread is NOT deleted: it stays in CODEX_HOME and stays
|
|
5487
|
-
* resumable from `GET /sdk-sessions`.
|
|
5488
|
-
*
|
|
5489
|
-
* Two things it does on the way through, both mirroring the Claude engine's
|
|
5490
|
-
* SDK-driven reset (`engines/claude/runner.ts`):
|
|
5491
|
-
*
|
|
5492
|
-
* 1. **The new thread id is adopted before `conversation_reset` is emitted**,
|
|
5493
|
-
* whenever a child is already up — the eager `thread/start` costs no
|
|
5494
|
-
* tokens and no model call, and it is what keeps the dormant record from
|
|
5495
|
-
* ever naming the conversation that was just cleared. With no child there
|
|
5496
|
-
* is nothing to start against and the id is simply dropped; the parking
|
|
5497
|
-
* service treats a resumable session with no engine session id as one with
|
|
5498
|
-
* nothing to come back to, and forgets the stale record.
|
|
5499
|
-
* 2. **The context reading is retired**, in `#emit`'s `conversation_reset`
|
|
5500
|
-
* arm. Codex cannot re-poll it the way Claude does — the only source is
|
|
5501
|
-
* `thread/tokenUsage/updated`, which arrives *during* a turn — so there is
|
|
5502
|
-
* no reading at all until the next turn runs, and the protocol's rule
|
|
5503
|
-
* applies: render nothing rather than a stale ring or a 0%.
|
|
5504
|
-
*
|
|
5505
|
-
* The turn counter stays monotonic across this, on purpose (it is an unread
|
|
5506
|
-
* cursor, not an item count), and so does `activityCount` — `#emit` owns both.
|
|
5507
|
-
*/
|
|
5508
4143
|
async clearContext() {
|
|
5509
4144
|
if (this.#closed) throw new Error("session is closed");
|
|
5510
4145
|
const run = this.#turnChain.then(() => this.#clearNow());
|
|
5511
4146
|
this.#turnChain = run.then(() => void 0, () => void 0);
|
|
5512
4147
|
await run;
|
|
5513
4148
|
}
|
|
5514
|
-
/** The clear itself, only ever called as a turn-chain link. */
|
|
5515
4149
|
async #clearNow() {
|
|
5516
4150
|
if (this.#closed) throw new Error("session is closed");
|
|
5517
4151
|
const previousThread = this.#sdkSessionId;
|
|
@@ -5536,8 +4170,6 @@ var CodexRunner = class {
|
|
|
5536
4170
|
sdkSessionId: this.#sdkSessionId
|
|
5537
4171
|
});
|
|
5538
4172
|
}
|
|
5539
|
-
/** Address the in-flight turn only (no approval sweep) — also the follow-up
|
|
5540
|
-
* for a deny+interrupt whose wire decision couldn't carry the interrupt. */
|
|
5541
4173
|
async #interruptTurn() {
|
|
5542
4174
|
const active = this.#activeTurn;
|
|
5543
4175
|
const connection = this.#connection;
|
|
@@ -5606,37 +4238,15 @@ var CodexRunner = class {
|
|
|
5606
4238
|
});
|
|
5607
4239
|
this.#setStatus("closed");
|
|
5608
4240
|
}
|
|
5609
|
-
/** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
|
|
5610
|
-
* "show everything" on one row, so a per-runner seq index would be a map
|
|
5611
|
-
* maintained on every emit to save a walk nobody makes twice a minute. */
|
|
5612
4241
|
eventAt(seq) {
|
|
5613
|
-
return this.#
|
|
4242
|
+
return this.#log.at(seq);
|
|
5614
4243
|
}
|
|
5615
4244
|
subscribe(listener, afterSeq = 0, options) {
|
|
5616
|
-
return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
|
|
4245
|
+
return this.#subscribers.subscribe(this.#log.events, listener, afterSeq, options, this.#log.resetSeq);
|
|
5617
4246
|
}
|
|
5618
4247
|
#scheduleTurn() {
|
|
5619
4248
|
this.#turnChain = this.#turnChain.then(() => this.#runTurn());
|
|
5620
4249
|
}
|
|
5621
|
-
/**
|
|
5622
|
-
* Read `[sandbox_workspace_write]` as codex resolves it for this session's
|
|
5623
|
-
* cwd, once per child, so {@link CodexRunner.#turnSandboxPolicy} can restate
|
|
5624
|
-
* it verbatim.
|
|
5625
|
-
*
|
|
5626
|
-
* Why this exists at all: `turn/start`'s object-form sandbox policy is
|
|
5627
|
-
* serde-defaulted field by field, so `{type: 'workspaceWrite'}` bare means
|
|
5628
|
-
* `networkAccess: false, writableRoots: []` NO MATTER what the operator
|
|
5629
|
-
* configured — and we must keep sending the object every turn, because
|
|
5630
|
-
* restating it is what makes a between-turns permission-mode switch take
|
|
5631
|
-
* effect. Measured against 0.149.0 with `network_access = true` set: the
|
|
5632
|
-
* bare object produced `curl: (6) Could not resolve host`, the fully-stated
|
|
5633
|
-
* object and an omitted policy both produced `200`. `read-only` is not
|
|
5634
|
-
* affected — the setting is scoped to workspace-write, as its name says, and
|
|
5635
|
-
* a read-only sandbox has no network either way.
|
|
5636
|
-
*
|
|
5637
|
-
* A failure here is not fatal: `#workspaceWrite` stays undefined and we send
|
|
5638
|
-
* the bare shape, which is exactly the behaviour that shipped before.
|
|
5639
|
-
*/
|
|
5640
4250
|
async #readWorkspaceWrite(connection) {
|
|
5641
4251
|
this.#workspaceWrite = void 0;
|
|
5642
4252
|
try {
|
|
@@ -5651,7 +4261,6 @@ var CodexRunner = class {
|
|
|
5651
4261
|
};
|
|
5652
4262
|
} catch {}
|
|
5653
4263
|
}
|
|
5654
|
-
/** The mode's turn-level sandbox policy, with the operator's workspace-write settings intact. */
|
|
5655
4264
|
#turnSandboxPolicy() {
|
|
5656
4265
|
const policy = TURN_SANDBOX_BY_MODE[this.#permissionMode];
|
|
5657
4266
|
if (policy?.type !== "workspaceWrite" || !this.#workspaceWrite) return policy;
|
|
@@ -5660,13 +4269,6 @@ var CodexRunner = class {
|
|
|
5660
4269
|
...this.#workspaceWrite
|
|
5661
4270
|
};
|
|
5662
4271
|
}
|
|
5663
|
-
/**
|
|
5664
|
-
* The session's live connection with its thread loaded, (re)building both as
|
|
5665
|
-
* needed: spawn + `initialize`/`initialized` on a fresh child, then
|
|
5666
|
-
* `thread/start` (new) or `thread/resume` (a create-request `resume`, or a
|
|
5667
|
-
* thread orphaned by a dead child). The response's resolved model/effort are
|
|
5668
|
-
* kept so per-turn overrides can name "the profile default" explicitly.
|
|
5669
|
-
*/
|
|
5670
4272
|
async #ensureThread() {
|
|
5671
4273
|
if (this.#closed) throw new Error("session is closed");
|
|
5672
4274
|
let connection = this.#connection;
|
|
@@ -5689,18 +4291,11 @@ var CodexRunner = class {
|
|
|
5689
4291
|
this.#activeTurn?.reject(new Error(message));
|
|
5690
4292
|
});
|
|
5691
4293
|
try {
|
|
5692
|
-
await connection.request("initialize",
|
|
5693
|
-
clientInfo: {
|
|
5694
|
-
name: "workerdeck",
|
|
5695
|
-
title: "WorkerDeck",
|
|
5696
|
-
version: `protocol-${PROTOCOL_VERSION}`
|
|
5697
|
-
},
|
|
5698
|
-
capabilities: { experimentalApi: true }
|
|
5699
|
-
});
|
|
4294
|
+
await connection.request("initialize", INITIALIZE_PARAMS);
|
|
5700
4295
|
} catch (error) {
|
|
5701
4296
|
connection.close();
|
|
5702
4297
|
if (this.#connection === connection) this.#connection = void 0;
|
|
5703
|
-
if (error instanceof JsonRpcError) throw new Error("codex app-server rejected initialize (capabilities.experimentalApi: true — required for the granular approval policy, and WorkerDeck has no non-experimental fallback): " + error.message);
|
|
4298
|
+
if (error instanceof JsonRpcError) throw new Error("codex app-server rejected initialize (capabilities.experimentalApi: true — required for the granular approval policy, and WorkerDeck has no non-experimental fallback): " + error.message, { cause: error });
|
|
5704
4299
|
throw error;
|
|
5705
4300
|
}
|
|
5706
4301
|
connection.notify("initialized");
|
|
@@ -5731,21 +4326,6 @@ var CodexRunner = class {
|
|
|
5731
4326
|
this.#refreshSkills(connection);
|
|
5732
4327
|
return connection;
|
|
5733
4328
|
}
|
|
5734
|
-
/**
|
|
5735
|
-
* Re-read `skills/list` and publish it, if it changed.
|
|
5736
|
-
*
|
|
5737
|
-
* **`cwds` is passed explicitly, and must be.** The schema documents the empty
|
|
5738
|
-
* case as "the current session working directory", which reads like the
|
|
5739
|
-
* thread's — it is not. Measured against 0.146.0: with no `cwds`, and *after*
|
|
5740
|
-
* a `thread/start` carrying this session's cwd, the response comes back keyed
|
|
5741
|
-
* to the app-server child's own process directory (for WorkerDeck, wherever
|
|
5742
|
-
* the gateway was launched) and reports no repo-scoped skills at all. So a
|
|
5743
|
-
* project's own `.codex/skills/**` were invisible until this argument existed.
|
|
5744
|
-
*
|
|
5745
|
-
* Best-effort throughout. A binary too old to know the method, a broken
|
|
5746
|
-
* manifest, a child that died mid-call — none of that is worth failing a
|
|
5747
|
-
* session over, and the panel simply stays absent.
|
|
5748
|
-
*/
|
|
5749
4329
|
async #refreshSkills(connection) {
|
|
5750
4330
|
if (this.#skillsRefresh) return this.#skillsRefresh;
|
|
5751
4331
|
const run = (async () => {
|
|
@@ -5775,33 +4355,8 @@ var CodexRunner = class {
|
|
|
5775
4355
|
this.#skillsRefresh = run;
|
|
5776
4356
|
return run;
|
|
5777
4357
|
}
|
|
5778
|
-
/**
|
|
5779
|
-
* The session's MCP servers, live from the binary.
|
|
5780
|
-
*
|
|
5781
|
-
* Two sources merged, because codex splits them: `mcpServerStatus/list` says
|
|
5782
|
-
* what is configured and what each server exposes (including every tool's
|
|
5783
|
-
* full JSON Schema, which the Agent SDK does not give us), and the
|
|
5784
|
-
* `mcpServer/startupStatus/updated` notifications say which of them are
|
|
5785
|
-
* actually up.
|
|
5786
|
-
*
|
|
5787
|
-
* Answers **before the session has connected**, over a throwaway child, for
|
|
5788
|
-
* the same reason the skill list does: a codex session spawns nothing until
|
|
5789
|
-
* it has work, and a panel that said "no MCP servers configured" until the
|
|
5790
|
-
* first turn would be stating something false about the operator's config.
|
|
5791
|
-
* The request blocks until the servers are enumerated (measured: complete on
|
|
5792
|
-
* the very first call), so there is no half-populated answer to race.
|
|
5793
|
-
*
|
|
5794
|
-
* Resolves undefined only when there is genuinely nothing to say — the
|
|
5795
|
-
* session is closed, or the child could not be spoken to. The route turns
|
|
5796
|
-
* that into a 501.
|
|
5797
|
-
*
|
|
5798
|
-
* **Listing only.** There is no per-server reconnect or toggle on this
|
|
5799
|
-
* transport — hence no `reconnectMcpServer`/`setMcpServerEnabled` here, and
|
|
5800
|
-
* `ENGINE_CAPABILITIES.codex.mcpServerActions: false` so clients render the
|
|
5801
|
-
* panel read-only instead of offering buttons that cannot work.
|
|
5802
|
-
*/
|
|
5803
4358
|
async mcpServers() {
|
|
5804
|
-
if (this.#closed) return
|
|
4359
|
+
if (this.#closed) return;
|
|
5805
4360
|
const live = this.#connection;
|
|
5806
4361
|
let scratch;
|
|
5807
4362
|
try {
|
|
@@ -5812,15 +4367,6 @@ var CodexRunner = class {
|
|
|
5812
4367
|
scratch?.close();
|
|
5813
4368
|
}
|
|
5814
4369
|
}
|
|
5815
|
-
/**
|
|
5816
|
-
* Announce a file the ENGINE wrote on the host, so a client can fetch it
|
|
5817
|
-
* without the operator having declared its directory as a host-file root.
|
|
5818
|
-
*
|
|
5819
|
-
* Deliberately narrow: only paths codex reports as *written by its own tool*
|
|
5820
|
-
* belong here. A path the model merely read (`imageView`) is an agent-chosen
|
|
5821
|
-
* claim, and those keep going through `/fs/*` and its root allowlist — see
|
|
5822
|
-
* the note on `file_produced` in the protocol.
|
|
5823
|
-
*/
|
|
5824
4370
|
#emitFileProduced(path, toolUseId) {
|
|
5825
4371
|
if (this.#producedPaths.has(path)) return;
|
|
5826
4372
|
this.#producedPaths.add(path);
|
|
@@ -5838,16 +4384,6 @@ var CodexRunner = class {
|
|
|
5838
4384
|
toolUseId
|
|
5839
4385
|
});
|
|
5840
4386
|
}
|
|
5841
|
-
/**
|
|
5842
|
-
* On resume, replay the thread's prior turns as `replay: true` events,
|
|
5843
|
-
* seq'd before any live turn — the SessionRunner backfill contract, fed
|
|
5844
|
-
* from `thread/resume`'s own `thread.turns`. When the resume response says
|
|
5845
|
-
* that page is partial (`turnsBackwardsCursor`), the FULL rollout history
|
|
5846
|
-
* is fetched via `thread/read {includeTurns: true}` instead — and if even
|
|
5847
|
-
* that fails, the partial page is replayed under a visible notice rather
|
|
5848
|
-
* than silently posing as the whole thread. Best-effort like the Claude
|
|
5849
|
-
* backfill: an unreadable history never blocks the resume itself.
|
|
5850
|
-
*/
|
|
5851
4387
|
async #backfillHistory() {
|
|
5852
4388
|
try {
|
|
5853
4389
|
if (this.#closed) return;
|
|
@@ -5876,7 +4412,6 @@ var CodexRunner = class {
|
|
|
5876
4412
|
this.#setStatus("idle");
|
|
5877
4413
|
}
|
|
5878
4414
|
}
|
|
5879
|
-
/** Replay historical turns through the SAME item mapping the live path uses. */
|
|
5880
4415
|
#replayTurns(turns) {
|
|
5881
4416
|
for (const turn of turns) {
|
|
5882
4417
|
if (this.#closed) return;
|
|
@@ -5905,8 +4440,6 @@ var CodexRunner = class {
|
|
|
5905
4440
|
}
|
|
5906
4441
|
}
|
|
5907
4442
|
}
|
|
5908
|
-
/** Fresh per-turn state — one per live turn, and one per REPLAYED turn (the
|
|
5909
|
-
* nonce is the item-id namespace, and its per-turn-ness is the invariant). */
|
|
5910
4443
|
#newTurnState() {
|
|
5911
4444
|
return {
|
|
5912
4445
|
nonce: randomUUID(),
|
|
@@ -5995,10 +4528,6 @@ var CodexRunner = class {
|
|
|
5995
4528
|
}
|
|
5996
4529
|
this.#notifications[method]?.(params);
|
|
5997
4530
|
}
|
|
5998
|
-
/** Whether a notification is about the session's own thread. A notification
|
|
5999
|
-
* with no `threadId` counts as the root's: every thread-scoped method the
|
|
6000
|
-
* schema defines carries one, so an absent id means an older or narrower
|
|
6001
|
-
* shape, not a sub-agent. */
|
|
6002
4531
|
#isRootThread(params) {
|
|
6003
4532
|
const threadId = this.#threadIdOf(params);
|
|
6004
4533
|
if (threadId === void 0) return true;
|
|
@@ -6008,42 +4537,18 @@ var CodexRunner = class {
|
|
|
6008
4537
|
const threadId = params?.threadId;
|
|
6009
4538
|
return typeof threadId === "string" ? threadId : void 0;
|
|
6010
4539
|
}
|
|
6011
|
-
/**
|
|
6012
|
-
* The agent behind a notification's `threadId` — the attribution every item
|
|
6013
|
-
* and delta handler asks before emitting, so two agents streaming
|
|
6014
|
-
* concurrently into this one connection come apart again by the id each
|
|
6015
|
-
* frame carries, never by any mutable "current agent".
|
|
6016
|
-
*
|
|
6017
|
-
* A non-root thread with no record still gets one: a thread emitting items on
|
|
6018
|
-
* this connection *is* an agent, whatever announced it (codex runs threads of
|
|
6019
|
-
* its own for review/compact, and a `subAgentActivity` could in principle be
|
|
6020
|
-
* missed) — the claude tracker's nested-event fallback, on a stronger signal.
|
|
6021
|
-
* The minted record is label-less and its anchor is authored here, because an
|
|
6022
|
-
* attributed event whose parent id matches no top-level `tool_use` would
|
|
6023
|
-
* render inline rather than as a frame; a late `started` edge fills the name
|
|
6024
|
-
* in. Root-thread traffic — and, defensively, the pre-thread shapes with no
|
|
6025
|
-
* id at all — stays unattributed (`undefined`).
|
|
6026
|
-
*/
|
|
6027
4540
|
#agentFor(params) {
|
|
6028
4541
|
const threadId = this.#threadIdOf(params);
|
|
6029
|
-
if (threadId === void 0 || threadId === this.#sdkSessionId) return
|
|
4542
|
+
if (threadId === void 0 || threadId === this.#sdkSessionId) return;
|
|
6030
4543
|
const known = this.#agents.get(threadId);
|
|
6031
4544
|
if (known) return known;
|
|
6032
|
-
if (this.#clearedThreads.has(threadId)) return
|
|
4545
|
+
if (this.#clearedThreads.has(threadId)) return;
|
|
6033
4546
|
const nonce = this.#activeTurn?.nonce ?? "codex";
|
|
6034
4547
|
const record = this.#agents.open(threadId, `${nonce}:agent:${threadId}`, void 0, Date.now());
|
|
6035
4548
|
record.anchored = true;
|
|
6036
4549
|
this.#emitToolUse(record.toolUseId, CODEX_AGENT_TOOL, { agentThreadId: threadId });
|
|
6037
4550
|
return record;
|
|
6038
4551
|
}
|
|
6039
|
-
/**
|
|
6040
|
-
* A child thread's `turn/completed` is that AGENT's completion — the one
|
|
6041
|
-
* codex sends (`subAgentActivity` has no 'completed' kind, verified live).
|
|
6042
|
-
* The verdict is the turn's own status, and the report is the completed
|
|
6043
|
-
* turn's final message, delivered as the anchor's `tool_result` so the row
|
|
6044
|
-
* settles exactly the way a claude `Task`'s does. Deliberately not gated on
|
|
6045
|
-
* `#activeTurn`: an agent finishing between root turns still finished.
|
|
6046
|
-
*/
|
|
6047
4552
|
#settleAgentTurn(params) {
|
|
6048
4553
|
const threadId = this.#threadIdOf(params);
|
|
6049
4554
|
const record = threadId ? this.#agents.get(threadId) : void 0;
|
|
@@ -6054,11 +4559,6 @@ var CodexRunner = class {
|
|
|
6054
4559
|
const report = (turn ? turnReport(turn) : void 0) ?? turn?.error?.message ?? (status === "done" ? "" : turn?.status ?? "failed");
|
|
6055
4560
|
this.#emitToolResult(record.toolUseId, report, status === "failed");
|
|
6056
4561
|
}
|
|
6057
|
-
/** Reasoning deltas arrive on two methods that differ only in which section
|
|
6058
|
-
* counter they advance; the section key carries the method so the two streams
|
|
6059
|
-
* never share a boundary (and item ids are per-thread, so two agents' streams
|
|
6060
|
-
* never share one either). Section boundaries (a new summary/content entry)
|
|
6061
|
-
* render as paragraph breaks — the completed item joins sections with '\n\n'. */
|
|
6062
4562
|
#reasoningDelta(method) {
|
|
6063
4563
|
return (params) => {
|
|
6064
4564
|
const active = this.#activeTurn;
|
|
@@ -6066,26 +4566,23 @@ var CodexRunner = class {
|
|
|
6066
4566
|
const payload = params;
|
|
6067
4567
|
if (typeof payload?.delta !== "string" || !payload.delta) return;
|
|
6068
4568
|
const index = payload.contentIndex ?? payload.summaryIndex ?? 0;
|
|
6069
|
-
const
|
|
4569
|
+
const agent = this.#agentFor(params);
|
|
4570
|
+
const key = `${agent?.toolUseId ?? ""}:${payload.itemId ?? ""}:${method}`;
|
|
6070
4571
|
const previous = active.sectionIndex.get(key);
|
|
6071
4572
|
active.sectionIndex.set(key, index);
|
|
6072
4573
|
const separator = previous !== void 0 && index > previous ? "\n\n" : "";
|
|
6073
4574
|
this.#emitDelta({
|
|
6074
4575
|
type: "thinking_delta",
|
|
6075
4576
|
thinking: separator + payload.delta
|
|
6076
|
-
},
|
|
4577
|
+
}, agent?.toolUseId ?? null);
|
|
6077
4578
|
};
|
|
6078
4579
|
}
|
|
6079
|
-
/** One item-progress handler serves `item/started` and `item/updated`. */
|
|
6080
4580
|
#itemProgress = (params) => {
|
|
6081
4581
|
const active = this.#activeTurn;
|
|
6082
4582
|
if (!active) return;
|
|
6083
4583
|
const item = params?.item;
|
|
6084
4584
|
if (item) this.#handleItemProgress(item, active, this.#agentFor(params));
|
|
6085
4585
|
};
|
|
6086
|
-
/** The notification dispatch table — every method the child emits that this
|
|
6087
|
-
* runner maps, in one place. Handlers read `this.#activeTurn` themselves:
|
|
6088
|
-
* dispatch is synchronous, so the read is the same one the old switch made. */
|
|
6089
4586
|
#notifications = {
|
|
6090
4587
|
"thread/started": (params) => {
|
|
6091
4588
|
const thread = params?.thread;
|
|
@@ -6180,26 +4677,17 @@ var CodexRunner = class {
|
|
|
6180
4677
|
return;
|
|
6181
4678
|
}
|
|
6182
4679
|
},
|
|
6183
|
-
|
|
4680
|
+
error: (params) => {
|
|
6184
4681
|
const active = this.#activeTurn;
|
|
6185
4682
|
const error = params?.error;
|
|
6186
4683
|
if (active && typeof error?.message === "string") active.lastError = error.message;
|
|
6187
4684
|
}
|
|
6188
4685
|
};
|
|
6189
|
-
/** Answer a server→client request: the ask channels become pending
|
|
6190
|
-
* permission requests; anything else gets a JSON-RPC -32601 rather than a
|
|
6191
|
-
* hang (an unanswered server request wedges the turn). */
|
|
6192
4686
|
async #answerServerRequest(method, params, wireId) {
|
|
6193
4687
|
const channel = APPROVAL_CHANNELS[method];
|
|
6194
4688
|
if (channel) return this.#requestApproval(channel, method, params, wireId);
|
|
6195
4689
|
throw new JsonRpcError(-32601, `workerdeck does not handle server request '${method}'`);
|
|
6196
4690
|
}
|
|
6197
|
-
/**
|
|
6198
|
-
* Surface one ask-channel request as a pending {@link PermissionRequest};
|
|
6199
|
-
* the returned promise is the JSON-RPC response, resolved when a
|
|
6200
|
-
* `permission_decision` lands — or by the timeout, an interrupt, turn end,
|
|
6201
|
-
* session close, or codex resolving it itself. Never left hanging.
|
|
6202
|
-
*/
|
|
6203
4691
|
#requestApproval(channel, method, params, wireId) {
|
|
6204
4692
|
if (method === "item/tool/requestUserInput") {
|
|
6205
4693
|
const behavior = this.#config.questionBehavior ?? "ask";
|
|
@@ -6238,9 +4726,6 @@ var CodexRunner = class {
|
|
|
6238
4726
|
if (this.#activeTurn) this.#setStatus("awaiting_approval");
|
|
6239
4727
|
});
|
|
6240
4728
|
}
|
|
6241
|
-
/** 'auto'/'deny' sessions settle codex questions synchronously instead of
|
|
6242
|
-
* pending. Request/resolved events still fire so transcripts and job
|
|
6243
|
-
* webhooks show what was chosen. */
|
|
6244
4729
|
#resolveQuestionByPolicy(channel, params, mode) {
|
|
6245
4730
|
const itemId = channel.itemId(params);
|
|
6246
4731
|
const request = {
|
|
@@ -6275,13 +4760,6 @@ var CodexRunner = class {
|
|
|
6275
4760
|
});
|
|
6276
4761
|
return { answers };
|
|
6277
4762
|
}
|
|
6278
|
-
/**
|
|
6279
|
-
* Settle one pending approval: pick the channel's wire response for the
|
|
6280
|
-
* decision, answer the JSON-RPC request, and emit `permission_resolved`.
|
|
6281
|
-
* An allow the request offered no plain accept for becomes the channel's
|
|
6282
|
-
* denial, said out loud — never a silently widened grant, and never a
|
|
6283
|
-
* decision the request didn't offer.
|
|
6284
|
-
*/
|
|
6285
4763
|
#settleApproval(id, pending, decision, resolvedBy) {
|
|
6286
4764
|
clearTimeout(pending.timer);
|
|
6287
4765
|
this.#approvals.delete(id);
|
|
@@ -6309,9 +4787,6 @@ var CodexRunner = class {
|
|
|
6309
4787
|
if (behavior === "deny" && decision.behavior === "deny" && decision.interrupt && sent.decision !== "cancel") this.#interruptTurn();
|
|
6310
4788
|
if (!this.#closed && this.#approvals.size === 0 && this.#status === "awaiting_approval") this.#setStatus("running");
|
|
6311
4789
|
}
|
|
6312
|
-
/** Tool calls surface as tool_use when they start; text and reasoning stream
|
|
6313
|
-
* natively via the delta notifications. `agent` is the sub-agent whose thread
|
|
6314
|
-
* the item arrived on — undefined for the session's own. */
|
|
6315
4790
|
#handleItemProgress(item, active, agent) {
|
|
6316
4791
|
const id = `${active.nonce}:${item.id}`;
|
|
6317
4792
|
if (item.type === "subAgentActivity") {
|
|
@@ -6355,14 +4830,6 @@ var CodexRunner = class {
|
|
|
6355
4830
|
}
|
|
6356
4831
|
});
|
|
6357
4832
|
}
|
|
6358
|
-
/**
|
|
6359
|
-
* The completed-item mapping, one handler per member of the {@link AppServerItem}
|
|
6360
|
-
* union. The mapped type is the invariant made checkable: model a new item
|
|
6361
|
-
* type in `types.ts` and this table fails to compile until it says what the
|
|
6362
|
-
* item becomes on the wire — the old switch silently fell through to the
|
|
6363
|
-
* unknown-item passthrough instead. (The runtime still receives types the
|
|
6364
|
-
* union has never heard of; those take the passthrough above.)
|
|
6365
|
-
*/
|
|
6366
4833
|
#itemCompleted = {
|
|
6367
4834
|
userMessage: (item, active, _id, agent) => {
|
|
6368
4835
|
if (!agent) return;
|
|
@@ -6502,10 +4969,6 @@ var CodexRunner = class {
|
|
|
6502
4969
|
uuid
|
|
6503
4970
|
});
|
|
6504
4971
|
}
|
|
6505
|
-
/** `agent` (rather than a bare parent id) because a nested call is also the
|
|
6506
|
-
* agent's progress reading: `SubagentInfo.toolCount` ticks here, once per
|
|
6507
|
-
* card — the `counted` set is what keeps an upserted re-emission (the
|
|
6508
|
-
* finished imageGeneration input) from counting one picture twice. */
|
|
6509
4972
|
#emitToolUse(id, name, input, agent) {
|
|
6510
4973
|
if (agent && !agent.counted.has(id)) {
|
|
6511
4974
|
agent.counted.add(id);
|
|
@@ -6545,14 +5008,6 @@ var CodexRunner = class {
|
|
|
6545
5008
|
uuid: `${toolUseId}-result`
|
|
6546
5009
|
});
|
|
6547
5010
|
}
|
|
6548
|
-
/**
|
|
6549
|
-
* Per-turn usage re-mapped to the Anthropic accounting convention the whole
|
|
6550
|
-
* stack assumes (GOTCHAS §Codex engine): OpenAI's `inputTokens` includes the
|
|
6551
|
-
* cached share, so input excludes it (else queue token budgets double-count
|
|
6552
|
-
* cache-heavy runs); reasoning tokens are billed output; `totalCostUsd: 0` =
|
|
6553
|
-
* unknown, the AiSdkRunner precedent. Usage is summed from the turn's
|
|
6554
|
-
* `thread/tokenUsage/updated` notifications — `turn/completed` carries none.
|
|
6555
|
-
*/
|
|
6556
5011
|
#finishTurn(kind, startedAt, active, errors) {
|
|
6557
5012
|
for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
|
|
6558
5013
|
behavior: "deny",
|
|
@@ -6580,23 +5035,6 @@ var CodexRunner = class {
|
|
|
6580
5035
|
this.#emitContextUsage(active);
|
|
6581
5036
|
this.#setStatus("idle");
|
|
6582
5037
|
}
|
|
6583
|
-
/**
|
|
6584
|
-
* Subscription windows, mapped onto the protocol's named vocabulary.
|
|
6585
|
-
*
|
|
6586
|
-
* The shapes disagree: codex reports windows *positionally* (`primary` /
|
|
6587
|
-
* `secondary`) with a length in minutes, while `RateLimitInfo.rateLimitType`
|
|
6588
|
-
* is a name whose meaning clients already know — iOS labels `seven_day` as
|
|
6589
|
-
* "Weekly" and derives the pace marker's denominator from it. Naming the
|
|
6590
|
-
* window by its measured duration is therefore the honest mapping rather
|
|
6591
|
-
* than a borrowed one: codex's primary window is 10080 minutes, which *is*
|
|
6592
|
-
* seven days. A duration we have no name for keeps an explicit
|
|
6593
|
-
* `window_<n>m` key — clients render it verbatim and simply draw no pace
|
|
6594
|
-
* marker, which beats mislabeling it as a week.
|
|
6595
|
-
*
|
|
6596
|
-
* `status` is 'allowed' by construction (the session is running), matching
|
|
6597
|
-
* `rateLimitEventsFromUsage`; codex's `rateLimitReachedType` is the one
|
|
6598
|
-
* signal that a limit is actually biting, so it becomes 'rejected'.
|
|
6599
|
-
*/
|
|
6600
5038
|
#emitRateLimits(limits) {
|
|
6601
5039
|
if (!limits) return;
|
|
6602
5040
|
const status = limits.rateLimitReachedType ? "rejected" : "allowed";
|
|
@@ -6620,16 +5058,6 @@ var CodexRunner = class {
|
|
|
6620
5058
|
});
|
|
6621
5059
|
}
|
|
6622
5060
|
}
|
|
6623
|
-
/**
|
|
6624
|
-
* Context occupancy, after the turn — the same cadence the Claude runner
|
|
6625
|
-
* polls `getContextUsage()` on, so clients need nothing new.
|
|
6626
|
-
*
|
|
6627
|
-
* Emitted only when the binary gave BOTH numbers: the protocol is explicit
|
|
6628
|
-
* that a client renders nothing rather than a 0% ring, and a window of
|
|
6629
|
-
* `null` (which app-server does send) would otherwise divide into a
|
|
6630
|
-
* meaningless percentage. `categories` is empty because codex publishes no
|
|
6631
|
-
* breakdown — clients must not render an empty "Breakdown" section for it.
|
|
6632
|
-
*/
|
|
6633
5061
|
#emitContextUsage(active) {
|
|
6634
5062
|
const totalTokens = active.contextTokens;
|
|
6635
5063
|
const maxTokens = active.contextWindow;
|
|
@@ -6646,9 +5074,10 @@ var CodexRunner = class {
|
|
|
6646
5074
|
});
|
|
6647
5075
|
}
|
|
6648
5076
|
#setStatus(status, detail) {
|
|
6649
|
-
if (this.#status === status) return;
|
|
5077
|
+
if (this.#status === status && this.#statusDetail === detail) return;
|
|
6650
5078
|
if (this.#status === "closed" || this.#status === "failed") return;
|
|
6651
5079
|
this.#status = status;
|
|
5080
|
+
this.#statusDetail = detail;
|
|
6652
5081
|
this.#emit({
|
|
6653
5082
|
type: "status_changed",
|
|
6654
5083
|
status,
|
|
@@ -6660,32 +5089,26 @@ var CodexRunner = class {
|
|
|
6660
5089
|
...body,
|
|
6661
5090
|
replay: true
|
|
6662
5091
|
};
|
|
6663
|
-
|
|
6664
|
-
...body,
|
|
6665
|
-
seq: ++this.#seq,
|
|
6666
|
-
ts: Date.now()
|
|
6667
|
-
};
|
|
6668
|
-
this.#lastActivityAt = event.ts;
|
|
6669
|
-
this.#activityCount += transcriptActivity(body);
|
|
6670
|
-
this.#contextUsage = contextReading(body) ?? this.#contextUsage;
|
|
6671
|
-
if (body.type === "conversation_reset") {
|
|
6672
|
-
this.#resetSeq = event.seq;
|
|
6673
|
-
this.#contextUsage = void 0;
|
|
6674
|
-
}
|
|
6675
|
-
this.#events.push(event);
|
|
6676
|
-
this.#subscribers.emit(event);
|
|
5092
|
+
this.#subscribers.emit(this.#log.append(body));
|
|
6677
5093
|
}
|
|
6678
5094
|
};
|
|
6679
5095
|
//#endregion
|
|
6680
5096
|
//#region src/engines/codex/catalog.ts
|
|
6681
5097
|
/**
|
|
6682
|
-
* The Codex engine's model catalog, seeded from the binary's own embedded
|
|
6683
|
-
*
|
|
6684
|
-
*
|
|
6685
|
-
*
|
|
5098
|
+
* The Codex engine's model catalog, seeded from the binary's own embedded model
|
|
5099
|
+
* table (see `provenance` for the version) — that table, not the SDK's stale
|
|
5100
|
+
* `ModelReasoningEffort` union, is the truth about which reasoning efforts each
|
|
5101
|
+
* model takes. Mapping decisions: the internal `codex-auto-review` row is dropped
|
|
5102
|
+
* (the codex analogue of the CLI's `default` sentinel), `primary` mirrors the
|
|
5103
|
+
* binary's own `visibility` field so both UIs group the way codex's picker does,
|
|
5104
|
+
* and `reasoningEfforts` carries `supported_reasoning_levels` verbatim — `max`
|
|
5105
|
+
* and `ultra` go beyond the SDK union, so trust the binary and keep strings open.
|
|
6686
5106
|
*
|
|
6687
|
-
* **Refresh procedure** (release checklist): extract the embedded JSON from
|
|
6688
|
-
*
|
|
5107
|
+
* **Refresh procedure** (release checklist): extract the embedded JSON from the
|
|
5108
|
+
* platform binary and diff. The two-hop resolve is NOT optional — under pnpm's
|
|
5109
|
+
* strict layout the platform package is a dependency of `@openai/codex` and
|
|
5110
|
+
* resolves only from that wrapper's location (MODULE_NOT_FOUND otherwise), the
|
|
5111
|
+
* same two hops `resolveBundledCodexExecutable` makes.
|
|
6689
5112
|
*
|
|
6690
5113
|
* node -e 'const d=require("fs").readFileSync(process.argv[1]);
|
|
6691
5114
|
* const s=d.indexOf(`{\n "models": [`);
|
|
@@ -6697,23 +5120,9 @@ var CodexRunner = class {
|
|
|
6697
5120
|
* const w=require.resolve("@openai/codex/package.json");
|
|
6698
5121
|
* createRequire(w).resolve("@openai/codex-darwin-arm64/package.json")
|
|
6699
5122
|
* .replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
|
|
6700
|
-
*
|
|
6701
|
-
* The two-hop resolve is NOT optional: under pnpm's strict layout the platform
|
|
6702
|
-
* package is a dependency of `@openai/codex`, so it resolves only from that
|
|
6703
|
-
* wrapper's location, never from the repo root. Resolving it directly throws
|
|
6704
|
-
* MODULE_NOT_FOUND — the same two hops `resolveBundledCodexExecutable` makes.
|
|
6705
|
-
*
|
|
6706
|
-
* Mapping decisions:
|
|
6707
|
-
* - the internal `codex-auto-review` row is dropped (the codex analogue of
|
|
6708
|
-
* dropping the CLI's `default` sentinel);
|
|
6709
|
-
* - `primary` mirrors the binary's own `visibility` field ('list' = shown in
|
|
6710
|
-
* its picker, 'hide' = its "older models"), so both UIs group the way
|
|
6711
|
-
* codex's own picker does;
|
|
6712
|
-
* - `reasoningEfforts` carries `supported_reasoning_levels` verbatim — note
|
|
6713
|
-
* `max`/`ultra` beyond the SDK union; trust the binary, keep strings open.
|
|
6714
5123
|
*/
|
|
6715
5124
|
const CODEX_CATALOG = {
|
|
6716
|
-
provenance: "embedded model presets of @openai/codex@0.
|
|
5125
|
+
provenance: "embedded model presets of @openai/codex@0.151.0 (darwin-arm64 binary), re-extracted 2026-09-02 and unchanged since 0.149.0",
|
|
6717
5126
|
models: [
|
|
6718
5127
|
{
|
|
6719
5128
|
value: "gpt-5.6-sol",
|
|
@@ -6813,19 +5222,6 @@ const CODEX_CATALOG = {
|
|
|
6813
5222
|
};
|
|
6814
5223
|
//#endregion
|
|
6815
5224
|
//#region src/engines/codex/process.ts
|
|
6816
|
-
/** How much stderr to keep for the exit diagnostic. The binary logs startup
|
|
6817
|
-
* noise there; only the tail explains a death. */
|
|
6818
|
-
const STDERR_TAIL_BYTES = 4096;
|
|
6819
|
-
/**
|
|
6820
|
-
* Spawn one `codex app-server` child and frame JSON-RPC over its stdio — the
|
|
6821
|
-
* real {@link AppServerConnectFn}. The child's env is passed **complete**
|
|
6822
|
-
* (a provided spawn env replaces process.env, never merges with it), with the
|
|
6823
|
-
* profile's CODEX_HOME pin already applied by the runner.
|
|
6824
|
-
*
|
|
6825
|
-
* No spawn cwd: the working directory is a thread/turn parameter, and a cwd
|
|
6826
|
-
* that doesn't exist should fail the *turn* with codex's own error, not the
|
|
6827
|
-
* spawn.
|
|
6828
|
-
*/
|
|
6829
5225
|
function connectAppServer(options) {
|
|
6830
5226
|
const child = spawn(options.executable, ["app-server"], {
|
|
6831
5227
|
env: options.env,
|
|
@@ -6841,7 +5237,7 @@ function connectAppServer(options) {
|
|
|
6841
5237
|
});
|
|
6842
5238
|
let stderrTail = "";
|
|
6843
5239
|
child.stderr.on("data", (chunk) => {
|
|
6844
|
-
stderrTail = (stderrTail + String(chunk)).slice(-
|
|
5240
|
+
stderrTail = (stderrTail + String(chunk)).slice(-4096);
|
|
6845
5241
|
});
|
|
6846
5242
|
let closeHandler;
|
|
6847
5243
|
let done = false;
|
|
@@ -6874,19 +5270,12 @@ function connectAppServer(options) {
|
|
|
6874
5270
|
//#endregion
|
|
6875
5271
|
//#region src/engines/codex/adapter.ts
|
|
6876
5272
|
const NOT_INSTALLED = "@openai/codex is not installed — add it (an optional peer of @workerdeck/core) to run codex profiles";
|
|
6877
|
-
/**
|
|
6878
|
-
* The codex binary sessions will run: the per-platform package installed next
|
|
6879
|
-
* to `@openai/codex`, `vendor/<target-triple>/bin/codex` — the same file the
|
|
6880
|
-
* npm wrapper's own `bin/codex.js` launcher execs. Probing this binary rather
|
|
6881
|
-
* than whatever `codex` is on PATH means the availability answer is about the
|
|
6882
|
-
* executable sessions will actually run. Undefined when it can't be found;
|
|
6883
|
-
* callers degrade to 'unknown'.
|
|
6884
|
-
*/
|
|
6885
5273
|
function resolveBundledCodexExecutable() {
|
|
6886
5274
|
const triple = targetTriple();
|
|
6887
|
-
if (!triple) return
|
|
5275
|
+
if (!triple) return;
|
|
6888
5276
|
try {
|
|
6889
|
-
const
|
|
5277
|
+
const wrapper = createRequire(import.meta.url).resolve("@openai/codex/package.json");
|
|
5278
|
+
const path = createRequire(wrapper).resolve(`@openai/codex-${platformPackageSuffix()}/package.json`).replace(/package\.json$/, `vendor/${triple}/bin/codex`);
|
|
6890
5279
|
if (existsSync(path)) return path;
|
|
6891
5280
|
} catch {}
|
|
6892
5281
|
}
|
|
@@ -6899,34 +5288,13 @@ function targetTriple() {
|
|
|
6899
5288
|
function platformPackageSuffix() {
|
|
6900
5289
|
return `${process.platform}-${process.arch}`;
|
|
6901
5290
|
}
|
|
6902
|
-
/**
|
|
6903
|
-
* Availability, mirroring **the app-server surface's actual credential chain**
|
|
6904
|
-
* (verified 2026-08-05 against 0.146.0 by driving the raw binary): auth comes
|
|
6905
|
-
* solely from the CODEX_HOME auth store (`codex login`, file or keyring). The
|
|
6906
|
-
* env-key routes are dead ends here — `CODEX_API_KEY` is read only by
|
|
6907
|
-
* `codex exec` (a turn goes out with no credential at all: "Missing bearer"),
|
|
6908
|
-
* and `OPENAI_API_KEY` was never read by either surface. So, in order:
|
|
6909
|
-
*
|
|
6910
|
-
* 1. Binary resolvable, else unavailable with the install reason;
|
|
6911
|
-
* 2. `codex login status` under the profile's complete session env:
|
|
6912
|
-
* exit 0 → available; the "Not logged in" verdict → unavailable, with an
|
|
6913
|
-
* exact remedy when a stranded env key explains the misconfiguration;
|
|
6914
|
-
* anything else (a stray CODEX_ACCESS_TOKEN JWT error, a crashed spawn) →
|
|
6915
|
-
* 'unknown' — the checkClaudeAuth never-overclaim discipline.
|
|
6916
|
-
*
|
|
6917
|
-
* Only the exit code and the fixed verdict line are consulted — never
|
|
6918
|
-
* surfaced: `login status` output includes a masked key fragment. The
|
|
6919
|
-
* `smoke:codex --canary` run is the drift alarm for all of this.
|
|
6920
|
-
*/
|
|
6921
5291
|
async function checkCodexAvailability(profile, env, options = {}) {
|
|
6922
5292
|
const executable = resolveBundledCodexExecutable();
|
|
6923
5293
|
if (!executable) return {
|
|
6924
5294
|
available: false,
|
|
6925
5295
|
reason: NOT_INSTALLED
|
|
6926
5296
|
};
|
|
6927
|
-
const childEnv =
|
|
6928
|
-
for (const [key, value] of Object.entries(env)) if (value !== void 0) childEnv[key] = value;
|
|
6929
|
-
if (profile.codexHome) childEnv.CODEX_HOME = profile.codexHome;
|
|
5297
|
+
const childEnv = codexChildEnv(env, profile.codexHome);
|
|
6930
5298
|
return new Promise((resolve) => {
|
|
6931
5299
|
execFile(executable, ["login", "status"], {
|
|
6932
5300
|
env: childEnv,
|
|
@@ -6948,24 +5316,18 @@ async function checkCodexAvailability(profile, env, options = {}) {
|
|
|
6948
5316
|
});
|
|
6949
5317
|
});
|
|
6950
5318
|
}
|
|
6951
|
-
/** `thread/list` page size (its own default is 25) and a hard page bound so a
|
|
6952
|
-
* misbehaving cursor can never spin the listing forever. */
|
|
6953
5319
|
const LIST_PAGE_SIZE = 100;
|
|
6954
5320
|
const MAX_LIST_PAGES = 40;
|
|
6955
|
-
/** thread/list's `cwd` filter is an EXACT path match (measured, 0.146.0), so
|
|
6956
|
-
* offer both the spelled and canonical forms — macOS listings would otherwise
|
|
6957
|
-
* miss `/tmp/...` threads recorded under `/private/tmp/...`. */
|
|
6958
5321
|
function cwdFilter(dir) {
|
|
6959
|
-
const forms = new Set([dir]);
|
|
5322
|
+
const forms = /* @__PURE__ */ new Set([dir]);
|
|
6960
5323
|
try {
|
|
6961
5324
|
forms.add(realpathSync(dir));
|
|
6962
5325
|
} catch {}
|
|
6963
5326
|
return [...forms];
|
|
6964
5327
|
}
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
* `sessionId` field is not it. */
|
|
5328
|
+
function secondsToMs(value) {
|
|
5329
|
+
return typeof value === "number" && Number.isFinite(value) ? value * 1e3 : void 0;
|
|
5330
|
+
}
|
|
6969
5331
|
function summarizeThread(row) {
|
|
6970
5332
|
const name = typeof row.name === "string" && row.name.length > 0 ? row.name : void 0;
|
|
6971
5333
|
const preview = typeof row.preview === "string" && row.preview.length > 0 ? row.preview : void 0;
|
|
@@ -6980,30 +5342,12 @@ function summarizeThread(row) {
|
|
|
6980
5342
|
cwd: typeof row.cwd === "string" ? row.cwd : void 0
|
|
6981
5343
|
};
|
|
6982
5344
|
}
|
|
6983
|
-
/**
|
|
6984
|
-
* CODEX_HOME's threads over ONE short-lived `codex app-server` child: the
|
|
6985
|
-
* runner's own handshake (`experimentalApi` and all — one code path, no
|
|
6986
|
-
* second vocabulary to drift), `thread/list` pages walked by cursor, child
|
|
6987
|
-
* closed before returning. Requires no live session and costs no tokens —
|
|
6988
|
-
* it is how "resume" is offered before anything is running. The `connectFn`
|
|
6989
|
-
* seam exists for the scripted-peer tests; the adapter passes the real
|
|
6990
|
-
* spawn.
|
|
6991
|
-
*/
|
|
6992
5345
|
async function listCodexSessions(options) {
|
|
6993
|
-
const childEnv =
|
|
6994
|
-
for (const [key, value] of Object.entries(options.env)) if (value !== void 0) childEnv[key] = value;
|
|
6995
|
-
if (options.profile?.codexHome) childEnv.CODEX_HOME = options.profile.codexHome;
|
|
5346
|
+
const childEnv = codexChildEnv(options.env, options.profile?.codexHome);
|
|
6996
5347
|
const connection = options.connectFn({ env: childEnv });
|
|
6997
5348
|
const rows = [];
|
|
6998
5349
|
try {
|
|
6999
|
-
await connection.request("initialize",
|
|
7000
|
-
clientInfo: {
|
|
7001
|
-
name: "workerdeck",
|
|
7002
|
-
title: "WorkerDeck",
|
|
7003
|
-
version: `protocol-${PROTOCOL_VERSION}`
|
|
7004
|
-
},
|
|
7005
|
-
capabilities: { experimentalApi: true }
|
|
7006
|
-
});
|
|
5350
|
+
await connection.request("initialize", INITIALIZE_PARAMS);
|
|
7007
5351
|
connection.notify("initialized");
|
|
7008
5352
|
const base = {
|
|
7009
5353
|
limit: LIST_PAGE_SIZE,
|
|
@@ -7030,15 +5374,6 @@ async function listCodexSessions(options) {
|
|
|
7030
5374
|
const start = options.offset ?? 0;
|
|
7031
5375
|
return options.limit === void 0 ? summaries.slice(start) : summaries.slice(start, start + options.limit);
|
|
7032
5376
|
}
|
|
7033
|
-
/**
|
|
7034
|
-
* OpenAI Codex as an engine: the codex CLI binary driven over its `app-server`
|
|
7035
|
-
* JSON-RPC surface — structurally the Claude engine's sibling (a local agent
|
|
7036
|
-
* binary with sessions, sandboxing and resume, resolving its own credentials
|
|
7037
|
-
* from the operator's environment). `@openai/codex` — the npm package that
|
|
7038
|
-
* carries the binary — is an **optional peer**: absent, every codex profile
|
|
7039
|
-
* reports unavailable and createRunner throws the same message, and no
|
|
7040
|
-
* consumer downloads a ~40 MB per-platform binary it never uses.
|
|
7041
|
-
*/
|
|
7042
5377
|
const codexAdapter = {
|
|
7043
5378
|
engine: "codex",
|
|
7044
5379
|
capabilities: ENGINE_CAPABILITIES.codex,
|
|
@@ -7071,17 +5406,6 @@ const codexAdapter = {
|
|
|
7071
5406
|
};
|
|
7072
5407
|
//#endregion
|
|
7073
5408
|
//#region src/engines/provider/adapter.ts
|
|
7074
|
-
/**
|
|
7075
|
-
* The model-agnostic provider engine as a pseudo-adapter: capabilities and an
|
|
7076
|
-
* env-var probe live here, but its runners are assembled by the host's
|
|
7077
|
-
* `createEngineRunner` hook (which is where provider credentials are resolved
|
|
7078
|
-
* and model SDKs are imported — neither belongs in this repo's import graph).
|
|
7079
|
-
* The server routes provider creates to the hook; `createRunner` here throws
|
|
7080
|
-
* so a mis-routed call fails loudly instead of quietly building nothing.
|
|
7081
|
-
*
|
|
7082
|
-
* The catalog is empty by the same token: provider model ids are operator-
|
|
7083
|
-
* declared per profile (`provider.models`), not shipped with releases.
|
|
7084
|
-
*/
|
|
7085
5409
|
const providerAdapter = {
|
|
7086
5410
|
engine: "provider",
|
|
7087
5411
|
capabilities: ENGINE_CAPABILITIES.provider,
|
|
@@ -7110,7 +5434,6 @@ const ADAPTERS = {
|
|
|
7110
5434
|
codex: codexAdapter,
|
|
7111
5435
|
provider: providerAdapter
|
|
7112
5436
|
};
|
|
7113
|
-
/** The in-repo adapter for an engine. An absent `engine` means 'claude'. */
|
|
7114
5437
|
function getEngineAdapter(engine) {
|
|
7115
5438
|
return ADAPTERS[engine ?? "claude"];
|
|
7116
5439
|
}
|