@workerdeck/core 0.22.0 → 1.0.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 +92 -1181
- package/build/index.mjs +406 -1976
- package/build/index.mjs.map +1 -1
- package/package.json +3 -3
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,15 +11,12 @@ 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
|
-
/** The four the Anthropic API accepts. Notably absent: image/heic — an iPhone's
|
|
15
|
-
* native photo format, which clients must transcode before upload. */
|
|
16
14
|
const IMAGE_TYPES = new Set([
|
|
17
15
|
"image/jpeg",
|
|
18
16
|
"image/png",
|
|
19
17
|
"image/gif",
|
|
20
18
|
"image/webp"
|
|
21
19
|
]);
|
|
22
|
-
/** Textual types whose media type doesn't start with `text/`. */
|
|
23
20
|
const TEXT_TYPES = new Set([
|
|
24
21
|
"application/json",
|
|
25
22
|
"application/xml",
|
|
@@ -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 = 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,15 +1395,11 @@ 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;
|
|
1402
|
+
#pendingApprovals = /* @__PURE__ */ new Map();
|
|
1902
1403
|
constructor(config, id = randomUUID()) {
|
|
1903
1404
|
const mode = config.permissionMode ?? "default";
|
|
1904
1405
|
if (!SUPPORTED_PERMISSION_MODES.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the AI SDK engine`);
|
|
@@ -1910,23 +1411,11 @@ var AiSdkRunner = class {
|
|
|
1910
1411
|
this.createdAt = config.restore?.createdAt ?? Date.now();
|
|
1911
1412
|
if (config.restore) this.#restore(config.restore);
|
|
1912
1413
|
}
|
|
1913
|
-
/** Adopt a parked session's state. The event log and seq counter come back
|
|
1914
|
-
* verbatim: a client reattaching with `afterSeq` must see one unbroken stream
|
|
1915
|
-
* across the teardown, not a second session that restarts at 1. */
|
|
1916
1414
|
#restore(snapshot) {
|
|
1917
1415
|
if (snapshot.engine !== "provider") throw new Error(`cannot restore a '${snapshot.engine}' snapshot into the AI SDK engine`);
|
|
1918
1416
|
const state = snapshot.state;
|
|
1919
1417
|
if (!state || !Array.isArray(state.messages)) throw new Error("session snapshot is missing its provider-engine state");
|
|
1920
|
-
this.#
|
|
1921
|
-
this.#events = [...snapshot.events];
|
|
1922
|
-
this.#activityCount = 0;
|
|
1923
|
-
for (const event of this.#events) {
|
|
1924
|
-
this.#activityCount += transcriptActivity(event);
|
|
1925
|
-
if (event.type === "conversation_reset") {
|
|
1926
|
-
this.#resetSeq = event.seq;
|
|
1927
|
-
this.#contextUsage = void 0;
|
|
1928
|
-
} else this.#contextUsage = contextReading(event) ?? this.#contextUsage;
|
|
1929
|
-
}
|
|
1418
|
+
this.#log.restore(snapshot.events, snapshot.seq, state.lastActivityAt);
|
|
1930
1419
|
this.#messages = [...state.messages];
|
|
1931
1420
|
for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
|
|
1932
1421
|
this.#dispatched = new Set(state.dispatched);
|
|
@@ -1935,7 +1424,6 @@ var AiSdkRunner = class {
|
|
|
1935
1424
|
this.#turnAccum = state.turnAccum ? { ...state.turnAccum } : void 0;
|
|
1936
1425
|
if (this.#turnAccum && state.parkedAt !== void 0) this.#turnAccum.startedAt += Date.now() - state.parkedAt;
|
|
1937
1426
|
this.#permissionMode = state.permissionMode;
|
|
1938
|
-
this.#lastActivityAt = state.lastActivityAt;
|
|
1939
1427
|
this.#status = this.#pendingToolCalls.size > 0 ? "parked" : "idle";
|
|
1940
1428
|
if (state.model !== void 0 && state.model !== this.#modelAlias && this.#config.resolveModel) {
|
|
1941
1429
|
this.#modelAlias = state.model;
|
|
@@ -1946,21 +1434,17 @@ var AiSdkRunner = class {
|
|
|
1946
1434
|
return this.#status;
|
|
1947
1435
|
}
|
|
1948
1436
|
get lastSeq() {
|
|
1949
|
-
return this.#seq;
|
|
1437
|
+
return this.#log.seq;
|
|
1950
1438
|
}
|
|
1951
|
-
/** The session's durable state — persist to park, replay to rehydrate. */
|
|
1952
1439
|
get messages() {
|
|
1953
1440
|
return [...this.#messages];
|
|
1954
1441
|
}
|
|
1955
|
-
/** External tool calls the loop is currently parked on. */
|
|
1956
1442
|
get pendingToolCalls() {
|
|
1957
1443
|
return [...this.#pendingToolCalls.values()];
|
|
1958
1444
|
}
|
|
1959
1445
|
get pendingApprovals() {
|
|
1960
|
-
return [];
|
|
1446
|
+
return [...this.#pendingApprovals.values()].map((a) => a.request);
|
|
1961
1447
|
}
|
|
1962
|
-
/** The session's scratch filesystem (see Runner.vfs) — the server's file
|
|
1963
|
-
* routes serve deliverables straight from it. */
|
|
1964
1448
|
get vfs() {
|
|
1965
1449
|
return this.#config.vfs;
|
|
1966
1450
|
}
|
|
@@ -1971,19 +1455,23 @@ var AiSdkRunner = class {
|
|
|
1971
1455
|
cwd: this.#config.cwd ?? "",
|
|
1972
1456
|
profile: this.#config.profile,
|
|
1973
1457
|
engine: "provider",
|
|
1974
|
-
capabilities:
|
|
1458
|
+
capabilities: this.#config.shouldApprove ? {
|
|
1459
|
+
...ENGINE_CAPABILITIES.provider,
|
|
1460
|
+
interactiveApprovals: true
|
|
1461
|
+
} : ENGINE_CAPABILITIES.provider,
|
|
1975
1462
|
model: this.#modelId(),
|
|
1976
1463
|
permissionMode: this.#permissionMode,
|
|
1977
1464
|
createdAt: this.createdAt,
|
|
1978
|
-
lastSeq: this.#seq,
|
|
1979
|
-
activityCount: this.#activityCount,
|
|
1980
|
-
|
|
1981
|
-
|
|
1465
|
+
lastSeq: this.#log.seq,
|
|
1466
|
+
activityCount: this.#log.activityCount,
|
|
1467
|
+
proseCount: this.#log.proseCount,
|
|
1468
|
+
contextUsage: this.#log.contextUsage,
|
|
1469
|
+
pendingPermissionCount: this.#pendingApprovals.size,
|
|
1982
1470
|
meta: this.#config.meta,
|
|
1983
1471
|
scope: this.#config.scope,
|
|
1984
|
-
title: this.#
|
|
1472
|
+
title: sessionTitle(this.#config),
|
|
1985
1473
|
numTurns: this.#numTurns || void 0,
|
|
1986
|
-
lastActivityAt: this.#lastActivityAt
|
|
1474
|
+
lastActivityAt: this.#log.lastActivityAt
|
|
1987
1475
|
};
|
|
1988
1476
|
}
|
|
1989
1477
|
start() {
|
|
@@ -1994,15 +1482,9 @@ var AiSdkRunner = class {
|
|
|
1994
1482
|
if (this.#config.prompt) this.sendMessage(this.#config.prompt);
|
|
1995
1483
|
return this.#turnChain;
|
|
1996
1484
|
}
|
|
1997
|
-
/**
|
|
1998
|
-
* Snapshot durable state, release engine resources, and go inert — the session
|
|
1999
|
-
* continues in the snapshot, not in this object. Returns undefined when parking
|
|
2000
|
-
* would lose work or has nothing to wait for: a turn in flight, no parked call,
|
|
2001
|
-
* or an already-closed/parked runner.
|
|
2002
|
-
*/
|
|
2003
1485
|
park() {
|
|
2004
|
-
if (this.#closed || this.#parked) return
|
|
2005
|
-
if (this.#abort || !this.#restingOnDeferred()) return
|
|
1486
|
+
if (this.#closed || this.#parked) return;
|
|
1487
|
+
if (this.#abort || !this.#restingOnDeferred()) return;
|
|
2006
1488
|
this.#setStatus("parked");
|
|
2007
1489
|
const snapshot = this.#buildSnapshot();
|
|
2008
1490
|
this.#parked = true;
|
|
@@ -2012,52 +1494,11 @@ var AiSdkRunner = class {
|
|
|
2012
1494
|
} catch {}
|
|
2013
1495
|
return snapshot;
|
|
2014
1496
|
}
|
|
2015
|
-
/**
|
|
2016
|
-
* The same snapshot, taken without ending anything.
|
|
2017
|
-
*
|
|
2018
|
-
* `park()` and this are two operations that happen to produce the same value,
|
|
2019
|
-
* and the difference is the whole point: `park()` *ends* the live runner
|
|
2020
|
-
* (inert, listeners dropped, `onClose` called), which is right for deferred
|
|
2021
|
-
* execution — the session has nothing to do for possibly days — and wrong for
|
|
2022
|
-
* restart-survival, where the session is active and someone is mid-
|
|
2023
|
-
* conversation. This one changes nothing at all: no status emit, no listener
|
|
2024
|
-
* clear, no disposer. The host writes the value through to durable storage
|
|
2025
|
-
* after each turn and keeps the runner live and warm, so a restart rebuilds
|
|
2026
|
-
* from the last write through the existing `restore` path and the next message
|
|
2027
|
-
* costs no wake.
|
|
2028
|
-
*
|
|
2029
|
-
* The gate is `park()`'s minus the requirement that there be something parked:
|
|
2030
|
-
*
|
|
2031
|
-
* - `#abort` set is refused for the reason it always was — a `generate()` in
|
|
2032
|
-
* flight has produced messages that are not in the history yet, so the
|
|
2033
|
-
* snapshot would be of a turn that half-happened.
|
|
2034
|
-
* - Pending calls that are **not** all deferred are refused, which is
|
|
2035
|
-
* `park()`'s rule wearing a different hat. An in-process execution's result
|
|
2036
|
-
* is coming back to *this* runner and dies with the process; a restore would
|
|
2037
|
-
* wait on it forever, and `state.dispatched` is what would stop the rebuilt
|
|
2038
|
-
* runner from simply calling it again.
|
|
2039
|
-
* - Idle with nothing pending — the case `park()` exists to refuse — is
|
|
2040
|
-
* exactly the case this exists to allow.
|
|
2041
|
-
*/
|
|
2042
1497
|
snapshot() {
|
|
2043
|
-
if (this.#closed || this.#parked || this.#abort) return
|
|
2044
|
-
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;
|
|
2045
1500
|
return this.#buildSnapshot();
|
|
2046
1501
|
}
|
|
2047
|
-
/**
|
|
2048
|
-
* The snapshot value itself, shared so a park and a write-through cannot
|
|
2049
|
-
* disagree about what a session *is*.
|
|
2050
|
-
*
|
|
2051
|
-
* The event log is filtered through {@link snapshotRetains} — the persisted
|
|
2052
|
-
* log drops stream deltas, which are superseded by the `assistant_message`
|
|
2053
|
-
* that flushes them and would otherwise be tens of times the size of the text
|
|
2054
|
-
* they spell. Parks get it too, and should: a park sits on disk for days.
|
|
2055
|
-
*
|
|
2056
|
-
* The `parked` list and `state.parkedAt` are honest under both callers. An
|
|
2057
|
-
* idle write-through has no pending calls, so `parked` is empty and the host
|
|
2058
|
-
* arms no watchdogs; `parkedAt` is "when this was taken", which is what
|
|
2059
|
-
* `#restore` needs to discount a turn's clock either way.
|
|
2060
|
-
*/
|
|
2061
1502
|
#buildSnapshot() {
|
|
2062
1503
|
const parked = [...this.#pendingToolCalls.values()].map((call) => ({
|
|
2063
1504
|
executionId: call.toolCallId,
|
|
@@ -2073,15 +1514,15 @@ var AiSdkRunner = class {
|
|
|
2073
1514
|
turnAccum: this.#turnAccum ? { ...this.#turnAccum } : void 0,
|
|
2074
1515
|
permissionMode: this.#permissionMode,
|
|
2075
1516
|
model: this.#modelAlias,
|
|
2076
|
-
lastActivityAt: this.#lastActivityAt,
|
|
1517
|
+
lastActivityAt: this.#log.lastActivityAt,
|
|
2077
1518
|
parkedAt: Date.now()
|
|
2078
1519
|
};
|
|
2079
1520
|
return {
|
|
2080
1521
|
engine: "provider",
|
|
2081
1522
|
id: this.id,
|
|
2082
1523
|
createdAt: this.createdAt,
|
|
2083
|
-
seq: this.#seq,
|
|
2084
|
-
events: this.#events.filter((event) => snapshotRetains(event)),
|
|
1524
|
+
seq: this.#log.seq,
|
|
1525
|
+
events: this.#log.events.filter((event) => snapshotRetains(event)),
|
|
2085
1526
|
vfs: this.#config.vfs?.snapshot(),
|
|
2086
1527
|
parked,
|
|
2087
1528
|
state
|
|
@@ -2115,19 +1556,11 @@ var AiSdkRunner = class {
|
|
|
2115
1556
|
});
|
|
2116
1557
|
this.#scheduleTurn();
|
|
2117
1558
|
}
|
|
2118
|
-
/**
|
|
2119
|
-
* Deliver the result of an external (execute-less) tool call. Appends the
|
|
2120
|
-
* tool-result message and, once no calls remain pending, re-enters the loop.
|
|
2121
|
-
* Idempotent per toolCallId: unknown/already-settled ids return false.
|
|
2122
|
-
*/
|
|
2123
1559
|
resolveToolCall(toolCallId, output, options) {
|
|
2124
1560
|
if (!this.#settlePendingCall(toolCallId, output, options?.isError === true)) return false;
|
|
2125
1561
|
if (this.#pendingToolCalls.size === 0) this.#scheduleTurn();
|
|
2126
1562
|
return true;
|
|
2127
1563
|
}
|
|
2128
|
-
/** Record a parked call's outcome into the message history (so it stays
|
|
2129
|
-
* replayable — a dangling tool call without a result is invalid input for
|
|
2130
|
-
* providers) and the event log. Does NOT re-enter the loop. */
|
|
2131
1564
|
#settlePendingCall(toolCallId, output, isError) {
|
|
2132
1565
|
const pending = this.#pendingToolCalls.get(toolCallId);
|
|
2133
1566
|
if (!pending || this.#closed || this.#parked) return false;
|
|
@@ -2163,11 +1596,38 @@ var AiSdkRunner = class {
|
|
|
2163
1596
|
});
|
|
2164
1597
|
return true;
|
|
2165
1598
|
}
|
|
2166
|
-
resolvePermission(
|
|
2167
|
-
|
|
1599
|
+
resolvePermission(requestId, decision) {
|
|
1600
|
+
const approval = this.#pendingApprovals.get(requestId);
|
|
1601
|
+
if (!approval) return false;
|
|
1602
|
+
clearTimeout(approval.timer);
|
|
1603
|
+
this.#pendingApprovals.delete(requestId);
|
|
1604
|
+
const source = "client";
|
|
1605
|
+
if (decision.behavior === "allow") {
|
|
1606
|
+
this.#emit({
|
|
1607
|
+
type: "permission_resolved",
|
|
1608
|
+
requestId,
|
|
1609
|
+
behavior: "allow",
|
|
1610
|
+
resolvedBy: source
|
|
1611
|
+
});
|
|
1612
|
+
this.#dispatchSingle(approval.toolCallId);
|
|
1613
|
+
} else {
|
|
1614
|
+
const message = decision.message ?? "Permission denied by user";
|
|
1615
|
+
this.#emit({
|
|
1616
|
+
type: "permission_resolved",
|
|
1617
|
+
requestId,
|
|
1618
|
+
behavior: "deny",
|
|
1619
|
+
resolvedBy: source,
|
|
1620
|
+
message
|
|
1621
|
+
});
|
|
1622
|
+
this.#applyExecutionResult(approval.toolCallId, {
|
|
1623
|
+
status: "failed",
|
|
1624
|
+
reason: "permission_denied",
|
|
1625
|
+
error: message
|
|
1626
|
+
});
|
|
1627
|
+
if (decision.interrupt) this.interrupt();
|
|
1628
|
+
}
|
|
1629
|
+
return true;
|
|
2168
1630
|
}
|
|
2169
|
-
/** Emit `file_delivered` — the deliver_file tool's hand-over event (wired by
|
|
2170
|
-
* createEngineSession via ToolContextOptions.onFileDelivered). */
|
|
2171
1631
|
emitFileDelivered(file) {
|
|
2172
1632
|
if (this.#closed || this.#parked) return;
|
|
2173
1633
|
this.#emit({
|
|
@@ -2175,11 +1635,6 @@ var AiSdkRunner = class {
|
|
|
2175
1635
|
...file
|
|
2176
1636
|
});
|
|
2177
1637
|
}
|
|
2178
|
-
/**
|
|
2179
|
-
* One plain generateText over the session's current model, billed into the
|
|
2180
|
-
* running turn's usage accumulator — the web_fetch digest pass uses this so
|
|
2181
|
-
* its tokens are never lost from the turn's accounting.
|
|
2182
|
-
*/
|
|
2183
1638
|
async generateDigest(prompt) {
|
|
2184
1639
|
const result = await generateText({
|
|
2185
1640
|
model: this.#model,
|
|
@@ -2195,19 +1650,6 @@ var AiSdkRunner = class {
|
|
|
2195
1650
|
}
|
|
2196
1651
|
return result.text;
|
|
2197
1652
|
}
|
|
2198
|
-
/**
|
|
2199
|
-
* Reset the conversation: drop the message array the next turn would have
|
|
2200
|
-
* been built from. There is no engine round trip — this runner *is* where the
|
|
2201
|
-
* transcript lives, so clearing it is the whole operation.
|
|
2202
|
-
*
|
|
2203
|
-
* Two things ride along, both already written elsewhere and both load-bearing
|
|
2204
|
-
* here. `#emit`'s `conversation_reset` arm retires `#contextUsage` (the
|
|
2205
|
-
* reading described a conversation that no longer exists), and the same arm
|
|
2206
|
-
* in `restore` keeps a parked session that comes back after a clear from
|
|
2207
|
-
* resurrecting it. Pending tool calls are NOT swept: a parked call is work a
|
|
2208
|
-
* backend still owes an answer for, and a clear is not an interrupt — the
|
|
2209
|
-
* refusal below is what keeps the two apart.
|
|
2210
|
-
*/
|
|
2211
1653
|
async clearContext() {
|
|
2212
1654
|
if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
|
|
2213
1655
|
const run = this.#turnChain.then(() => {
|
|
@@ -2283,6 +1725,8 @@ var AiSdkRunner = class {
|
|
|
2283
1725
|
this.#abort?.abort();
|
|
2284
1726
|
this.#pendingToolCalls.clear();
|
|
2285
1727
|
this.#dispatched.clear();
|
|
1728
|
+
for (const { timer } of this.#pendingApprovals.values()) clearTimeout(timer);
|
|
1729
|
+
this.#pendingApprovals.clear();
|
|
2286
1730
|
this.#emit({
|
|
2287
1731
|
type: "session_closed",
|
|
2288
1732
|
reason
|
|
@@ -2292,92 +1736,131 @@ var AiSdkRunner = class {
|
|
|
2292
1736
|
Promise.resolve(this.#config.onClose?.()).catch(() => {});
|
|
2293
1737
|
} catch {}
|
|
2294
1738
|
}
|
|
2295
|
-
/** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
|
|
2296
|
-
* "show everything" on one row, so a per-runner seq index would be a map
|
|
2297
|
-
* maintained on every emit to save a walk nobody makes twice a minute. */
|
|
2298
1739
|
eventAt(seq) {
|
|
2299
|
-
return this.#
|
|
1740
|
+
return this.#log.at(seq);
|
|
2300
1741
|
}
|
|
2301
1742
|
subscribe(listener, afterSeq = 0, options) {
|
|
2302
|
-
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);
|
|
2303
1744
|
}
|
|
2304
1745
|
#scheduleTurn() {
|
|
2305
1746
|
this.#turnChain = this.#turnChain.then(() => this.#runTurn());
|
|
2306
1747
|
}
|
|
2307
|
-
/**
|
|
2308
|
-
* Deliver the result of an execution this runner dispatched. Used by the host
|
|
2309
|
-
* when a backend settled out-of-band (a browser bridge answering later, a
|
|
2310
|
-
* deferred executor). Idempotent by executionId.
|
|
2311
|
-
*/
|
|
2312
1748
|
settleExecution(executionId, result) {
|
|
2313
1749
|
if (this.#closed || this.#parked) return false;
|
|
2314
1750
|
if (!this.#pendingToolCalls.has(executionId)) return false;
|
|
2315
1751
|
this.#applyExecutionResult(executionId, result);
|
|
2316
1752
|
return true;
|
|
2317
1753
|
}
|
|
2318
|
-
/** Hand every parked call the executor owns to it. */
|
|
2319
1754
|
#dispatchPending() {
|
|
2320
1755
|
const executor = this.#config.executor;
|
|
2321
1756
|
if (!executor) return;
|
|
2322
1757
|
const executable = this.#config.executableTools;
|
|
1758
|
+
const needsApproval = this.#permissionMode === "default" && this.#config.shouldApprove;
|
|
2323
1759
|
const inFlight = [];
|
|
2324
1760
|
let anyDeferred = false;
|
|
1761
|
+
let anyAwaiting = false;
|
|
2325
1762
|
for (const call of Array.from(this.#pendingToolCalls.values())) {
|
|
2326
1763
|
if (executable && !executable.includes(call.toolName)) continue;
|
|
2327
1764
|
if (this.#dispatched.has(call.toolCallId)) continue;
|
|
2328
|
-
|
|
2329
|
-
const toolCall = {
|
|
2330
|
-
executionId: call.toolCallId,
|
|
2331
|
-
sessionId: this.id,
|
|
2332
|
-
tool: call.toolName,
|
|
2333
|
-
input: call.input,
|
|
2334
|
-
vfs: this.#config.vfs,
|
|
2335
|
-
limits: this.#config.executionLimits,
|
|
2336
|
-
signal: this.#abort?.signal
|
|
2337
|
-
};
|
|
2338
|
-
const profile = executor.describe?.(toolCall) ?? {};
|
|
2339
|
-
call.deferred = profile.deferred === true ? true : void 0;
|
|
2340
|
-
call.expiresAt = profile.timeoutMs === void 0 ? void 0 : Date.now() + profile.timeoutMs;
|
|
2341
|
-
anyDeferred ||= call.deferred === true;
|
|
2342
|
-
this.#emit({
|
|
2343
|
-
type: "execution_dispatched",
|
|
2344
|
-
executionId: call.toolCallId,
|
|
1765
|
+
if (needsApproval && needsApproval({
|
|
2345
1766
|
toolName: call.toolName,
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
this.#
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
1767
|
+
input: call.input
|
|
1768
|
+
})) {
|
|
1769
|
+
if ([...this.#pendingApprovals.values()].some((a) => a.toolCallId === call.toolCallId)) {
|
|
1770
|
+
anyAwaiting = true;
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
const requestId = randomUUID();
|
|
1774
|
+
const timeoutMs = this.#config.approvalTimeoutMs ?? 12e4;
|
|
1775
|
+
const request = {
|
|
1776
|
+
id: requestId,
|
|
1777
|
+
toolName: call.toolName,
|
|
1778
|
+
input: call.input,
|
|
1779
|
+
toolUseId: call.toolCallId,
|
|
1780
|
+
title: `Agent wants to run ${call.toolName}`,
|
|
1781
|
+
displayName: call.toolName,
|
|
1782
|
+
expiresAt: Date.now() + timeoutMs
|
|
1783
|
+
};
|
|
1784
|
+
const timer = setTimeout(() => {
|
|
1785
|
+
if (!this.#pendingApprovals.has(requestId)) return;
|
|
1786
|
+
this.resolvePermission(requestId, {
|
|
1787
|
+
behavior: "deny",
|
|
1788
|
+
message: "Approval timed out"
|
|
1789
|
+
});
|
|
1790
|
+
}, timeoutMs);
|
|
1791
|
+
this.#pendingApprovals.set(requestId, {
|
|
1792
|
+
request,
|
|
1793
|
+
toolCallId: call.toolCallId,
|
|
1794
|
+
timer
|
|
1795
|
+
});
|
|
1796
|
+
this.#emit({
|
|
1797
|
+
type: "permission_requested",
|
|
1798
|
+
request
|
|
2357
1799
|
});
|
|
2358
|
-
|
|
1800
|
+
anyAwaiting = true;
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
this.#dispatched.add(call.toolCallId);
|
|
1804
|
+
const dispatched = this.#dispatchCall(executor, call);
|
|
1805
|
+
anyDeferred ||= dispatched.deferred;
|
|
1806
|
+
inFlight.push(dispatched.promise);
|
|
2359
1807
|
}
|
|
1808
|
+
if (anyAwaiting) this.#setStatus("awaiting_approval");
|
|
2360
1809
|
if (anyDeferred) Promise.allSettled(inFlight).then(() => this.#announceParked());
|
|
2361
1810
|
}
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
1811
|
+
#dispatchSingle(toolCallId) {
|
|
1812
|
+
const executor = this.#config.executor;
|
|
1813
|
+
if (!executor) return;
|
|
1814
|
+
const call = this.#pendingToolCalls.get(toolCallId);
|
|
1815
|
+
if (!call || this.#dispatched.has(toolCallId)) return;
|
|
1816
|
+
this.#dispatched.add(toolCallId);
|
|
1817
|
+
const dispatched = this.#dispatchCall(executor, call);
|
|
1818
|
+
if (dispatched.deferred) dispatched.promise.then(() => this.#announceParked());
|
|
1819
|
+
}
|
|
1820
|
+
#dispatchCall(executor, call) {
|
|
1821
|
+
const toolCall = {
|
|
1822
|
+
executionId: call.toolCallId,
|
|
1823
|
+
sessionId: this.id,
|
|
1824
|
+
tool: call.toolName,
|
|
1825
|
+
input: call.input,
|
|
1826
|
+
vfs: this.#config.vfs,
|
|
1827
|
+
limits: this.#config.executionLimits,
|
|
1828
|
+
signal: this.#abort?.signal
|
|
1829
|
+
};
|
|
1830
|
+
const profile = executor.describe?.(toolCall) ?? {};
|
|
1831
|
+
call.deferred = profile.deferred === true ? true : void 0;
|
|
1832
|
+
call.expiresAt = profile.timeoutMs === void 0 ? void 0 : Date.now() + profile.timeoutMs;
|
|
1833
|
+
this.#emit({
|
|
1834
|
+
type: "execution_dispatched",
|
|
1835
|
+
executionId: call.toolCallId,
|
|
1836
|
+
toolName: call.toolName,
|
|
1837
|
+
backend: profile.backend ?? this.#config.executionBackend ?? "server",
|
|
1838
|
+
deferred: call.deferred,
|
|
1839
|
+
expiresAt: call.expiresAt
|
|
1840
|
+
});
|
|
1841
|
+
const promise = executor.dispatch(toolCall).then((dispatch) => {
|
|
1842
|
+
if (dispatch.status === "settled") this.#applyExecutionResult(call.toolCallId, dispatch.result);
|
|
1843
|
+
}).catch((error) => {
|
|
1844
|
+
this.#applyExecutionResult(call.toolCallId, {
|
|
1845
|
+
status: "failed",
|
|
1846
|
+
reason: "dispatch_error",
|
|
1847
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1848
|
+
});
|
|
1849
|
+
});
|
|
1850
|
+
return {
|
|
1851
|
+
deferred: call.deferred === true,
|
|
1852
|
+
promise
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
2368
1855
|
#announceParked() {
|
|
2369
1856
|
if (this.#closed || this.#parked || this.#abort) return;
|
|
2370
1857
|
if (this.#restingOnDeferred()) this.#setStatus("parked");
|
|
2371
1858
|
}
|
|
2372
|
-
/** The loop is waiting, and everything it waits on can only be answered from
|
|
2373
|
-
* outside this process. One still-live in-process execution means a result is
|
|
2374
|
-
* coming back to THIS runner, and tearing it down would strand it. */
|
|
2375
1859
|
#restingOnDeferred() {
|
|
2376
1860
|
if (this.#pendingToolCalls.size === 0) return false;
|
|
2377
1861
|
for (const call of this.#pendingToolCalls.values()) if (call.deferred !== true) return false;
|
|
2378
1862
|
return true;
|
|
2379
1863
|
}
|
|
2380
|
-
/** Fold an execution's outcome back into the loop, whichever way it went. */
|
|
2381
1864
|
#applyExecutionResult(executionId, result) {
|
|
2382
1865
|
if (this.#closed || this.#parked) return;
|
|
2383
1866
|
this.#dispatched.delete(executionId);
|
|
@@ -2607,9 +2090,6 @@ var AiSdkRunner = class {
|
|
|
2607
2090
|
if (this.#abort === abort) this.#abort = void 0;
|
|
2608
2091
|
}
|
|
2609
2092
|
}
|
|
2610
|
-
/** Emit the turn's result from the whole-turn accumulator, so a turn that
|
|
2611
|
-
* parked on external tool calls reports every leg's tokens and the full
|
|
2612
|
-
* elapsed time (including the time spent executing those tools). */
|
|
2613
2093
|
#finishTurn(text) {
|
|
2614
2094
|
const accum = this.#turnAccum ?? {
|
|
2615
2095
|
startedAt: Date.now(),
|
|
@@ -2641,39 +2121,17 @@ var AiSdkRunner = class {
|
|
|
2641
2121
|
if (typeof model === "string") return model;
|
|
2642
2122
|
return model.modelId;
|
|
2643
2123
|
}
|
|
2644
|
-
#title() {
|
|
2645
|
-
const metaTitle = this.#config.meta?.title;
|
|
2646
|
-
if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
|
|
2647
|
-
const prompt = this.#config.prompt;
|
|
2648
|
-
if (!prompt) return void 0;
|
|
2649
|
-
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
2650
|
-
}
|
|
2651
|
-
/**
|
|
2652
|
-
* This session's MCP servers, as the host assembled them.
|
|
2653
|
-
*
|
|
2654
|
-
* Always answers — an empty list when no MCP was wired — because the
|
|
2655
|
-
* alternative (undefined, which the server turns into a 501) says "this
|
|
2656
|
-
* engine cannot tell you", and this engine can: the host that built the
|
|
2657
|
-
* session is the only party who knows, and it has been asked.
|
|
2658
|
-
*/
|
|
2659
2124
|
async mcpServers() {
|
|
2660
2125
|
return await this.#config.reportMcpServers?.() ?? [];
|
|
2661
2126
|
}
|
|
2662
|
-
/** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
|
|
2663
|
-
* it (undefined) restores the derived title. The engine is never told. */
|
|
2664
2127
|
setTitle(title) {
|
|
2665
|
-
|
|
2666
|
-
if (title) meta.title = title;
|
|
2667
|
-
else delete meta.title;
|
|
2668
|
-
this.#config = {
|
|
2669
|
-
...this.#config,
|
|
2670
|
-
meta
|
|
2671
|
-
};
|
|
2128
|
+
this.#config = withTitle(this.#config, title);
|
|
2672
2129
|
}
|
|
2673
2130
|
#setStatus(status, detail) {
|
|
2674
|
-
if (this.#status === status) return;
|
|
2131
|
+
if (this.#status === status && this.#statusDetail === detail) return;
|
|
2675
2132
|
if (this.#status === "closed" || this.#status === "failed") return;
|
|
2676
2133
|
this.#status = status;
|
|
2134
|
+
this.#statusDetail = detail;
|
|
2677
2135
|
this.#emit({
|
|
2678
2136
|
type: "status_changed",
|
|
2679
2137
|
status,
|
|
@@ -2681,20 +2139,7 @@ var AiSdkRunner = class {
|
|
|
2681
2139
|
});
|
|
2682
2140
|
}
|
|
2683
2141
|
#emit(body) {
|
|
2684
|
-
|
|
2685
|
-
...body,
|
|
2686
|
-
seq: ++this.#seq,
|
|
2687
|
-
ts: Date.now()
|
|
2688
|
-
};
|
|
2689
|
-
this.#lastActivityAt = event.ts;
|
|
2690
|
-
this.#activityCount += transcriptActivity(body);
|
|
2691
|
-
this.#contextUsage = contextReading(body) ?? this.#contextUsage;
|
|
2692
|
-
if (body.type === "conversation_reset") {
|
|
2693
|
-
this.#resetSeq = event.seq;
|
|
2694
|
-
this.#contextUsage = void 0;
|
|
2695
|
-
}
|
|
2696
|
-
this.#events.push(event);
|
|
2697
|
-
this.#subscribers.emit(event);
|
|
2142
|
+
this.#subscribers.emit(this.#log.append(body));
|
|
2698
2143
|
}
|
|
2699
2144
|
};
|
|
2700
2145
|
function turnUsage(accum) {
|
|
@@ -2713,16 +2158,6 @@ function errorText(error) {
|
|
|
2713
2158
|
}
|
|
2714
2159
|
//#endregion
|
|
2715
2160
|
//#region src/engines/claude/auth.ts
|
|
2716
|
-
/**
|
|
2717
|
-
* The native Claude Code binary the Agent SDK itself spawns, resolved the way
|
|
2718
|
-
* the SDK resolves it: the platform-specific optional dependency installed next
|
|
2719
|
-
* to the SDK package (`@anthropic-ai/claude-agent-sdk-<platform>-<arch>/claude`).
|
|
2720
|
-
* Probing this binary rather than whatever `claude` is on PATH means an auth
|
|
2721
|
-
* check answers for the executable sessions will actually run — the two can be
|
|
2722
|
-
* different versions logged into different places. Returns undefined when it
|
|
2723
|
-
* can't be found (optional dep skipped, unsupported platform); callers degrade
|
|
2724
|
-
* to 'unknown', and the SDK surfaces its own error if a session is created.
|
|
2725
|
-
*/
|
|
2726
2161
|
function resolveBundledClaudeExecutable() {
|
|
2727
2162
|
try {
|
|
2728
2163
|
const fromSdk = createRequire(createRequire(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk"));
|
|
@@ -2734,18 +2169,6 @@ function resolveBundledClaudeExecutable() {
|
|
|
2734
2169
|
} catch {}
|
|
2735
2170
|
} catch {}
|
|
2736
2171
|
}
|
|
2737
|
-
/**
|
|
2738
|
-
* Ask the CLI whether `env` holds usable credentials: `claude auth status`
|
|
2739
|
-
* prints a JSON verdict covering every source the CLI itself consults for that
|
|
2740
|
-
* environment — `<CLAUDE_CONFIG_DIR>/.credentials.json`, the macOS login
|
|
2741
|
-
* Keychain, ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, Bedrock/Vertex
|
|
2742
|
-
* (verified against 2.1.217). Only the `loggedIn` boolean is ever read; the
|
|
2743
|
-
* identity fields in the payload (email, org, subscription) never leave the
|
|
2744
|
-
* parse. The exit code is deliberately ignored — 2.1.217 exits 1 on a
|
|
2745
|
-
* logged-out verdict where other versions exit 0 — and anything that doesn't
|
|
2746
|
-
* parse to a `loggedIn` boolean is 'unknown', because `auth status` is not a
|
|
2747
|
-
* stable contract. Never rejects.
|
|
2748
|
-
*/
|
|
2749
2172
|
function checkClaudeAuth(env, options = {}) {
|
|
2750
2173
|
const executable = options.executable ?? resolveBundledClaudeExecutable();
|
|
2751
2174
|
if (!executable) return Promise.resolve("unknown");
|
|
@@ -2767,11 +2190,6 @@ function checkClaudeAuth(env, options = {}) {
|
|
|
2767
2190
|
}
|
|
2768
2191
|
//#endregion
|
|
2769
2192
|
//#region src/executors/quickjs-executor.ts
|
|
2770
|
-
/**
|
|
2771
|
-
* In-process execution backend: runs a tool's untrusted script in the QuickJS
|
|
2772
|
-
* WASM guest. Always settles inline — nothing downstream assumes that, which is
|
|
2773
|
-
* what lets a deferred backend replace it behind the same seam.
|
|
2774
|
-
*/
|
|
2775
2193
|
var QuickJsExecutor = class {
|
|
2776
2194
|
#options;
|
|
2777
2195
|
constructor(options) {
|
|
@@ -2845,8 +2263,6 @@ function safeHost(url) {
|
|
|
2845
2263
|
return;
|
|
2846
2264
|
}
|
|
2847
2265
|
}
|
|
2848
|
-
/** Exact hostname match, or a single leading `*.` wildcard covering subdomains
|
|
2849
|
-
* (not the bare parent). Only http(s) — no file:, data:, or other schemes. */
|
|
2850
2266
|
function isHostAllowed(url, allowedHosts) {
|
|
2851
2267
|
let parsed;
|
|
2852
2268
|
try {
|
|
@@ -2870,14 +2286,6 @@ var PendingRequestRegistry = class {
|
|
|
2870
2286
|
get size() {
|
|
2871
2287
|
return this.#slots.size;
|
|
2872
2288
|
}
|
|
2873
|
-
/**
|
|
2874
|
-
* Register a request and get a promise for its outcome. The promise **never
|
|
2875
|
-
* rejects**: a timeout or cancellation resolves with `ok: false` so callers
|
|
2876
|
-
* feed the failure back into the agent loop instead of unwinding it.
|
|
2877
|
-
*
|
|
2878
|
-
* Re-registering a live id throws — silently replacing it would strand the
|
|
2879
|
-
* first waiter forever.
|
|
2880
|
-
*/
|
|
2881
2289
|
register(options) {
|
|
2882
2290
|
if (this.#slots.has(options.id)) throw new Error(`pending request '${options.id}' is already registered`);
|
|
2883
2291
|
const entry = {
|
|
@@ -2909,8 +2317,6 @@ var PendingRequestRegistry = class {
|
|
|
2909
2317
|
this.#slots.set(options.id, slot);
|
|
2910
2318
|
});
|
|
2911
2319
|
}
|
|
2912
|
-
/** Deliver a result. Returns false for unknown or already-settled ids —
|
|
2913
|
-
* duplicate and late deliveries are no-ops, never a second application. */
|
|
2914
2320
|
settle(id, value, settledBy = "client") {
|
|
2915
2321
|
return this.#settle(id, {
|
|
2916
2322
|
ok: true,
|
|
@@ -2918,7 +2324,6 @@ var PendingRequestRegistry = class {
|
|
|
2918
2324
|
settledBy
|
|
2919
2325
|
});
|
|
2920
2326
|
}
|
|
2921
|
-
/** Fail a request. Same idempotence guarantee as {@link settle}. */
|
|
2922
2327
|
fail(id, reason, error, settledBy = "server") {
|
|
2923
2328
|
return this.#settle(id, {
|
|
2924
2329
|
ok: false,
|
|
@@ -2938,7 +2343,6 @@ var PendingRequestRegistry = class {
|
|
|
2938
2343
|
const entries = [...this.#slots.values()].map(toEntry);
|
|
2939
2344
|
return kind ? entries.filter((e) => e.kind === kind) : entries;
|
|
2940
2345
|
}
|
|
2941
|
-
/** Fail everything (optionally of one kind) — session close, turn interrupt. */
|
|
2942
2346
|
cancelAll(reason, error, kind) {
|
|
2943
2347
|
let canceled = 0;
|
|
2944
2348
|
for (const slot of Array.from(this.#slots.values())) {
|
|
@@ -2972,21 +2376,9 @@ function toEntry(slot) {
|
|
|
2972
2376
|
}
|
|
2973
2377
|
//#endregion
|
|
2974
2378
|
//#region src/executors/browser-bridge-executor.ts
|
|
2975
|
-
/**
|
|
2976
|
-
* Executes tool calls in the attached client's own sandbox. The first backend
|
|
2977
|
-
* that genuinely returns `pending`: dispatch puts a request on the wire and
|
|
2978
|
-
* returns, and the result arrives later through {@link resolve}.
|
|
2979
|
-
*
|
|
2980
|
-
* Data locality is the point — documents can stay in the browser and never
|
|
2981
|
-
* reach the server. The tradeoff is trust: whatever comes back is untrusted
|
|
2982
|
-
* input, fine for the user's own data but never a source for authoritative
|
|
2983
|
-
* server state (that is why MCP and secret-bearing tools are never bridged).
|
|
2984
|
-
*/
|
|
2985
2379
|
var BrowserBridgeExecutor = class {
|
|
2986
2380
|
registry;
|
|
2987
2381
|
#options;
|
|
2988
|
-
/** Results that arrive before dispatch registers them (fast client, slow
|
|
2989
|
-
* bookkeeping) would otherwise be dropped — hold them briefly. */
|
|
2990
2382
|
#early = /* @__PURE__ */ new Map();
|
|
2991
2383
|
constructor(options) {
|
|
2992
2384
|
this.#options = options;
|
|
@@ -3040,10 +2432,6 @@ var BrowserBridgeExecutor = class {
|
|
|
3040
2432
|
status: "pending"
|
|
3041
2433
|
};
|
|
3042
2434
|
}
|
|
3043
|
-
/**
|
|
3044
|
-
* Apply a client's answer. Returns false when the id is unknown or already
|
|
3045
|
-
* settled — a late result after a timeout must not re-open a settled call.
|
|
3046
|
-
*/
|
|
3047
2435
|
resolve(executionId, answer) {
|
|
3048
2436
|
if (!this.registry.has(executionId)) {
|
|
3049
2437
|
this.#early.set(executionId, answer);
|
|
@@ -3056,13 +2444,12 @@ var BrowserBridgeExecutor = class {
|
|
|
3056
2444
|
return "output" in answer ? this.registry.settle(executionId, answer, "client") : this.registry.fail(executionId, answer.reason, answer.error, "client");
|
|
3057
2445
|
}
|
|
3058
2446
|
};
|
|
3059
|
-
/** Map a registry outcome onto the executor's result contract. */
|
|
3060
2447
|
function toExecutionResult(outcome) {
|
|
3061
2448
|
if (outcome.ok && "output" in outcome.value) {
|
|
3062
2449
|
const { output, logs } = outcome.value;
|
|
3063
2450
|
return {
|
|
3064
2451
|
status: "ok",
|
|
3065
|
-
output: output.
|
|
2452
|
+
output: output.value,
|
|
3066
2453
|
logs
|
|
3067
2454
|
};
|
|
3068
2455
|
}
|
|
@@ -3083,17 +2470,6 @@ function toExecutionResult(outcome) {
|
|
|
3083
2470
|
}
|
|
3084
2471
|
//#endregion
|
|
3085
2472
|
//#region src/executors/deferred-executor.ts
|
|
3086
|
-
/**
|
|
3087
|
-
* The executor for work that outlives the session's process residency: dispatch
|
|
3088
|
-
* hands the call off and returns `pending` **without holding a promise**, because
|
|
3089
|
-
* the runner it would resolve into is about to be torn down. The result can only
|
|
3090
|
-
* come back through the host — the execution-result route → `settleExecution` on a
|
|
3091
|
-
* rehydrated runner — which is exactly what makes a park durable rather than a
|
|
3092
|
-
* long in-memory await.
|
|
3093
|
-
*
|
|
3094
|
-
* Contrast {@link BrowserBridgeExecutor}, which is also `pending` but keeps its
|
|
3095
|
-
* answer in memory for the ~60s the tab has to reply.
|
|
3096
|
-
*/
|
|
3097
2473
|
var DeferredExecutor = class {
|
|
3098
2474
|
backend;
|
|
3099
2475
|
timeoutMs;
|
|
@@ -3103,8 +2479,6 @@ var DeferredExecutor = class {
|
|
|
3103
2479
|
this.backend = options.backend ?? "remote";
|
|
3104
2480
|
this.timeoutMs = options.timeoutMs;
|
|
3105
2481
|
}
|
|
3106
|
-
/** Every call this executor takes is deferred — route only the tools that
|
|
3107
|
-
* belong on the remote side to it. */
|
|
3108
2482
|
describe() {
|
|
3109
2483
|
return {
|
|
3110
2484
|
backend: this.backend,
|
|
@@ -3131,15 +2505,6 @@ var DeferredExecutor = class {
|
|
|
3131
2505
|
//#endregion
|
|
3132
2506
|
//#region src/engines/provider/tools.ts
|
|
3133
2507
|
const MAX_FILE_BYTES = 1024 * 1024;
|
|
3134
|
-
/**
|
|
3135
|
-
* Build the capability-scoped tool set for a session.
|
|
3136
|
-
*
|
|
3137
|
-
* The agent's authority is exactly what is granted here — there are no built-in
|
|
3138
|
-
* filesystem or shell tools, and nothing reaches the host filesystem: `fs_*`
|
|
3139
|
-
* operate on an in-memory scratch VFS. Tools whose backend is not supplied are
|
|
3140
|
-
* simply absent rather than present-and-failing, so a model cannot be tempted
|
|
3141
|
-
* by a capability the operator did not grant.
|
|
3142
|
-
*/
|
|
3143
2508
|
function createToolContext(options) {
|
|
3144
2509
|
const vfs = options.vfs ?? createVfs();
|
|
3145
2510
|
const definitions = [];
|
|
@@ -3292,29 +2657,12 @@ function createToolContext(options) {
|
|
|
3292
2657
|
sandboxedToolNames: definitions.filter((d) => d.trust === "sandboxed").map((d) => d.name)
|
|
3293
2658
|
};
|
|
3294
2659
|
}
|
|
3295
|
-
/** Add host-side MCP tools to a context. They are ALWAYS authoritative: they run
|
|
3296
|
-
* server-side with server credentials, and must never be handed to a browser. */
|
|
3297
2660
|
function withMcpTools(context, mcpTools) {
|
|
3298
2661
|
return withHostTools(context, Object.fromEntries(Object.entries(mcpTools).map(([name, mcpTool]) => [name, {
|
|
3299
2662
|
tool: mcpTool,
|
|
3300
2663
|
trust: "authoritative"
|
|
3301
2664
|
}])), "MCP tool");
|
|
3302
2665
|
}
|
|
3303
|
-
/**
|
|
3304
|
-
* Add host-supplied tools to a context at an explicit trust level.
|
|
3305
|
-
*
|
|
3306
|
-
* The trust level is the whole point of the seam: {@link withMcpTools} can only
|
|
3307
|
-
* produce authoritative tools, so a host tool that *should* be sandboxed — and
|
|
3308
|
-
* therefore executable in the browser tab that asked for it — had no way to be
|
|
3309
|
-
* expressed at all. Here the host says which it is, and the contradictions are
|
|
3310
|
-
* refused rather than silently resolved:
|
|
3311
|
-
*
|
|
3312
|
-
* - a `sandboxed` tool carrying `execute` would run inline in this process with
|
|
3313
|
-
* the gateway's ambient authority, which is exactly what sandboxing it was
|
|
3314
|
-
* meant to prevent;
|
|
3315
|
-
* - an `authoritative` tool *without* `execute` would park the turn on a call no
|
|
3316
|
-
* executor claims, and the session would simply stop.
|
|
3317
|
-
*/
|
|
3318
2666
|
function withHostTools(context, hostTools, kind = "host tool") {
|
|
3319
2667
|
const entries = Object.entries(hostTools);
|
|
3320
2668
|
if (entries.length === 0) return context;
|
|
@@ -3461,10 +2809,6 @@ function parseUrl(raw) {
|
|
|
3461
2809
|
return;
|
|
3462
2810
|
}
|
|
3463
2811
|
}
|
|
3464
|
-
/** SSRF guard: resolve the hostname and refuse private, loopback, and link-local
|
|
3465
|
-
* destinations. Checked per redirect hop. Resolution happens once here and again
|
|
3466
|
-
* inside fetch (a DNS-rebinding TOCTOU); this tier accepts that — operators who
|
|
3467
|
-
* need pinning can supply `fetchImpl` with a pinned agent. */
|
|
3468
2812
|
async function denyReason(url, allowedHosts) {
|
|
3469
2813
|
const host = url.hostname.toLowerCase();
|
|
3470
2814
|
if (allowedHosts && allowedHosts.length > 0 && !hostMatches(host, allowedHosts)) return `host not allowed: ${host}`;
|
|
@@ -3489,7 +2833,6 @@ function hostMatches(host, allowedHosts) {
|
|
|
3489
2833
|
return host === pattern;
|
|
3490
2834
|
});
|
|
3491
2835
|
}
|
|
3492
|
-
/** Private / loopback / link-local / unspecified, IPv4 and IPv6 (incl. v4-mapped). */
|
|
3493
2836
|
function isPrivateAddress(address) {
|
|
3494
2837
|
const ip = address.toLowerCase();
|
|
3495
2838
|
if (ip.includes(":")) {
|
|
@@ -3530,12 +2873,6 @@ async function readCapped(response, maxBytes) {
|
|
|
3530
2873
|
function looksLikeHtml(body) {
|
|
3531
2874
|
return /<(!doctype|html|head|body)[\s>]/i.test(body.slice(0, 1024));
|
|
3532
2875
|
}
|
|
3533
|
-
/**
|
|
3534
|
-
* Dependency-free HTML → markdown, tuned for "give the model readable text":
|
|
3535
|
-
* drops non-content subtrees, keeps headings/lists/links/emphasis/code, strips
|
|
3536
|
-
* everything else. Not a spec-grade converter on purpose — a small predictable
|
|
3537
|
-
* transform beats dragging a DOM into core.
|
|
3538
|
-
*/
|
|
3539
2876
|
function htmlToMarkdown(html) {
|
|
3540
2877
|
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, "");
|
|
3541
2878
|
text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, body) => {
|
|
@@ -3558,28 +2895,27 @@ function decodeEntities(text) {
|
|
|
3558
2895
|
}
|
|
3559
2896
|
//#endregion
|
|
3560
2897
|
//#region src/engines/provider/session.ts
|
|
3561
|
-
/** Which capability a wired backend yields, for grant filtering. */
|
|
3562
2898
|
const CAPABILITY_TOOLS = {
|
|
3563
2899
|
search: "web_search",
|
|
3564
2900
|
download: "download",
|
|
3565
2901
|
webFetch: "web_fetch",
|
|
3566
2902
|
deliverFiles: "deliver_file"
|
|
3567
2903
|
};
|
|
3568
|
-
/**
|
|
3569
|
-
* Assemble a model-agnostic session: provider model, capability-scoped tools,
|
|
3570
|
-
* a scratch VFS, and the executor that runs the sandboxed ones.
|
|
3571
|
-
*
|
|
3572
|
-
* This is the piece an operator wires into the server's `createEngineRunner`.
|
|
3573
|
-
*
|
|
3574
|
-
* The host wires the *backends*; the profile and the session request decide which
|
|
3575
|
-
* of them are actually granted (`profile.session`, `config.capabilities`). A
|
|
3576
|
-
* backend that isn't granted is simply not built into the tool set, so withholding
|
|
3577
|
-
* a capability costs the host no branching. No declaration anywhere = everything
|
|
3578
|
-
* the host wired, which is what a host that ignores profiles gets.
|
|
3579
|
-
*/
|
|
3580
2904
|
function createEngineSession(options) {
|
|
3581
2905
|
const vfs = options.config.vfs ?? createVfs(options.config.restore ? options.config.restore.vfs : options.seedVfs);
|
|
3582
|
-
const executor = options.selectExecutor
|
|
2906
|
+
const executor = options.selectExecutor.length > 0 ? {
|
|
2907
|
+
describe(call) {
|
|
2908
|
+
const target = options.selectExecutor(call);
|
|
2909
|
+
const backend = typeof options.backend === "function" ? options.backend(call) : options.backend;
|
|
2910
|
+
return {
|
|
2911
|
+
...target.describe?.(call),
|
|
2912
|
+
...backend ? { backend } : {}
|
|
2913
|
+
};
|
|
2914
|
+
},
|
|
2915
|
+
dispatch(call) {
|
|
2916
|
+
return options.selectExecutor(call).dispatch(call);
|
|
2917
|
+
}
|
|
2918
|
+
} : options.selectExecutor();
|
|
3583
2919
|
const granted = options.config.capabilities ?? options.profile?.session?.capabilities;
|
|
3584
2920
|
const isGranted = (key) => granted === void 0 || granted.includes(CAPABILITY_TOOLS[key]);
|
|
3585
2921
|
let runner;
|
|
@@ -3613,26 +2949,14 @@ function createEngineSession(options) {
|
|
|
3613
2949
|
vfs,
|
|
3614
2950
|
executor,
|
|
3615
2951
|
executableTools: context.sandboxedToolNames,
|
|
3616
|
-
executionBackend: options.backend ?? "server",
|
|
2952
|
+
executionBackend: typeof options.backend === "function" ? void 0 : options.backend ?? "server",
|
|
3617
2953
|
executionLimits: options.executionLimits,
|
|
2954
|
+
shouldApprove: options.shouldApprove,
|
|
2955
|
+
approvalTimeoutMs: options.approvalTimeoutMs,
|
|
3618
2956
|
reportMcpServers: options.mcp ? () => Promise.resolve(declaredServers === void 0 ? options.mcp.servers : options.mcp.servers.filter((s) => declaredServers.includes(s.name))) : void 0
|
|
3619
2957
|
}, options.id);
|
|
3620
2958
|
return runner;
|
|
3621
2959
|
}
|
|
3622
|
-
/**
|
|
3623
|
-
* Refuse to build a session whose profile names an MCP server that isn't there.
|
|
3624
|
-
*
|
|
3625
|
-
* A profile's `mcpServers` list is a **declaration**, not a filter: an embedder
|
|
3626
|
-
* who wrote it meant the agent to have those tools. Honouring it partially is
|
|
3627
|
-
* the worst failure mode this engine has — the session starts, reports healthy,
|
|
3628
|
-
* and the agent apologises its way through every request that needed the server,
|
|
3629
|
-
* with one warning line in a log nobody is reading.
|
|
3630
|
-
*
|
|
3631
|
-
* With a {@link McpConnection} the check is exact (did this server connect?).
|
|
3632
|
-
* With a bare tool set all we can see is whether any tool carries the server's
|
|
3633
|
-
* namespace, so a genuinely tool-less server would trip it — the fix there is to
|
|
3634
|
-
* pass `mcp` rather than to weaken this.
|
|
3635
|
-
*/
|
|
3636
2960
|
function requireDeclaredServers(profileName, declared, mcp, tools) {
|
|
3637
2961
|
if (!declared || declared.length === 0) return;
|
|
3638
2962
|
const missing = declared.filter((name) => {
|
|
@@ -3649,33 +2973,11 @@ function requireDeclaredServers(profileName, declared, mcp, tools) {
|
|
|
3649
2973
|
}).join(", ");
|
|
3650
2974
|
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.`);
|
|
3651
2975
|
}
|
|
3652
|
-
/**
|
|
3653
|
-
* Restrict a connected tool set to the MCP servers a profile grants, by the
|
|
3654
|
-
* `<server>__<tool>` namespace {@link connectMcpTools} assigns. Undefined `servers`
|
|
3655
|
-
* = no declaration, so every connected server passes through.
|
|
3656
|
-
*
|
|
3657
|
-
* This is how one process-wide MCP connection serves a mixed fleet: the host
|
|
3658
|
-
* connects everything once, each profile grants a subset. The transport configs —
|
|
3659
|
-
* and any credentials in their headers — never leave the host for a profile.
|
|
3660
|
-
*/
|
|
3661
2976
|
function selectMcpTools(tools, servers) {
|
|
3662
2977
|
if (!tools || servers === void 0) return tools;
|
|
3663
2978
|
const allowed = new Set(servers);
|
|
3664
2979
|
return Object.fromEntries(Object.entries(tools).filter(([name]) => allowed.has(name.split("__")[0])));
|
|
3665
2980
|
}
|
|
3666
|
-
/**
|
|
3667
|
-
* Connect to MCP servers and return their tools, ready for {@link withMcpTools}.
|
|
3668
|
-
*
|
|
3669
|
-
* Server-side only, with server credentials: these tools are authoritative and
|
|
3670
|
-
* must never be bridged to a browser. `@ai-sdk/mcp` is imported lazily and is an
|
|
3671
|
-
* optional dependency — an operator who wires no MCP servers never needs it.
|
|
3672
|
-
*
|
|
3673
|
-
* **A stateless MCP server must answer `GET` with 405.** The client opens the
|
|
3674
|
-
* SSE stream with a `GET` before it sends anything, and a POST-only server
|
|
3675
|
-
* mounted under a framework's default 404 makes the whole connect fail with an
|
|
3676
|
-
* error that names neither the method nor the route. This is the single most
|
|
3677
|
-
* common way an otherwise-correct MCP mount fails.
|
|
3678
|
-
*/
|
|
3679
2981
|
async function connectMcpTools(servers, options = {}) {
|
|
3680
2982
|
const entries = Object.entries(servers);
|
|
3681
2983
|
if (entries.length === 0) return {
|
|
@@ -3717,7 +3019,7 @@ async function connectMcpTools(servers, options = {}) {
|
|
|
3717
3019
|
options.onError?.(name, error);
|
|
3718
3020
|
if (options.required) {
|
|
3719
3021
|
await closeAll();
|
|
3720
|
-
throw new Error(`MCP server '${name}' failed to connect: ${message}
|
|
3022
|
+
throw new Error(`MCP server '${name}' failed to connect: ${message}`, { cause: error });
|
|
3721
3023
|
}
|
|
3722
3024
|
}
|
|
3723
3025
|
}
|
|
@@ -3727,7 +3029,6 @@ async function connectMcpTools(servers, options = {}) {
|
|
|
3727
3029
|
close: closeAll
|
|
3728
3030
|
};
|
|
3729
3031
|
}
|
|
3730
|
-
/** The connection's identity, minus its secrets — `headers` never travel. */
|
|
3731
3032
|
function describeServer(server) {
|
|
3732
3033
|
if ("url" in server) return {
|
|
3733
3034
|
transport: server.type === "sse" ? "sse" : "http",
|
|
@@ -3739,12 +3040,6 @@ function describeServer(server) {
|
|
|
3739
3040
|
args: server.args
|
|
3740
3041
|
};
|
|
3741
3042
|
}
|
|
3742
|
-
/**
|
|
3743
|
-
* The AI SDK hands back its own `Tool`, whose `inputSchema` may be a zod schema
|
|
3744
|
-
* or a `jsonSchema()` wrapper. Only the latter carries a JSON Schema document,
|
|
3745
|
-
* so that is the only case where parameters are reported — `McpServerToolInfo`
|
|
3746
|
-
* models the absence deliberately, and inventing one here would be worse.
|
|
3747
|
-
*/
|
|
3748
3043
|
function toToolInfo(name, mcpTool) {
|
|
3749
3044
|
const { description, inputSchema } = mcpTool ?? {};
|
|
3750
3045
|
return {
|
|
@@ -3753,12 +3048,6 @@ function toToolInfo(name, mcpTool) {
|
|
|
3753
3048
|
inputSchema: inputSchema?.jsonSchema
|
|
3754
3049
|
};
|
|
3755
3050
|
}
|
|
3756
|
-
/**
|
|
3757
|
-
* Only http/sse: the AI SDK's built-in transports are the remote ones, and its
|
|
3758
|
-
* own docs mark stdio local-only and not deployable. A stdio server here is a
|
|
3759
|
-
* misconfiguration worth surfacing rather than silently dropping — the Claude
|
|
3760
|
-
* engine still supports stdio, since the CLI spawns those itself.
|
|
3761
|
-
*/
|
|
3762
3051
|
function toTransport(server) {
|
|
3763
3052
|
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)");
|
|
3764
3053
|
return server.type === "sse" ? {
|
|
@@ -3773,30 +3062,6 @@ function toTransport(server) {
|
|
|
3773
3062
|
}
|
|
3774
3063
|
//#endregion
|
|
3775
3064
|
//#region src/engines/claude/catalog.ts
|
|
3776
|
-
/**
|
|
3777
|
-
* The Claude engine's model catalog — what a create form offers before any
|
|
3778
|
-
* session has run.
|
|
3779
|
-
*
|
|
3780
|
-
* **Refresh procedure** (release checklist): run `supportedModels()` on a
|
|
3781
|
-
* throwaway SDK query (no tokens spent) and re-apply the shaping rules of
|
|
3782
|
-
* `modelOptionsFromSdk` (`src/normalize.ts`) at authoring time: drop the
|
|
3783
|
-
* `default` sentinel row, derive display names from resolved ids where
|
|
3784
|
-
* unambiguous, mark the newest of each family `primary`, sort by family rank.
|
|
3785
|
-
* A unit test replays the raw extraction through `modelOptionsFromSdk` and
|
|
3786
|
-
* asserts these rows match, so the rules cannot drift.
|
|
3787
|
-
*
|
|
3788
|
-
* Two things the live `capabilities` event can never offer:
|
|
3789
|
-
* - rows for **older models** the CLI no longer reports (hand-maintained, the
|
|
3790
|
-
* accepted cost of a static catalog; the CLI silently downgrades an effort a
|
|
3791
|
-
* model doesn't support, so `reasoningEfforts` is omitted on them and the
|
|
3792
|
-
* engine default set applies);
|
|
3793
|
-
* - an answer on a **cold server**. The live event still exists and remains
|
|
3794
|
-
* the in-session truth for the model switcher; this catalog is the
|
|
3795
|
-
* create-form truth.
|
|
3796
|
-
*
|
|
3797
|
-
* `defaultModel` is deliberately NOT here: a claude profile's default is the
|
|
3798
|
-
* operator's CLI config, unknowable statically.
|
|
3799
|
-
*/
|
|
3800
3065
|
const CLAUDE_CATALOG = {
|
|
3801
3066
|
provenance: "supportedModels() of @anthropic-ai/claude-agent-sdk 0.3.221 (Claude Code CLI), extracted 2026-08-05; older-model rows hand-maintained",
|
|
3802
3067
|
models: [
|
|
@@ -3866,13 +3131,6 @@ const CLAUDE_CATALOG = {
|
|
|
3866
3131
|
};
|
|
3867
3132
|
//#endregion
|
|
3868
3133
|
//#region src/engines/claude/adapter.ts
|
|
3869
|
-
/**
|
|
3870
|
-
* The Claude engine as an adapter — a thin, behaviourally inert wrapper:
|
|
3871
|
-
* `SessionRunner` unchanged, `checkClaudeAuth` as the probe, the static
|
|
3872
|
-
* catalog for create forms. Exists so catalogs, capabilities and availability
|
|
3873
|
-
* have one shape across engines; the runner itself is exactly what
|
|
3874
|
-
* `registry.prepare()` builds.
|
|
3875
|
-
*/
|
|
3876
3134
|
const claudeAdapter = {
|
|
3877
3135
|
engine: "claude",
|
|
3878
3136
|
capabilities: ENGINE_CAPABILITIES.claude,
|
|
@@ -3890,12 +3148,6 @@ const claudeAdapter = {
|
|
|
3890
3148
|
if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
|
|
3891
3149
|
return new SessionRunner(config, id);
|
|
3892
3150
|
},
|
|
3893
|
-
/**
|
|
3894
|
-
* The Agent SDK's on-disk session store, mapped browser-safe. The SDK reads
|
|
3895
|
-
* the store of the *process* environment — it takes no config dir — so a
|
|
3896
|
-
* profile pin cannot narrow this listing; that matches the route's
|
|
3897
|
-
* pre-adapter behavior exactly (the listing was always process-global).
|
|
3898
|
-
*/
|
|
3899
3151
|
async listSessions({ dir, limit, offset }) {
|
|
3900
3152
|
return (await listSessions({
|
|
3901
3153
|
dir,
|
|
@@ -3913,10 +3165,28 @@ const claudeAdapter = {
|
|
|
3913
3165
|
}));
|
|
3914
3166
|
}
|
|
3915
3167
|
};
|
|
3168
|
+
//#endregion
|
|
3169
|
+
//#region src/engines/codex/connect.ts
|
|
3916
3170
|
/**
|
|
3917
|
-
*
|
|
3918
|
-
*
|
|
3171
|
+
* The operator's environment reaches the child whole — WorkerDeck resolves no credential of its
|
|
3172
|
+
* own — and the profile's CODEX_HOME is the one key it pins, last, so a profile always wins over
|
|
3173
|
+
* an inherited value. Every path that spawns or connects to an app-server goes through here.
|
|
3919
3174
|
*/
|
|
3175
|
+
function codexChildEnv(base, codexHome) {
|
|
3176
|
+
const env = {};
|
|
3177
|
+
for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
|
|
3178
|
+
if (codexHome) env.CODEX_HOME = codexHome;
|
|
3179
|
+
return env;
|
|
3180
|
+
}
|
|
3181
|
+
/** `experimentalApi` gates the granular approval policy and there is no non-experimental fallback, so it is not per-call-site. */
|
|
3182
|
+
const INITIALIZE_PARAMS = {
|
|
3183
|
+
clientInfo: {
|
|
3184
|
+
name: "workerdeck",
|
|
3185
|
+
title: "WorkerDeck",
|
|
3186
|
+
version: `protocol-${PROTOCOL_VERSION}`
|
|
3187
|
+
},
|
|
3188
|
+
capabilities: { experimentalApi: true }
|
|
3189
|
+
};
|
|
3920
3190
|
var JsonRpcError = class extends Error {
|
|
3921
3191
|
code;
|
|
3922
3192
|
constructor(code, message) {
|
|
@@ -3925,18 +3195,6 @@ var JsonRpcError = class extends Error {
|
|
|
3925
3195
|
this.code = code;
|
|
3926
3196
|
}
|
|
3927
3197
|
};
|
|
3928
|
-
/**
|
|
3929
|
-
* JSON-RPC over a `codex app-server` child's stdio: **newline-delimited JSON**,
|
|
3930
|
-
* one message per line, and — verified against 0.146.0 — an envelope *without*
|
|
3931
|
-
* the `jsonrpc: "2.0"` field (`{id, method, params}` / `{id, result}` /
|
|
3932
|
-
* `{id, error}`; the binary's own schema marks only those required). Server→
|
|
3933
|
-
* client notifications additionally carry a top-level `emittedAtMs`, ignored
|
|
3934
|
-
* here.
|
|
3935
|
-
*
|
|
3936
|
-
* Transport only: no method knowledge, no process ownership. The process
|
|
3937
|
-
* wrapper (`process.ts`) owns the child and calls {@link fail} when it dies so
|
|
3938
|
-
* every in-flight request rejects instead of hanging.
|
|
3939
|
-
*/
|
|
3940
3198
|
var JsonRpcStdioConnection = class {
|
|
3941
3199
|
#output;
|
|
3942
3200
|
#nextId = 1;
|
|
@@ -3944,7 +3202,6 @@ var JsonRpcStdioConnection = class {
|
|
|
3944
3202
|
#buffer = "";
|
|
3945
3203
|
#closed = false;
|
|
3946
3204
|
#notificationHandler;
|
|
3947
|
-
/** Where {@link CODEX_TRACE_ENV} pointed, or undefined — read once. */
|
|
3948
3205
|
#trace;
|
|
3949
3206
|
#requestHandler;
|
|
3950
3207
|
constructor(options) {
|
|
@@ -3983,8 +3240,6 @@ var JsonRpcStdioConnection = class {
|
|
|
3983
3240
|
onRequest(handler) {
|
|
3984
3241
|
this.#requestHandler = handler;
|
|
3985
3242
|
}
|
|
3986
|
-
/** Reject everything in flight and refuse new traffic — the child is gone
|
|
3987
|
-
* (or the session is over). Idempotent. */
|
|
3988
3243
|
fail(message) {
|
|
3989
3244
|
if (this.#closed) return;
|
|
3990
3245
|
this.#closed = true;
|
|
@@ -4014,15 +3269,6 @@ var JsonRpcStdioConnection = class {
|
|
|
4014
3269
|
this.#dispatch(message);
|
|
4015
3270
|
}
|
|
4016
3271
|
}
|
|
4017
|
-
/**
|
|
4018
|
-
* Append one inbound message to the trace file, when the operator asked for
|
|
4019
|
-
* one. **Notifications and server→client requests only** — a response body is
|
|
4020
|
-
* not needed to answer the questions this exists for, and `account/*` results
|
|
4021
|
-
* are the one place app-server traffic can carry a masked credential
|
|
4022
|
-
* fragment, which nothing of ours writes to disk (see the auth red lines).
|
|
4023
|
-
* Best-effort and synchronous: a debug sink that loses lines proves nothing,
|
|
4024
|
-
* and a debug sink that throws must not take the session with it.
|
|
4025
|
-
*/
|
|
4026
3272
|
#traceLine(message) {
|
|
4027
3273
|
if (!this.#trace) return;
|
|
4028
3274
|
const method = message.method;
|
|
@@ -4071,50 +3317,12 @@ var JsonRpcStdioConnection = class {
|
|
|
4071
3317
|
};
|
|
4072
3318
|
//#endregion
|
|
4073
3319
|
//#region src/engines/codex/subagents.ts
|
|
4074
|
-
/**
|
|
4075
|
-
* The codex side of `SessionInfo.subagents` — and the attribution table that
|
|
4076
|
-
* gives every event a spawned agent produces its `parentToolUseId`.
|
|
4077
|
-
*
|
|
4078
|
-
* Codex's signal is stronger than the claude engine's, so this is deliberately
|
|
4079
|
-
* NOT that tracker generalised (`engines/claude/subagents.ts` infers spawns
|
|
4080
|
-
* from tool names and verdicts from result-text sniffing, ~290 lines of module
|
|
4081
|
-
* doc explaining the inference). Here nothing is inferred: `subAgentActivity
|
|
4082
|
-
* {kind: 'started'}` on the owning thread positively announces an agent, names
|
|
4083
|
-
* it (`agentPath`), keys it (`agentThreadId` — the id every one of its later
|
|
4084
|
-
* notifications carries) and hands over the model's own `spawn_agent` call id;
|
|
4085
|
-
* the agent's end is its own thread's `turn/completed`, status included. So a
|
|
4086
|
-
* record is keyed by **thread id** — the wire's handle — while exposing a
|
|
4087
|
-
* **tool-use id** — the protocol's: `parentToolUseId` on nested events must
|
|
4088
|
-
* equal the anchor `tool_use`'s id for `subagentItems` (the frame membership
|
|
4089
|
-
* rule every client shares) to reassemble the sidechain, and this map is where
|
|
4090
|
-
* the two vocabularies meet.
|
|
4091
|
-
*
|
|
4092
|
-
* Two decisions worth their prose:
|
|
4093
|
-
*
|
|
4094
|
-
* **A record survives the runner's turns.** Codex agents are designed to
|
|
4095
|
-
* outlive the root turn that spawned them (`sendInput`/`resumeAgent` address a
|
|
4096
|
-
* thread that kept existing), so — unlike a pending approval — nothing here is
|
|
4097
|
-
* swept when a root turn ends. What does end every agent is the app-server
|
|
4098
|
-
* process itself: the runner calls {@link sweep} when the child dies or the
|
|
4099
|
-
* session closes, because an agent whose host process is gone can never report,
|
|
4100
|
-
* and `running` on a closed session would be a lie a polled list re-renders
|
|
4101
|
-
* forever (the claude tracker's argument, inherited whole).
|
|
4102
|
-
*
|
|
4103
|
-
* **The settled tail is bounded, running records never are** — the same
|
|
4104
|
-
* {@link SUBAGENT_HISTORY} discipline as the claude tracker, and enforced at
|
|
4105
|
-
* settle time for the same reason: a settle happens once per agent, `list()`
|
|
4106
|
-
* once per row of a 1.2s-polled sessions list.
|
|
4107
|
-
*/
|
|
4108
3320
|
var CodexAgentTracker = class {
|
|
4109
3321
|
#byThread = /* @__PURE__ */ new Map();
|
|
4110
3322
|
#settleCounter = 0;
|
|
4111
|
-
/** The record whose thread this is — the attribution lookup. */
|
|
4112
3323
|
get(agentThreadId) {
|
|
4113
3324
|
return this.#byThread.get(agentThreadId);
|
|
4114
3325
|
}
|
|
4115
|
-
/** Open (or return) the record for a thread. Fill-in, never overwrite: a
|
|
4116
|
-
* label-less fallback record keeps its accumulated count and its already
|
|
4117
|
-
* published toolUseId when the announcing item arrives late. */
|
|
4118
3326
|
open(agentThreadId, toolUseId, agentType, ts) {
|
|
4119
3327
|
let record = this.#byThread.get(agentThreadId);
|
|
4120
3328
|
if (!record) {
|
|
@@ -4131,8 +3339,6 @@ var CodexAgentTracker = class {
|
|
|
4131
3339
|
record.agentType ??= agentType;
|
|
4132
3340
|
return record;
|
|
4133
3341
|
}
|
|
4134
|
-
/** The agent's thread ran again (`kind: 'interacted'`, or a fresh
|
|
4135
|
-
* `turn/started` on its thread): a settled verdict no longer describes it. */
|
|
4136
3342
|
revive(record) {
|
|
4137
3343
|
record.status = "running";
|
|
4138
3344
|
record.settledOrder = void 0;
|
|
@@ -4153,41 +3359,21 @@ var CodexAgentTracker = class {
|
|
|
4153
3359
|
settled--;
|
|
4154
3360
|
}
|
|
4155
3361
|
}
|
|
4156
|
-
/** A real verdict for one agent — its thread's `turn/completed`, or the
|
|
4157
|
-
* `interrupted` activity edge. */
|
|
4158
3362
|
settle(record, status) {
|
|
4159
3363
|
if (record.status === status) return;
|
|
4160
3364
|
this.#settle(record, status);
|
|
4161
3365
|
}
|
|
4162
|
-
/** The process the agents lived in is gone (child death, session close):
|
|
4163
|
-
* everything still running is settled as failed — the report can never come. */
|
|
4164
3366
|
sweep() {
|
|
4165
3367
|
for (const record of this.#byThread.values()) if (record.status === "running") this.#settle(record, "failed");
|
|
4166
3368
|
}
|
|
4167
|
-
/**
|
|
4168
|
-
* The conversation these agents belonged to is gone (a `conversation_reset`).
|
|
4169
|
-
*
|
|
4170
|
-
* Deliberately NOT {@link CodexAgentTracker.sweep}: that settles the running
|
|
4171
|
-
* ones as failed and keeps the rows, which is right when the *process* dies —
|
|
4172
|
-
* the transcript still holds the anchor `tool_use` each row points at, and a
|
|
4173
|
-
* row that vanished would leave that card unexplained. A clear is the other
|
|
4174
|
-
* way round. The anchors go with the transcript, so a surviving row would
|
|
4175
|
-
* publish a `toolUseId` that resolves to nothing — and clients key a
|
|
4176
|
-
* pressable, enterable agent line off exactly that id.
|
|
4177
|
-
*/
|
|
4178
3369
|
forget() {
|
|
4179
3370
|
this.#byThread.clear();
|
|
4180
3371
|
}
|
|
4181
|
-
/** The thread ids currently tracked — what a clear remembers so a still-running
|
|
4182
|
-
* agent's later traffic can be dropped rather than re-anchored. */
|
|
4183
3372
|
threadIds() {
|
|
4184
3373
|
return Array.from(this.#byThread.keys());
|
|
4185
3374
|
}
|
|
4186
|
-
/** The rollup as `SessionInfo.subagents` serves it — spawn order, fresh
|
|
4187
|
-
* objects, and `undefined` when there is nothing to say (absent and empty
|
|
4188
|
-
* mean the same thing to a client, and bytes on a polled list are paid for). */
|
|
4189
3375
|
list() {
|
|
4190
|
-
if (this.#byThread.size === 0) return
|
|
3376
|
+
if (this.#byThread.size === 0) return;
|
|
4191
3377
|
const out = [];
|
|
4192
3378
|
for (const r of this.#byThread.values()) out.push({
|
|
4193
3379
|
toolUseId: r.toolUseId,
|
|
@@ -4201,63 +3387,12 @@ var CodexAgentTracker = class {
|
|
|
4201
3387
|
};
|
|
4202
3388
|
//#endregion
|
|
4203
3389
|
//#region src/engines/codex/trust.ts
|
|
4204
|
-
/**
|
|
4205
|
-
* Codex project trust: will this session's cwd get its `.codex/config.toml`?
|
|
4206
|
-
*
|
|
4207
|
-
* Codex only layers a project's `.codex/config.toml` onto the operator's base
|
|
4208
|
-
* config when the project is *trusted* (a `[projects."<path>"]
|
|
4209
|
-
* trust_level = "trusted"` entry in `$CODEX_HOME/config.toml`), and the
|
|
4210
|
-
* app-server surface has no trust prompt — that lives in the TUI. So under
|
|
4211
|
-
* WorkerDeck an untrusted project's config, MCP servers included, is silently
|
|
4212
|
-
* ignored: no error, no notice, servers just missing. The runner asks this
|
|
4213
|
-
* module at session start whether that is about to happen, so the transcript
|
|
4214
|
-
* can say so.
|
|
4215
|
-
*
|
|
4216
|
-
* Semantics, all measured against both the bundled 0.146.0 and 0.149.0
|
|
4217
|
-
* (2026-08-22, via `codex mcp list` from probe cwds and via `thread/start` +
|
|
4218
|
-
* `mcpServerStatus/list` on the app-server surface — identical answers):
|
|
4219
|
-
*
|
|
4220
|
-
* - **Discovery**: config layers come from the cwd and its ancestors up to and
|
|
4221
|
-
* including the nearest directory containing `.git` (dir or file). With no
|
|
4222
|
-
* git anywhere above, the cwd alone is consulted. Directories above the
|
|
4223
|
-
* nearest git root never contribute, trusted or not.
|
|
4224
|
-
* - **Trust per layer**: an exact entry for the layer's own canonical path
|
|
4225
|
-
* decides (an explicit `"untrusted"` beats inherited trust); without one the
|
|
4226
|
-
* layer inherits from the chain's git root — trusted iff the git root has a
|
|
4227
|
-
* trusted entry, where a linked worktree's root also counts its main
|
|
4228
|
-
* repository's entry (the `.git` file's gitdir names it). A trusted
|
|
4229
|
-
* mid-chain directory does NOT trust its children, and plain path
|
|
4230
|
-
* containment without git confers nothing.
|
|
4231
|
-
* - **Canonical paths**: codex matches entries against the canonicalized cwd —
|
|
4232
|
-
* a macOS `/tmp/...` entry never matches the `/private/tmp/...` it points
|
|
4233
|
-
* at, while the reverse spelling works (and the app-server canonicalizes its
|
|
4234
|
-
* `cwd` param too). Both sides here are realpath'd, which can only err
|
|
4235
|
-
* toward silence.
|
|
4236
|
-
* - **The gate is sandbox-scoped**: `thread/start` under `workspace-write` or
|
|
4237
|
-
* `danger-full-access` (permission modes `acceptEdits`/`bypassPermissions`)
|
|
4238
|
-
* WRITES the trust entry itself and loads the config — only `read-only`
|
|
4239
|
-
* (mode `default`) leaves the project untrusted and the config ignored. A
|
|
4240
|
-
* later `turn/start` with a wider sandboxPolicy does not heal the thread
|
|
4241
|
-
* (measured): the caller probes `default`-mode sessions only, and the notice
|
|
4242
|
-
* stays true for the session it opens.
|
|
4243
|
-
* - `trust_level`'s vocabulary is exactly `trusted`/`untrusted`; any other
|
|
4244
|
-
* value fails codex's bootstrap outright ("unknown variant"), so a config
|
|
4245
|
-
* carrying one probes silent — that session announces its own failure.
|
|
4246
|
-
*
|
|
4247
|
-
* The correctness bar for every degrade path: a FALSE notice — warning about a
|
|
4248
|
-
* project codex actually trusts — is worse than a missed one. The narrow TOML
|
|
4249
|
-
* reader below refuses (→ silence) anything it cannot interpret with
|
|
4250
|
-
* certainty, rather than guessing.
|
|
4251
|
-
*/
|
|
4252
3390
|
const BARE_KEY = /[A-Za-z0-9_-]/;
|
|
4253
3391
|
function skipWs(text, pos) {
|
|
4254
3392
|
let i = pos;
|
|
4255
3393
|
while (i < text.length && (text[i] === " " || text[i] === " ")) i++;
|
|
4256
3394
|
return i;
|
|
4257
3395
|
}
|
|
4258
|
-
/** One-line TOML basic string starting at `pos` (which must be `"`). Undefined
|
|
4259
|
-
* on an escape TOML doesn't define or a close quote that never comes — the
|
|
4260
|
-
* caller refuses the file rather than guessing what codex would read. */
|
|
4261
3396
|
function parseBasicString(text, pos) {
|
|
4262
3397
|
let out = "";
|
|
4263
3398
|
let i = pos + 1;
|
|
@@ -4279,12 +3414,12 @@ function parseBasicString(text, pos) {
|
|
|
4279
3414
|
else if (esc === "u" || esc === "U") {
|
|
4280
3415
|
const width = esc === "u" ? 4 : 8;
|
|
4281
3416
|
const hex = text.slice(i + 2, i + 2 + width);
|
|
4282
|
-
if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return
|
|
3417
|
+
if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return;
|
|
4283
3418
|
const code = Number.parseInt(hex, 16);
|
|
4284
|
-
if (code > 1114111) return
|
|
3419
|
+
if (code > 1114111) return;
|
|
4285
3420
|
out += String.fromCodePoint(code);
|
|
4286
3421
|
i += width;
|
|
4287
|
-
} else return
|
|
3422
|
+
} else return;
|
|
4288
3423
|
i += 2;
|
|
4289
3424
|
continue;
|
|
4290
3425
|
}
|
|
@@ -4292,17 +3427,14 @@ function parseBasicString(text, pos) {
|
|
|
4292
3427
|
i++;
|
|
4293
3428
|
}
|
|
4294
3429
|
}
|
|
4295
|
-
/** One-line TOML literal string starting at `pos` (which must be `'`). */
|
|
4296
3430
|
function parseLiteralString(text, pos) {
|
|
4297
3431
|
const close = text.indexOf("'", pos + 1);
|
|
4298
|
-
if (close === -1) return
|
|
3432
|
+
if (close === -1) return;
|
|
4299
3433
|
return {
|
|
4300
3434
|
value: text.slice(pos + 1, close),
|
|
4301
3435
|
end: close + 1
|
|
4302
3436
|
};
|
|
4303
3437
|
}
|
|
4304
|
-
/** A dotted key path — bare, `"basic"` and `'literal'` keys, whitespace around
|
|
4305
|
-
* the dots — as found in table headers and on the left of assignments. */
|
|
4306
3438
|
function parseKeyPath(text, pos) {
|
|
4307
3439
|
const keys = [];
|
|
4308
3440
|
let i = pos;
|
|
@@ -4311,7 +3443,7 @@ function parseKeyPath(text, pos) {
|
|
|
4311
3443
|
const ch = text[i];
|
|
4312
3444
|
if (ch === "\"" || ch === "'") {
|
|
4313
3445
|
const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
|
|
4314
|
-
if (!str) return
|
|
3446
|
+
if (!str) return;
|
|
4315
3447
|
keys.push(str.value);
|
|
4316
3448
|
i = str.end;
|
|
4317
3449
|
} else if (ch !== void 0 && BARE_KEY.test(ch)) {
|
|
@@ -4328,23 +3460,14 @@ function parseKeyPath(text, pos) {
|
|
|
4328
3460
|
i++;
|
|
4329
3461
|
}
|
|
4330
3462
|
}
|
|
4331
|
-
/**
|
|
4332
|
-
* Scan an assignment's value (or the continuation line of a multi-line array),
|
|
4333
|
-
* confirming where it ends. Returns the bracket depth carried onto the next
|
|
4334
|
-
* line (0 = the value is complete) plus the string itself when the whole value
|
|
4335
|
-
* was one plain one-line string. Undefined refuses the file: multi-line
|
|
4336
|
-
* strings are where a line reader starts misreading string *content* as
|
|
4337
|
-
* sections and entries — the exact mistake that could flip a real trust entry
|
|
4338
|
-
* — so they are not parsed around, they end the attempt.
|
|
4339
|
-
*/
|
|
4340
3463
|
function scanValueLine(text, pos, depth) {
|
|
4341
3464
|
let i = skipWs(text, pos);
|
|
4342
3465
|
if (depth === 0 && (text[i] === "\"" || text[i] === "'")) {
|
|
4343
|
-
if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return
|
|
3466
|
+
if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return;
|
|
4344
3467
|
const str = text[i] === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
|
|
4345
|
-
if (!str) return
|
|
3468
|
+
if (!str) return;
|
|
4346
3469
|
const rest = skipWs(text, str.end);
|
|
4347
|
-
if (rest < text.length && text[rest] !== "#") return
|
|
3470
|
+
if (rest < text.length && text[rest] !== "#") return;
|
|
4348
3471
|
return {
|
|
4349
3472
|
depth: 0,
|
|
4350
3473
|
value: str.value
|
|
@@ -4354,33 +3477,21 @@ function scanValueLine(text, pos, depth) {
|
|
|
4354
3477
|
const ch = text[i];
|
|
4355
3478
|
if (ch === "#") break;
|
|
4356
3479
|
if (ch === "\"" || ch === "'") {
|
|
4357
|
-
if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return
|
|
3480
|
+
if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return;
|
|
4358
3481
|
const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
|
|
4359
|
-
if (!str) return
|
|
3482
|
+
if (!str) return;
|
|
4360
3483
|
i = str.end;
|
|
4361
3484
|
continue;
|
|
4362
3485
|
}
|
|
4363
3486
|
if (ch === "[" || ch === "{") depth++;
|
|
4364
3487
|
else if (ch === "]" || ch === "}") {
|
|
4365
3488
|
depth--;
|
|
4366
|
-
if (depth < 0) return
|
|
3489
|
+
if (depth < 0) return;
|
|
4367
3490
|
}
|
|
4368
3491
|
i++;
|
|
4369
3492
|
}
|
|
4370
3493
|
return { depth };
|
|
4371
3494
|
}
|
|
4372
|
-
/**
|
|
4373
|
-
* The `[projects."<path>"] trust_level = "..."` entries of a codex
|
|
4374
|
-
* `config.toml`, by a deliberately narrow reader (core takes no TOML
|
|
4375
|
-
* dependency for this). Handles what codex itself writes plus the reasonable
|
|
4376
|
-
* hand-edits — comments, CRLF, whitespace, quoted keys with escapes, literal
|
|
4377
|
-
* and bare keys, `[projects]`-with-dotted-keys and top-level dotted forms,
|
|
4378
|
-
* single-line inline tables, multi-line arrays — and returns **undefined for
|
|
4379
|
-
* anything else it meets anywhere in the file** (multi-line strings,
|
|
4380
|
-
* `projects` as an inline table, array-of-tables, junk): the caller treats
|
|
4381
|
-
* undefined as "cannot know" and stays silent. Conflicting duplicate entries
|
|
4382
|
-
* also refuse — invalid for TOML, and guessing wrong is a false notice.
|
|
4383
|
-
*/
|
|
4384
3495
|
function parseProjectTrustEntries(source) {
|
|
4385
3496
|
const entries = /* @__PURE__ */ new Map();
|
|
4386
3497
|
let section = [];
|
|
@@ -4389,7 +3500,7 @@ function parseProjectTrustEntries(source) {
|
|
|
4389
3500
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
4390
3501
|
if (carryDepth > 0) {
|
|
4391
3502
|
const scanned = scanValueLine(line, 0, carryDepth);
|
|
4392
|
-
if (!scanned) return
|
|
3503
|
+
if (!scanned) return;
|
|
4393
3504
|
carryDepth = scanned.depth;
|
|
4394
3505
|
continue;
|
|
4395
3506
|
}
|
|
@@ -4398,43 +3509,35 @@ function parseProjectTrustEntries(source) {
|
|
|
4398
3509
|
if (line[start] === "[") {
|
|
4399
3510
|
const array = line.startsWith("[[", start);
|
|
4400
3511
|
const path = parseKeyPath(line, start + (array ? 2 : 1));
|
|
4401
|
-
if (!path) return
|
|
3512
|
+
if (!path) return;
|
|
4402
3513
|
const close = array ? "]]" : "]";
|
|
4403
|
-
if (!line.startsWith(close, path.end)) return
|
|
3514
|
+
if (!line.startsWith(close, path.end)) return;
|
|
4404
3515
|
const rest = skipWs(line, path.end + close.length);
|
|
4405
|
-
if (rest < line.length && line[rest] !== "#") return
|
|
4406
|
-
if (array && path.value[0] === "projects") return
|
|
3516
|
+
if (rest < line.length && line[rest] !== "#") return;
|
|
3517
|
+
if (array && path.value[0] === "projects") return;
|
|
4407
3518
|
section = path.value;
|
|
4408
3519
|
continue;
|
|
4409
3520
|
}
|
|
4410
3521
|
const key = parseKeyPath(line, start);
|
|
4411
|
-
if (!key) return
|
|
4412
|
-
if (line[key.end] !== "=") return
|
|
3522
|
+
if (!key) return;
|
|
3523
|
+
if (line[key.end] !== "=") return;
|
|
4413
3524
|
const scanned = scanValueLine(line, key.end + 1, 0);
|
|
4414
|
-
if (!scanned) return
|
|
3525
|
+
if (!scanned) return;
|
|
4415
3526
|
carryDepth = scanned.depth;
|
|
4416
3527
|
const full = [...section, ...key.value];
|
|
4417
3528
|
if (full[0] !== "projects") continue;
|
|
4418
|
-
if (full.length < 3) return
|
|
3529
|
+
if (full.length < 3) return;
|
|
4419
3530
|
if (full.length === 3 && full[2] === "trust_level") {
|
|
4420
|
-
if (carryDepth !== 0 || scanned.value === void 0) return
|
|
3531
|
+
if (carryDepth !== 0 || scanned.value === void 0) return;
|
|
4421
3532
|
const project = full[1];
|
|
4422
3533
|
const existing = entries.get(project);
|
|
4423
|
-
if (existing !== void 0 && existing !== scanned.value) return
|
|
3534
|
+
if (existing !== void 0 && existing !== scanned.value) return;
|
|
4424
3535
|
entries.set(project, scanned.value);
|
|
4425
3536
|
}
|
|
4426
3537
|
}
|
|
4427
|
-
if (carryDepth > 0) return
|
|
3538
|
+
if (carryDepth > 0) return;
|
|
4428
3539
|
return entries;
|
|
4429
3540
|
}
|
|
4430
|
-
/**
|
|
4431
|
-
* A linked worktree inherits trust from its main repository's entry (measured:
|
|
4432
|
-
* trusting the main repo path loads the worktree's project config). The
|
|
4433
|
-
* worktree's `.git` is a FILE whose `gitdir:` line names
|
|
4434
|
-
* `<main>/.git/worktrees/<name>`; the directory owning that `.git` is the
|
|
4435
|
-
* anchor to look up. Anything unreadable or shaped differently resolves false
|
|
4436
|
-
* — this route can only ADD trust, i.e. silence, never a false notice.
|
|
4437
|
-
*/
|
|
4438
3541
|
function mainRepositoryTrusted(gitRootDir, canonical) {
|
|
4439
3542
|
const gitPath = join(gitRootDir, ".git");
|
|
4440
3543
|
try {
|
|
@@ -4453,15 +3556,6 @@ function mainRepositoryTrusted(gitRootDir, canonical) {
|
|
|
4453
3556
|
return false;
|
|
4454
3557
|
}
|
|
4455
3558
|
}
|
|
4456
|
-
/**
|
|
4457
|
-
* The notice for a codex session about to run on a cwd whose
|
|
4458
|
-
* `.codex/config.toml` codex will ignore, or undefined when there is nothing
|
|
4459
|
-
* to say — no project config anywhere codex would look, the project is
|
|
4460
|
-
* trusted, or the situation cannot be established with certainty. Read-only
|
|
4461
|
-
* throughout: WorkerDeck never writes trust entries (adjacent to the auth red
|
|
4462
|
-
* lines — trusting a directory is the operator's decision, made in codex's
|
|
4463
|
-
* own prompt or by their own hand).
|
|
4464
|
-
*/
|
|
4465
3559
|
function untrustedProjectNotice(options) {
|
|
4466
3560
|
let cwd;
|
|
4467
3561
|
try {
|
|
@@ -4492,17 +3586,17 @@ function untrustedProjectNotice(options) {
|
|
|
4492
3586
|
return false;
|
|
4493
3587
|
}
|
|
4494
3588
|
});
|
|
4495
|
-
if (layers.length === 0) return
|
|
3589
|
+
if (layers.length === 0) return;
|
|
4496
3590
|
const homeConfigPath = join(options.codexHome, "config.toml");
|
|
4497
3591
|
let source = "";
|
|
4498
3592
|
try {
|
|
4499
3593
|
source = readFileSync(homeConfigPath, "utf8");
|
|
4500
3594
|
} catch (error) {
|
|
4501
|
-
if (error.code !== "ENOENT") return
|
|
3595
|
+
if (error.code !== "ENOENT") return;
|
|
4502
3596
|
}
|
|
4503
3597
|
const entries = parseProjectTrustEntries(source);
|
|
4504
|
-
if (!entries) return
|
|
4505
|
-
for (const value of entries.values()) if (value !== "trusted" && value !== "untrusted") return
|
|
3598
|
+
if (!entries) return;
|
|
3599
|
+
for (const value of entries.values()) if (value !== "trusted" && value !== "untrusted") return;
|
|
4506
3600
|
const canonical = /* @__PURE__ */ new Map();
|
|
4507
3601
|
for (const [key, value] of entries) {
|
|
4508
3602
|
let path = key;
|
|
@@ -4518,59 +3612,25 @@ function untrustedProjectNotice(options) {
|
|
|
4518
3612
|
if (entry !== void 0) return entry !== "trusted";
|
|
4519
3613
|
return !rootTrusted;
|
|
4520
3614
|
});
|
|
4521
|
-
if (ignored.length === 0) return
|
|
3615
|
+
if (ignored.length === 0) return;
|
|
4522
3616
|
const trustDir = gitRoot ?? cwd;
|
|
4523
3617
|
const configs = ignored.map((layer) => join(layer, ".codex", "config.toml"));
|
|
4524
3618
|
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}.`;
|
|
4525
3619
|
}
|
|
4526
3620
|
//#endregion
|
|
4527
3621
|
//#region src/engines/codex/runner.ts
|
|
4528
|
-
/**
|
|
4529
|
-
* thread/start's sandbox axis (string form) — our permission modes as codex
|
|
4530
|
-
* sandbox modes: `default` → read-only (reads run; any mutation is refused by
|
|
4531
|
-
* the OS sandbox and — with the ask policy below — escalates to a real
|
|
4532
|
-
* question), `acceptEdits` → workspace-write (in-workspace writes sail
|
|
4533
|
-
* through, the acceptEdits grant), `bypassPermissions` → danger-full-access.
|
|
4534
|
-
* `auto` rides the SAME sandbox as acceptEdits — it is not a wider grant, it
|
|
4535
|
-
* only moves *who answers* the approvals (see {@link APPROVALS_REVIEWER_BY_MODE}).
|
|
4536
|
-
*/
|
|
4537
3622
|
const THREAD_SANDBOX_BY_MODE = {
|
|
4538
3623
|
default: "read-only",
|
|
4539
3624
|
acceptEdits: "workspace-write",
|
|
4540
3625
|
auto: "workspace-write",
|
|
4541
3626
|
bypassPermissions: "danger-full-access"
|
|
4542
3627
|
};
|
|
4543
|
-
/**
|
|
4544
|
-
* turn/start's sandboxPolicy axis (object form — same policy, second shape).
|
|
4545
|
-
*
|
|
4546
|
-
* The `workspaceWrite` entries here are a SHAPE, not the whole policy: every
|
|
4547
|
-
* unstated field of that variant is serde-defaulted by the app-server, so
|
|
4548
|
-
* sending it bare silently overrides the operator's `[sandbox_workspace_write]`
|
|
4549
|
-
* — `network_access` back to false, `writable_roots` back to empty — on every
|
|
4550
|
-
* turn. {@link CodexRunner.#turnSandboxPolicy} restates those fields from
|
|
4551
|
-
* `config/read`; nothing else may send this map's `workspaceWrite` entries
|
|
4552
|
-
* directly.
|
|
4553
|
-
*/
|
|
4554
3628
|
const TURN_SANDBOX_BY_MODE = {
|
|
4555
3629
|
default: { type: "readOnly" },
|
|
4556
3630
|
acceptEdits: { type: "workspaceWrite" },
|
|
4557
3631
|
auto: { type: "workspaceWrite" },
|
|
4558
3632
|
bypassPermissions: { type: "dangerFullAccess" }
|
|
4559
3633
|
};
|
|
4560
|
-
/**
|
|
4561
|
-
* The approval axis, stated as the GRANULAR object on both thread/start and
|
|
4562
|
-
* turn/start — never the string vocabulary, deliberately and unconditionally:
|
|
4563
|
-
* measured against 0.146.0, plain `'untrusted'` never asked anything (a
|
|
4564
|
-
* sandbox-violating write was silently refused, a safe echo auto-approved),
|
|
4565
|
-
* while the granular flags make a blocked action a real server→client
|
|
4566
|
-
* question. Granular policies are gated on `capabilities.experimentalApi` at
|
|
4567
|
-
* initialize; WorkerDeck declares it always and keeps NO non-experimental
|
|
4568
|
-
* fallback — a future binary that rejects either gate fails loudly (see
|
|
4569
|
-
* {@link CodexRunner.#ensureThread}) instead of quietly not asking.
|
|
4570
|
-
*
|
|
4571
|
-
* `default`/`acceptEdits` ask (all flags on — the sandbox axis above already
|
|
4572
|
-
* decides *what needs asking*); `bypassPermissions` asks nothing, same shape.
|
|
4573
|
-
*/
|
|
4574
3634
|
const GRANULAR_ASK = { granular: {
|
|
4575
3635
|
sandbox_approval: true,
|
|
4576
3636
|
rules: true,
|
|
@@ -4585,11 +3645,6 @@ const GRANULAR_NEVER = { granular: {
|
|
|
4585
3645
|
request_permissions: false,
|
|
4586
3646
|
skill_approval: false
|
|
4587
3647
|
} };
|
|
4588
|
-
/**
|
|
4589
|
-
* Notifications whose meaning is scoped to ONE thread, and which are therefore
|
|
4590
|
-
* only ever read off the session's own. Everything else (items, deltas) is
|
|
4591
|
-
* accepted from any thread on the connection — see `#handleNotification`.
|
|
4592
|
-
*/
|
|
4593
3648
|
const THREAD_SCOPED_NOTIFICATIONS = new Set([
|
|
4594
3649
|
"turn/started",
|
|
4595
3650
|
"turn/completed",
|
|
@@ -4601,62 +3656,20 @@ const APPROVAL_POLICY_BY_MODE = {
|
|
|
4601
3656
|
auto: GRANULAR_ASK,
|
|
4602
3657
|
bypassPermissions: GRANULAR_NEVER
|
|
4603
3658
|
};
|
|
4604
|
-
/**
|
|
4605
|
-
* The THIRD approval axis — *who reviews*, independent of the sandbox axis and
|
|
4606
|
-
* the ask axis above. Codex's `approvalsReviewer` (thread/start and turn/start,
|
|
4607
|
-
* present since 0.146.0) routes every approval request either to the user
|
|
4608
|
-
* (`'user'`, codex's own default) or to `'auto_review'`: a prompted subagent
|
|
4609
|
-
* that gathers context and applies a risk framework before allowing or denying.
|
|
4610
|
-
* That is codex's "Approve for me" preset, and our `auto` mode is exactly it.
|
|
4611
|
-
*
|
|
4612
|
-
* Sent explicitly for EVERY mode rather than omitted for the default — a thread
|
|
4613
|
-
* inherits `approvalsReviewer` across turns ("this turn and subsequent turns"),
|
|
4614
|
-
* so leaving it unset would let a stale reviewer from an earlier turn survive a
|
|
4615
|
-
* mode switch back to a user-reviewed mode. Stating it every time makes the
|
|
4616
|
-
* mode the single source of truth.
|
|
4617
|
-
*
|
|
4618
|
-
* NOTE the asymmetry with the Claude engine's `auto`: that classifier is
|
|
4619
|
-
* operator-configurable (`autoMode.environment`, allow/soft_deny/hard_deny);
|
|
4620
|
-
* this reviewer has no configuration surface at all.
|
|
4621
|
-
*/
|
|
4622
3659
|
const APPROVALS_REVIEWER_BY_MODE = {
|
|
4623
3660
|
default: "user",
|
|
4624
3661
|
acceptEdits: "user",
|
|
4625
3662
|
auto: "auto_review",
|
|
4626
3663
|
bypassPermissions: "user"
|
|
4627
3664
|
};
|
|
4628
|
-
/** Fallback timeout for a pending approval nobody answers — the SessionRunner
|
|
4629
|
-
* default, so unattended codex sessions land the same way Claude ones do. */
|
|
4630
3665
|
const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
|
|
4631
|
-
/**
|
|
4632
|
-
* Tool name for codex's built-in `image_gen`. A stable string because it is a
|
|
4633
|
-
* rendering contract: both clients key an icon (and, where they can reach the
|
|
4634
|
-
* host filesystem, an inline preview) off it.
|
|
4635
|
-
*/
|
|
4636
3666
|
const CODEX_IMAGE_TOOL = "CodexImageGeneration";
|
|
4637
|
-
/**
|
|
4638
|
-
* Tool name for a spawned agent's anchor `tool_use` — the claude engine's
|
|
4639
|
-
* `Task` in this engine's vocabulary. Codex never sends such a call: the model's
|
|
4640
|
-
* `spawn_agent` surfaces only as the `subAgentActivity` marker item, so the
|
|
4641
|
-
* runner authors the call itself, because everything downstream is built on a
|
|
4642
|
-
* top-level `tool_use` existing — `terminalBlocks` absorbs a sidechain into the
|
|
4643
|
-
* call whose id its events carry as `parentToolUseId`, the takeover frames by
|
|
4644
|
-
* it, and `taskIdentity` labels it from the input's `subagent_type`. Not a new
|
|
4645
|
-
* wire idea, just a row: the same shape every other codex tool card uses.
|
|
4646
|
-
*/
|
|
4647
3667
|
const CODEX_AGENT_TOOL = "CodexAgent";
|
|
4648
|
-
/** Tool name for the model's collab-agent calls (`wait`, `sendInput`, …), the
|
|
4649
|
-
* `tool` field carried in the input. One name for the whole open axis rather
|
|
4650
|
-
* than a name per verb, so a future verb renders instead of vanishing. */
|
|
4651
3668
|
const CODEX_COLLAB_TOOL = "CodexCollab";
|
|
4652
|
-
/** An agent's name is its path's basename: '/root/date_one' → 'date_one'. */
|
|
4653
3669
|
function agentName(agentPath) {
|
|
4654
|
-
if (typeof agentPath !== "string") return
|
|
3670
|
+
if (typeof agentPath !== "string") return;
|
|
4655
3671
|
return agentPath.split("/").filter(Boolean).at(-1) || void 0;
|
|
4656
3672
|
}
|
|
4657
|
-
/** The collab card's input: the verb always, the rich fields only when codex
|
|
4658
|
-
* actually filled them (measured against 0.146.0 they arrive empty — the card
|
|
4659
|
-
* must not render five null columns to say 'wait'). */
|
|
4660
3673
|
function collabInput(item) {
|
|
4661
3674
|
return {
|
|
4662
3675
|
tool: item.tool,
|
|
@@ -4665,9 +3678,6 @@ function collabInput(item) {
|
|
|
4665
3678
|
...item.model ? { model: item.model } : {}
|
|
4666
3679
|
};
|
|
4667
3680
|
}
|
|
4668
|
-
/** A completed turn's answer, from its summary `items` page — the last
|
|
4669
|
-
* `agentMessage` text. For a sub-agent's thread this is the agent's report,
|
|
4670
|
-
* which is exactly what belongs in the anchor's `tool_result`. */
|
|
4671
3681
|
function turnReport(turn) {
|
|
4672
3682
|
const items = Array.isArray(turn.items) ? turn.items : [];
|
|
4673
3683
|
for (let index = items.length - 1; index >= 0; index--) {
|
|
@@ -4675,29 +3685,13 @@ function turnReport(turn) {
|
|
|
4675
3685
|
if (item?.type === "agentMessage" && typeof item.text === "string" && item.text) return item.text;
|
|
4676
3686
|
}
|
|
4677
3687
|
}
|
|
4678
|
-
/** Longest `result` worth putting in a tool card. The field is free-form and
|
|
4679
|
-
* undocumented; anything past this is assumed to be an encoded image rather
|
|
4680
|
-
* than a sentence, and encoded images do not go in the event log. */
|
|
4681
3688
|
const MAX_IMAGE_RESULT_CHARS = 512;
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
*
|
|
4686
|
-
* Two properties fall out of that and both are load-bearing: codex reports the
|
|
4687
|
-
* same `savedPath` on the progress item and again on the completed one, so a
|
|
4688
|
-
* derived id makes the second emission a no-op instead of a duplicate row; and
|
|
4689
|
-
* a session rebuilt from a snapshot re-derives the same ids, so a client's
|
|
4690
|
-
* cached URL still resolves after a park/restore.
|
|
4691
|
-
*/
|
|
3689
|
+
function shortResult(result) {
|
|
3690
|
+
return result.length > 0 && result.length <= MAX_IMAGE_RESULT_CHARS && !result.startsWith("data:");
|
|
3691
|
+
}
|
|
4692
3692
|
function producedFileId(path) {
|
|
4693
3693
|
return createHash("sha256").update(path).digest("hex").slice(0, 32);
|
|
4694
3694
|
}
|
|
4695
|
-
/** Media type from the extension, for the handful a client renders inline.
|
|
4696
|
-
* Undefined for everything else — the route sniffs, and guessing here is how a
|
|
4697
|
-
* text file ends up labelled `image/png`. */
|
|
4698
|
-
function producedMediaType(path) {
|
|
4699
|
-
return PRODUCED_MEDIA_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()];
|
|
4700
|
-
}
|
|
4701
3695
|
const PRODUCED_MEDIA_TYPES = {
|
|
4702
3696
|
png: "image/png",
|
|
4703
3697
|
jpg: "image/jpeg",
|
|
@@ -4707,12 +3701,9 @@ const PRODUCED_MEDIA_TYPES = {
|
|
|
4707
3701
|
svg: "image/svg+xml",
|
|
4708
3702
|
pdf: "application/pdf"
|
|
4709
3703
|
};
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
* `enabled` defaults to true — an entry codex listed without the field is one it
|
|
4714
|
-
* considers live, and defaulting to false would hide working skills.
|
|
4715
|
-
*/
|
|
3704
|
+
function producedMediaType(path) {
|
|
3705
|
+
return PRODUCED_MEDIA_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()];
|
|
3706
|
+
}
|
|
4716
3707
|
function skillInfo(skill) {
|
|
4717
3708
|
return {
|
|
4718
3709
|
name: skill.name,
|
|
@@ -4724,19 +3715,6 @@ function skillInfo(skill) {
|
|
|
4724
3715
|
enabled: skill.enabled !== false
|
|
4725
3716
|
};
|
|
4726
3717
|
}
|
|
4727
|
-
/**
|
|
4728
|
-
* Codex's MCP status → the protocol's, which is Claude Code's vocabulary
|
|
4729
|
-
* ('connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled').
|
|
4730
|
-
*
|
|
4731
|
-
* Two inputs, and the auth one wins where it applies: a server that started
|
|
4732
|
-
* fine but has no credential is *needs-auth*, not connected, because that is
|
|
4733
|
-
* the thing the operator has to act on. `notLoggedIn` is the only auth value
|
|
4734
|
-
* that means "unusable" — `unsupported` is the normal answer for a stdio server
|
|
4735
|
-
* that has no auth concept at all.
|
|
4736
|
-
*
|
|
4737
|
-
* A server with no startup notification yet is 'pending', not 'connected':
|
|
4738
|
-
* `mcpServerStatus/list` alone only proves it is *configured*.
|
|
4739
|
-
*/
|
|
4740
3718
|
function mcpStatusOf(authStatus, update, hasTools) {
|
|
4741
3719
|
if (update?.status === "failed") return update.failureReason === "reauthenticationRequired" ? "needs-auth" : "failed";
|
|
4742
3720
|
if (update?.status === "cancelled") return "failed";
|
|
@@ -4745,7 +3723,6 @@ function mcpStatusOf(authStatus, update, hasTools) {
|
|
|
4745
3723
|
if (hasTools) return "connected";
|
|
4746
3724
|
return "pending";
|
|
4747
3725
|
}
|
|
4748
|
-
/** One `mcpServerStatus/list` entry as the protocol states it. */
|
|
4749
3726
|
function mcpServerInfo(server, update) {
|
|
4750
3727
|
const tools = Object.entries(server.tools ?? {}).flatMap(([key, tool]) => {
|
|
4751
3728
|
if (!tool) return [];
|
|
@@ -4772,55 +3749,26 @@ function mcpServerInfo(server, update) {
|
|
|
4772
3749
|
...tools.length > 0 ? { tools } : {}
|
|
4773
3750
|
};
|
|
4774
3751
|
}
|
|
4775
|
-
/** What the card shows while the picture is being made, and after. `savedPath`
|
|
4776
|
-
* only exists once it lands — a client keys its preview off it, so it is a
|
|
4777
|
-
* field rather than a sentence in the result text. */
|
|
4778
3752
|
function imageGenerationInput(item) {
|
|
4779
3753
|
return {
|
|
4780
3754
|
...item.revisedPrompt ? { prompt: item.revisedPrompt } : {},
|
|
4781
3755
|
...item.savedPath ? { savedPath: item.savedPath } : {}
|
|
4782
3756
|
};
|
|
4783
3757
|
}
|
|
4784
|
-
/**
|
|
4785
|
-
* The experimental per-request decision list, normalized to names: a string
|
|
4786
|
-
* entry is its own name, a structured entry (`{acceptWithExecpolicyAmendment:
|
|
4787
|
-
* …}`) is named by its key. Undefined = the request stated no list and the
|
|
4788
|
-
* channel's schema enum applies. Present only under `experimentalApi: true` —
|
|
4789
|
-
* which WorkerDeck always declares.
|
|
4790
|
-
*/
|
|
4791
3758
|
function offeredDecisions(params) {
|
|
4792
3759
|
const raw = params?.availableDecisions;
|
|
4793
|
-
if (!Array.isArray(raw)) return
|
|
3760
|
+
if (!Array.isArray(raw)) return;
|
|
4794
3761
|
const names = /* @__PURE__ */ new Set();
|
|
4795
3762
|
for (const entry of raw) if (typeof entry === "string") names.add(entry);
|
|
4796
3763
|
else if (entry && typeof entry === "object") for (const key of Object.keys(entry)) names.add(key);
|
|
4797
3764
|
return names.size > 0 ? names : void 0;
|
|
4798
3765
|
}
|
|
4799
|
-
/**
|
|
4800
|
-
* Decision picking for the `{decision: …}` channels (commandExecution,
|
|
4801
|
-
* fileChange), honoring the request's own `availableDecisions`:
|
|
4802
|
-
*
|
|
4803
|
-
* - allow → 'accept' when offered (or when no list was stated). A request
|
|
4804
|
-
* offering only the broader accepts ('acceptForSession',
|
|
4805
|
-
* 'acceptWithExecpolicyAmendment') yields undefined: a one-shot allow must
|
|
4806
|
-
* not be silently widened into a session-wide or persistent policy grant, so
|
|
4807
|
-
* the caller answers with the denial and says why.
|
|
4808
|
-
* - deny → 'decline', always: the response schema declares it unconditionally,
|
|
4809
|
-
* and it was verified live against 0.146.0 answering a request whose
|
|
4810
|
-
* availableDecisions omitted it — the turn completed cleanly. The list's job
|
|
4811
|
-
* is to gate the accept variants, not to take "no, but keep going" away
|
|
4812
|
-
* (its own alternative, 'cancel', would interrupt the whole turn).
|
|
4813
|
-
* - deny+interrupt → 'cancel' (codex's deny-and-interrupt) when offered;
|
|
4814
|
-
* otherwise 'decline', and the caller interrupts the turn itself.
|
|
4815
|
-
*/
|
|
4816
3766
|
function pickDecision(behavior, interrupt, offered) {
|
|
4817
3767
|
const has = (name) => !offered || offered.has(name);
|
|
4818
3768
|
if (behavior === "allow") return has("accept") ? "accept" : void 0;
|
|
4819
3769
|
if (interrupt && has("cancel")) return "cancel";
|
|
4820
3770
|
return "decline";
|
|
4821
3771
|
}
|
|
4822
|
-
/** Codex `requestUserInput` questions in the AskUserQuestion wire shape both
|
|
4823
|
-
* clients already render (QuestionPrompt / QuestionPromptView). */
|
|
4824
3772
|
function userQuestionsFromCodex(questions) {
|
|
4825
3773
|
return questions.map((question) => ({
|
|
4826
3774
|
question: question.question,
|
|
@@ -4831,18 +3779,6 @@ function userQuestionsFromCodex(questions) {
|
|
|
4831
3779
|
}))
|
|
4832
3780
|
}));
|
|
4833
3781
|
}
|
|
4834
|
-
/**
|
|
4835
|
-
* The text of a history `userMessage` item: its content entries' text parts
|
|
4836
|
-
* joined.
|
|
4837
|
-
*
|
|
4838
|
-
* Image parts have no replayable representation — the bytes went to the model,
|
|
4839
|
-
* not into the rollout we can render from — so they are named rather than
|
|
4840
|
-
* dropped. A prompt that was *only* an image used to produce an empty string,
|
|
4841
|
-
* which the caller read as "nothing to replay" and skipped: the turn lost its
|
|
4842
|
-
* user row and, with it, the prompt mark the scrubber navigates by, so a resumed
|
|
4843
|
-
* thread had answers with no visible question. A word in place of the picture is
|
|
4844
|
-
* a smaller lie than a turn that never happened.
|
|
4845
|
-
*/
|
|
4846
3782
|
function historyUserText(item) {
|
|
4847
3783
|
if (!Array.isArray(item.content)) return "";
|
|
4848
3784
|
let images = 0;
|
|
@@ -4855,9 +3791,6 @@ function historyUserText(item) {
|
|
|
4855
3791
|
if (text) return text;
|
|
4856
3792
|
return images > 0 ? `[${images === 1 ? "image" : `${images} images`}]` : "";
|
|
4857
3793
|
}
|
|
4858
|
-
/** The AskUserQuestion answer convention (question text → chosen label(s),
|
|
4859
|
-
* comma-joined) mapped back to codex's id-keyed shape. Questions the client
|
|
4860
|
-
* did not answer are absent, not empty. */
|
|
4861
3794
|
function codexAnswers(questions, answers) {
|
|
4862
3795
|
const out = {};
|
|
4863
3796
|
for (const question of questions) {
|
|
@@ -4866,7 +3799,6 @@ function codexAnswers(questions, answers) {
|
|
|
4866
3799
|
}
|
|
4867
3800
|
return out;
|
|
4868
3801
|
}
|
|
4869
|
-
/** The two channels whose response is `{decision: …}` share their pick logic. */
|
|
4870
3802
|
function decisionChannel(describe, itemId) {
|
|
4871
3803
|
return {
|
|
4872
3804
|
describe,
|
|
@@ -4887,11 +3819,6 @@ function decisionChannel(describe, itemId) {
|
|
|
4887
3819
|
}
|
|
4888
3820
|
};
|
|
4889
3821
|
}
|
|
4890
|
-
/**
|
|
4891
|
-
* The ask channels, wired to the permission surface. Anything not listed here
|
|
4892
|
-
* still gets a JSON-RPC -32601 — never a hang (an unanswered server request
|
|
4893
|
-
* wedges the turn).
|
|
4894
|
-
*/
|
|
4895
3822
|
const APPROVAL_CHANNELS = {
|
|
4896
3823
|
"item/commandExecution/requestApproval": decisionChannel((raw) => {
|
|
4897
3824
|
const params = raw;
|
|
@@ -4982,129 +3909,48 @@ const APPROVAL_CHANNELS = {
|
|
|
4982
3909
|
deny: (_raw, interrupt) => ({ response: { action: interrupt ? "cancel" : "decline" } })
|
|
4983
3910
|
}
|
|
4984
3911
|
};
|
|
4985
|
-
/**
|
|
4986
|
-
* Name a subscription window by its measured length, so codex's positional
|
|
4987
|
-
* windows land in the protocol's named vocabulary. The two names clients
|
|
4988
|
-
* already understand are exact matches for codex's durations (300 min = 5h,
|
|
4989
|
-
* 10080 min = 7d); anything else keeps a self-describing key rather than
|
|
4990
|
-
* borrowing a name that would size it wrongly.
|
|
4991
|
-
*/
|
|
4992
3912
|
function rateLimitWindowName(minutes) {
|
|
4993
|
-
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return
|
|
3913
|
+
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return;
|
|
4994
3914
|
if (minutes === 300) return "five_hour";
|
|
4995
3915
|
if (minutes === 10080) return "seven_day";
|
|
4996
3916
|
return `window_${minutes}m`;
|
|
4997
3917
|
}
|
|
4998
|
-
/**
|
|
4999
|
-
* The Codex engine, over the binary's `app-server` JSON-RPC surface: ONE
|
|
5000
|
-
* `codex app-server` child per *session* (spawned lazily, held across turns),
|
|
5001
|
-
* streaming `item/agentMessage/delta` and the reasoning deltas token-by-token
|
|
5002
|
-
* (`streaming: 'token'`). Follows `SessionRunner`'s event-log/seq/status
|
|
5003
|
-
* discipline with `AiSdkRunner`'s turn-chain (one turn at a time; sendMessage
|
|
5004
|
-
* queues). The first codex transport was `codex exec --experimental-json` (one
|
|
5005
|
-
* child per turn) — retired because its JSONL carries no partial messages, so
|
|
5006
|
-
* a turn could never stream.
|
|
5007
|
-
*
|
|
5008
|
-
* A dead child is a failed *turn*, not a failed session: the thread persists
|
|
5009
|
-
* on disk, the connection is dropped, and the next message spawns a fresh
|
|
5010
|
-
* child that `thread/resume`s the same thread id.
|
|
5011
|
-
*/
|
|
5012
3918
|
var CodexRunner = class {
|
|
5013
3919
|
id;
|
|
5014
3920
|
createdAt;
|
|
5015
3921
|
#config;
|
|
5016
|
-
/** {@link CodexRunnerConfig.cwd}, checked once in the constructor. */
|
|
5017
3922
|
#cwd;
|
|
5018
|
-
#
|
|
3923
|
+
#log = new EventLog();
|
|
5019
3924
|
#subscribers = new SubscriberSet();
|
|
5020
|
-
#seq = 0;
|
|
5021
|
-
/**
|
|
5022
|
-
* Latest context-window reading, retained from the last `context_usage` this
|
|
5023
|
-
* runner emitted so `GET /sessions` can answer it without an attach — see
|
|
5024
|
-
* `SessionInfo.contextUsage`. Folded in the emit path, so it is by
|
|
5025
|
-
* construction the same number the transcript last drew.
|
|
5026
|
-
*/
|
|
5027
|
-
#contextUsage;
|
|
5028
|
-
#activityCount = 0;
|
|
5029
|
-
/**
|
|
5030
|
-
* Seq of the latest `conversation_reset` event, 0 when none. The log itself is
|
|
5031
|
-
* never truncated — it still carries the state-bearing events (`capabilities`,
|
|
5032
|
-
* `system_init`, …) a fresh attacher depends on and which are not re-emitted —
|
|
5033
|
-
* but `subscribe()` skips transcript *content* strictly below this mark, so a
|
|
5034
|
-
* replay does not resurrect a cleared conversation. A later reset supersedes
|
|
5035
|
-
* an earlier one by overwriting it.
|
|
5036
|
-
*/
|
|
5037
|
-
#resetSeq = 0;
|
|
5038
3925
|
#status = "starting";
|
|
3926
|
+
#statusDetail;
|
|
5039
3927
|
#sdkSessionId;
|
|
5040
3928
|
#model;
|
|
5041
3929
|
#permissionMode;
|
|
5042
3930
|
#reasoningEffort;
|
|
5043
|
-
/** What the binary said the profile's defaults resolve to (thread/start
|
|
5044
|
-
* response) — lets `setModel(undefined)` mean "back to the default" even
|
|
5045
|
-
* though a turn/start override persists for subsequent turns. */
|
|
5046
3931
|
#resolvedModel;
|
|
5047
|
-
/** Last reported ChatGPT plan, so `plan_info` is emitted once per change. */
|
|
5048
3932
|
#planType;
|
|
5049
3933
|
#resolvedEffort;
|
|
5050
3934
|
#queue = [];
|
|
5051
3935
|
#turnChain = Promise.resolve();
|
|
5052
3936
|
#activeTurn;
|
|
5053
3937
|
#connection;
|
|
5054
|
-
/** Per-child, from `config/read`; undefined = read failed, send the bare shape. */
|
|
5055
3938
|
#workspaceWrite;
|
|
5056
3939
|
#threadLoaded = false;
|
|
5057
3940
|
#numTurns = 0;
|
|
5058
3941
|
#totalCostUsd;
|
|
5059
|
-
#lastActivityAt;
|
|
5060
3942
|
#started = false;
|
|
5061
3943
|
#closed = false;
|
|
5062
|
-
/** Session temp dir for image attachments (`localImage` takes host paths). */
|
|
5063
3944
|
#imageDir;
|
|
5064
|
-
/** Pending server→client approvals, keyed by the surfaced request id. */
|
|
5065
3945
|
#approvals = /* @__PURE__ */ new Map();
|
|
5066
|
-
/** True from start() until the resume backfill (the turn chain's first link)
|
|
5067
|
-
* settles — while set, sendMessage defers its user_message echo behind the
|
|
5068
|
-
* chain so a new turn can never precede or interleave the replayed history. */
|
|
5069
3946
|
#backfillPending = false;
|
|
5070
|
-
/** The resumed thread's prior turns, stashed by {@link #ensureThread} from
|
|
5071
|
-
* the ONE thread/resume the backfill consumes (`partial` = the response's
|
|
5072
|
-
* turnsBackwardsCursor said older turns exist beyond this page). A mid-life
|
|
5073
|
-
* reconnect also goes through thread/resume, but with no backfill pending
|
|
5074
|
-
* nothing is stashed — history is never replayed twice. */
|
|
5075
3947
|
#resumedHistory;
|
|
5076
|
-
/** Set around history replay: {@link #emit} stamps `replay: true` onto the
|
|
5077
|
-
* message events the live item mapping produces. */
|
|
5078
3948
|
#replayingHistory = false;
|
|
5079
|
-
/** Last `skills` payload emitted, serialized — the comparison that keeps a
|
|
5080
|
-
* `skills/changed` storm (the watcher fires per touched file) from filling
|
|
5081
|
-
* the event log with identical lists. */
|
|
5082
3949
|
#skillsFingerprint;
|
|
5083
|
-
/** In-flight `skills/list`, so a burst of `skills/changed` makes one call.
|
|
5084
|
-
* The pending promise is reused rather than queued: the request has no
|
|
5085
|
-
* arguments, so a second one would ask the same question. */
|
|
5086
3950
|
#skillsRefresh;
|
|
5087
|
-
/** Host paths already announced via `file_produced`, so the same picture
|
|
5088
|
-
* reported on both the progress and the completed item registers once. */
|
|
5089
3951
|
#producedPaths = /* @__PURE__ */ new Set();
|
|
5090
|
-
/** Per-server liveness, accumulated from `mcpServer/startupStatus/updated`.
|
|
5091
|
-
* `mcpServerStatus/list` does not carry a status field at all, so without
|
|
5092
|
-
* this every server would read as "configured" and never as up or down. */
|
|
5093
3952
|
#mcpStatus = /* @__PURE__ */ new Map();
|
|
5094
|
-
/** The spawned agents, keyed by their thread ids — the attribution table
|
|
5095
|
-
* behind `parentToolUseId` and the rollup behind `info().subagents`. Runner-
|
|
5096
|
-
* level, not per-turn: an agent's thread outlives the root turn that spawned
|
|
5097
|
-
* it, and only the child process dying (or the session closing) ends them
|
|
5098
|
-
* all — see the module doc in `subagents.ts`. */
|
|
5099
3953
|
#agents = new CodexAgentTracker();
|
|
5100
|
-
/** Threads that belonged to a conversation this session has cleared — the
|
|
5101
|
-
* agents that were still running when it happened. Their notifications keep
|
|
5102
|
-
* arriving on the same connection (a clear does not interrupt them and does
|
|
5103
|
-
* not drop the child), and without this {@link CodexRunner.#agentFor} would
|
|
5104
|
-
* mint them a fresh anchor and stream the cleared conversation's agent work
|
|
5105
|
-
* into the new one. Never pruned: it is a handful of uuids for the session's
|
|
5106
|
-
* life, and a late report from a long-dead agent is exactly what it exists to
|
|
5107
|
-
* catch. */
|
|
5108
3954
|
#clearedThreads = /* @__PURE__ */ new Set();
|
|
5109
3955
|
constructor(config, id = randomUUID()) {
|
|
5110
3956
|
const mode = config.permissionMode ?? "default";
|
|
@@ -5120,15 +3966,8 @@ var CodexRunner = class {
|
|
|
5120
3966
|
this.id = id;
|
|
5121
3967
|
this.createdAt = Date.now();
|
|
5122
3968
|
}
|
|
5123
|
-
/** The complete child environment — spawn env replaces process.env wholesale,
|
|
5124
|
-
* so this must carry everything a shell would, with the profile's CODEX_HOME
|
|
5125
|
-
* pin winning over operator env. */
|
|
5126
3969
|
#childEnv() {
|
|
5127
|
-
|
|
5128
|
-
const env = {};
|
|
5129
|
-
for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
|
|
5130
|
-
if (this.#config.codexHome) env.CODEX_HOME = this.#config.codexHome;
|
|
5131
|
-
return env;
|
|
3970
|
+
return codexChildEnv(this.#config.env ?? process.env, this.#config.codexHome);
|
|
5132
3971
|
}
|
|
5133
3972
|
get status() {
|
|
5134
3973
|
return this.#status;
|
|
@@ -5137,7 +3976,7 @@ var CodexRunner = class {
|
|
|
5137
3976
|
return this.#sdkSessionId;
|
|
5138
3977
|
}
|
|
5139
3978
|
get lastSeq() {
|
|
5140
|
-
return this.#seq;
|
|
3979
|
+
return this.#log.seq;
|
|
5141
3980
|
}
|
|
5142
3981
|
get pendingApprovals() {
|
|
5143
3982
|
return [...this.#approvals.values()].map((pending) => pending.request);
|
|
@@ -5155,36 +3994,22 @@ var CodexRunner = class {
|
|
|
5155
3994
|
permissionMode: this.#permissionMode,
|
|
5156
3995
|
canBypassPermissions: true,
|
|
5157
3996
|
createdAt: this.createdAt,
|
|
5158
|
-
lastSeq: this.#seq,
|
|
5159
|
-
activityCount: this.#activityCount,
|
|
5160
|
-
|
|
3997
|
+
lastSeq: this.#log.seq,
|
|
3998
|
+
activityCount: this.#log.activityCount,
|
|
3999
|
+
proseCount: this.#log.proseCount,
|
|
4000
|
+
contextUsage: this.#log.contextUsage,
|
|
5161
4001
|
pendingPermissionCount: this.#approvals.size,
|
|
5162
4002
|
meta: this.#config.meta,
|
|
5163
4003
|
scope: this.#config.scope,
|
|
5164
|
-
title: this.#
|
|
4004
|
+
title: sessionTitle(this.#config),
|
|
5165
4005
|
totalCostUsd: this.#totalCostUsd,
|
|
5166
4006
|
numTurns: this.#numTurns || void 0,
|
|
5167
|
-
lastActivityAt: this.#lastActivityAt,
|
|
4007
|
+
lastActivityAt: this.#log.lastActivityAt,
|
|
5168
4008
|
subagents: this.#agents.list()
|
|
5169
4009
|
};
|
|
5170
4010
|
}
|
|
5171
|
-
#title() {
|
|
5172
|
-
const metaTitle = this.#config.meta?.title;
|
|
5173
|
-
if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
|
|
5174
|
-
const prompt = this.#config.prompt;
|
|
5175
|
-
if (!prompt) return void 0;
|
|
5176
|
-
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
5177
|
-
}
|
|
5178
|
-
/** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
|
|
5179
|
-
* it (undefined) restores the derived title. The engine is never told. */
|
|
5180
4011
|
setTitle(title) {
|
|
5181
|
-
|
|
5182
|
-
if (title) meta.title = title;
|
|
5183
|
-
else delete meta.title;
|
|
5184
|
-
this.#config = {
|
|
5185
|
-
...this.#config,
|
|
5186
|
-
meta
|
|
5187
|
-
};
|
|
4012
|
+
this.#config = withTitle(this.#config, title);
|
|
5188
4013
|
}
|
|
5189
4014
|
start() {
|
|
5190
4015
|
if (this.#started) return this.#turnChain;
|
|
@@ -5198,20 +4023,6 @@ var CodexRunner = class {
|
|
|
5198
4023
|
if (!this.#config.prompt && !this.#config.resume) this.#probeSkills();
|
|
5199
4024
|
return this.#turnChain;
|
|
5200
4025
|
}
|
|
5201
|
-
/**
|
|
5202
|
-
* One-time transcript notice for the codex trust gap: a `default`-mode
|
|
5203
|
-
* session (read-only sandbox) on an untrusted cwd has its
|
|
5204
|
-
* `.codex/config.toml` — MCP servers included — silently ignored, and the
|
|
5205
|
-
* app-server surface has no trust prompt to say so (the TUI's prompt is
|
|
5206
|
-
* where the entry normally gets written). `acceptEdits`/`bypassPermissions`
|
|
5207
|
-
* sessions are exempt because their `thread/start` (workspace-write /
|
|
5208
|
-
* danger-full-access sandbox) writes the trust entry itself and loads the
|
|
5209
|
-
* config — measured against 0.146.0 and 0.149.0; a notice there would be
|
|
5210
|
-
* false. Emitted as `session_error`, which both clients render as an inline
|
|
5211
|
-
* notice while the session keeps running (the backfill-history precedent),
|
|
5212
|
-
* so nothing new rides the wire. Every degrade path is silence: a false
|
|
5213
|
-
* warning on a trusted project is worse than a missed one.
|
|
5214
|
-
*/
|
|
5215
4026
|
#warnUntrustedProject() {
|
|
5216
4027
|
if (this.#permissionMode !== "default") return;
|
|
5217
4028
|
try {
|
|
@@ -5229,20 +4040,6 @@ var CodexRunner = class {
|
|
|
5229
4040
|
});
|
|
5230
4041
|
} catch {}
|
|
5231
4042
|
}
|
|
5232
|
-
/**
|
|
5233
|
-
* List skills over a **throwaway** connection, for a session with nothing else
|
|
5234
|
-
* to do yet.
|
|
5235
|
-
*
|
|
5236
|
-
* `skills/list` needs a live child but not a thread, so this spawns one, asks,
|
|
5237
|
-
* and closes it — rather than bringing up the session's own child early and
|
|
5238
|
-
* leaving a codex process parked behind every session someone created and
|
|
5239
|
-
* never typed into. The session's real connection re-lists when it arrives;
|
|
5240
|
-
* the fingerprint compare in {@link #refreshSkills} makes that a no-op.
|
|
5241
|
-
*
|
|
5242
|
-
* Entirely best-effort and never awaited: a missing binary, a failed spawn or
|
|
5243
|
-
* a rejected handshake here must not turn a session that has not started into
|
|
5244
|
-
* a session that failed.
|
|
5245
|
-
*/
|
|
5246
4043
|
async #probeSkills() {
|
|
5247
4044
|
let connection;
|
|
5248
4045
|
try {
|
|
@@ -5253,28 +4050,10 @@ var CodexRunner = class {
|
|
|
5253
4050
|
connection?.close();
|
|
5254
4051
|
}
|
|
5255
4052
|
}
|
|
5256
|
-
/**
|
|
5257
|
-
* A handshaken child that is **not** the session's — for the questions a
|
|
5258
|
-
* client can ask before the session has anything to run (its skills, its MCP
|
|
5259
|
-
* servers). The caller owns it and must close it.
|
|
5260
|
-
*
|
|
5261
|
-
* No onNotification/onRequest/onClose wiring on purpose: this child answers
|
|
5262
|
-
* one question and goes away, so its notifications are noise and its death is
|
|
5263
|
-
* not the session's problem. The alternative — bringing the session's real
|
|
5264
|
-
* child up early — would park a codex process behind every session someone
|
|
5265
|
-
* created and never typed into.
|
|
5266
|
-
*/
|
|
5267
4053
|
async #openScratchConnection() {
|
|
5268
4054
|
const connection = this.#config.connectFn({ env: this.#childEnv() });
|
|
5269
4055
|
try {
|
|
5270
|
-
await connection.request("initialize",
|
|
5271
|
-
clientInfo: {
|
|
5272
|
-
name: "workerdeck",
|
|
5273
|
-
title: "WorkerDeck",
|
|
5274
|
-
version: `protocol-${PROTOCOL_VERSION}`
|
|
5275
|
-
},
|
|
5276
|
-
capabilities: { experimentalApi: true }
|
|
5277
|
-
});
|
|
4056
|
+
await connection.request("initialize", INITIALIZE_PARAMS);
|
|
5278
4057
|
connection.notify("initialized");
|
|
5279
4058
|
return connection;
|
|
5280
4059
|
} catch (error) {
|
|
@@ -5309,12 +4088,6 @@ var CodexRunner = class {
|
|
|
5309
4088
|
this.#queue.push({ input });
|
|
5310
4089
|
this.#scheduleTurn();
|
|
5311
4090
|
}
|
|
5312
|
-
/**
|
|
5313
|
-
* App-server input for a message with attachments: images land in a session
|
|
5314
|
-
* temp dir and travel as `localImage` host paths, text files inline into the
|
|
5315
|
-
* prompt in the shared named envelope, PDF has no representation (the
|
|
5316
|
-
* gateway's 415 normally refuses it first).
|
|
5317
|
-
*/
|
|
5318
4091
|
#buildInput(text, attachments) {
|
|
5319
4092
|
const parts = [];
|
|
5320
4093
|
for (const attachment of attachments) {
|
|
@@ -5347,8 +4120,6 @@ var CodexRunner = class {
|
|
|
5347
4120
|
});
|
|
5348
4121
|
return parts;
|
|
5349
4122
|
}
|
|
5350
|
-
/** Resolve a pending approval. Returns false if the id is unknown (e.g.
|
|
5351
|
-
* timed out, or already settled by codex itself). */
|
|
5352
4123
|
resolvePermission(requestId, decision) {
|
|
5353
4124
|
const pending = this.#approvals.get(requestId);
|
|
5354
4125
|
if (!pending) return false;
|
|
@@ -5364,42 +4135,12 @@ var CodexRunner = class {
|
|
|
5364
4135
|
await this.#interruptTurn();
|
|
5365
4136
|
await this.#turnChain;
|
|
5366
4137
|
}
|
|
5367
|
-
/**
|
|
5368
|
-
* Reset the conversation: a **fresh thread on the same session**.
|
|
5369
|
-
*
|
|
5370
|
-
* Codex has no clear/reset RPC — `thread/compact/start` summarises and
|
|
5371
|
-
* continues, `thread/fork` makes a second thread, and neither is "same
|
|
5372
|
-
* session, empty context". So the analog is to stop resuming the old thread
|
|
5373
|
-
* and start a new one, which is the path a dead child already takes minus the
|
|
5374
|
-
* resume. The old thread is NOT deleted: it stays in CODEX_HOME and stays
|
|
5375
|
-
* resumable from `GET /sdk-sessions`.
|
|
5376
|
-
*
|
|
5377
|
-
* Two things it does on the way through, both mirroring the Claude engine's
|
|
5378
|
-
* SDK-driven reset (`engines/claude/runner.ts`):
|
|
5379
|
-
*
|
|
5380
|
-
* 1. **The new thread id is adopted before `conversation_reset` is emitted**,
|
|
5381
|
-
* whenever a child is already up — the eager `thread/start` costs no
|
|
5382
|
-
* tokens and no model call, and it is what keeps the dormant record from
|
|
5383
|
-
* ever naming the conversation that was just cleared. With no child there
|
|
5384
|
-
* is nothing to start against and the id is simply dropped; the parking
|
|
5385
|
-
* service treats a resumable session with no engine session id as one with
|
|
5386
|
-
* nothing to come back to, and forgets the stale record.
|
|
5387
|
-
* 2. **The context reading is retired**, in `#emit`'s `conversation_reset`
|
|
5388
|
-
* arm. Codex cannot re-poll it the way Claude does — the only source is
|
|
5389
|
-
* `thread/tokenUsage/updated`, which arrives *during* a turn — so there is
|
|
5390
|
-
* no reading at all until the next turn runs, and the protocol's rule
|
|
5391
|
-
* applies: render nothing rather than a stale ring or a 0%.
|
|
5392
|
-
*
|
|
5393
|
-
* The turn counter stays monotonic across this, on purpose (it is an unread
|
|
5394
|
-
* cursor, not an item count), and so does `activityCount` — `#emit` owns both.
|
|
5395
|
-
*/
|
|
5396
4138
|
async clearContext() {
|
|
5397
4139
|
if (this.#closed) throw new Error("session is closed");
|
|
5398
4140
|
const run = this.#turnChain.then(() => this.#clearNow());
|
|
5399
4141
|
this.#turnChain = run.then(() => void 0, () => void 0);
|
|
5400
4142
|
await run;
|
|
5401
4143
|
}
|
|
5402
|
-
/** The clear itself, only ever called as a turn-chain link. */
|
|
5403
4144
|
async #clearNow() {
|
|
5404
4145
|
if (this.#closed) throw new Error("session is closed");
|
|
5405
4146
|
const previousThread = this.#sdkSessionId;
|
|
@@ -5424,8 +4165,6 @@ var CodexRunner = class {
|
|
|
5424
4165
|
sdkSessionId: this.#sdkSessionId
|
|
5425
4166
|
});
|
|
5426
4167
|
}
|
|
5427
|
-
/** Address the in-flight turn only (no approval sweep) — also the follow-up
|
|
5428
|
-
* for a deny+interrupt whose wire decision couldn't carry the interrupt. */
|
|
5429
4168
|
async #interruptTurn() {
|
|
5430
4169
|
const active = this.#activeTurn;
|
|
5431
4170
|
const connection = this.#connection;
|
|
@@ -5494,37 +4233,15 @@ var CodexRunner = class {
|
|
|
5494
4233
|
});
|
|
5495
4234
|
this.#setStatus("closed");
|
|
5496
4235
|
}
|
|
5497
|
-
/** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
|
|
5498
|
-
* "show everything" on one row, so a per-runner seq index would be a map
|
|
5499
|
-
* maintained on every emit to save a walk nobody makes twice a minute. */
|
|
5500
4236
|
eventAt(seq) {
|
|
5501
|
-
return this.#
|
|
4237
|
+
return this.#log.at(seq);
|
|
5502
4238
|
}
|
|
5503
4239
|
subscribe(listener, afterSeq = 0, options) {
|
|
5504
|
-
return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
|
|
4240
|
+
return this.#subscribers.subscribe(this.#log.events, listener, afterSeq, options, this.#log.resetSeq);
|
|
5505
4241
|
}
|
|
5506
4242
|
#scheduleTurn() {
|
|
5507
4243
|
this.#turnChain = this.#turnChain.then(() => this.#runTurn());
|
|
5508
4244
|
}
|
|
5509
|
-
/**
|
|
5510
|
-
* Read `[sandbox_workspace_write]` as codex resolves it for this session's
|
|
5511
|
-
* cwd, once per child, so {@link CodexRunner.#turnSandboxPolicy} can restate
|
|
5512
|
-
* it verbatim.
|
|
5513
|
-
*
|
|
5514
|
-
* Why this exists at all: `turn/start`'s object-form sandbox policy is
|
|
5515
|
-
* serde-defaulted field by field, so `{type: 'workspaceWrite'}` bare means
|
|
5516
|
-
* `networkAccess: false, writableRoots: []` NO MATTER what the operator
|
|
5517
|
-
* configured — and we must keep sending the object every turn, because
|
|
5518
|
-
* restating it is what makes a between-turns permission-mode switch take
|
|
5519
|
-
* effect. Measured against 0.149.0 with `network_access = true` set: the
|
|
5520
|
-
* bare object produced `curl: (6) Could not resolve host`, the fully-stated
|
|
5521
|
-
* object and an omitted policy both produced `200`. `read-only` is not
|
|
5522
|
-
* affected — the setting is scoped to workspace-write, as its name says, and
|
|
5523
|
-
* a read-only sandbox has no network either way.
|
|
5524
|
-
*
|
|
5525
|
-
* A failure here is not fatal: `#workspaceWrite` stays undefined and we send
|
|
5526
|
-
* the bare shape, which is exactly the behaviour that shipped before.
|
|
5527
|
-
*/
|
|
5528
4245
|
async #readWorkspaceWrite(connection) {
|
|
5529
4246
|
this.#workspaceWrite = void 0;
|
|
5530
4247
|
try {
|
|
@@ -5539,7 +4256,6 @@ var CodexRunner = class {
|
|
|
5539
4256
|
};
|
|
5540
4257
|
} catch {}
|
|
5541
4258
|
}
|
|
5542
|
-
/** The mode's turn-level sandbox policy, with the operator's workspace-write settings intact. */
|
|
5543
4259
|
#turnSandboxPolicy() {
|
|
5544
4260
|
const policy = TURN_SANDBOX_BY_MODE[this.#permissionMode];
|
|
5545
4261
|
if (policy?.type !== "workspaceWrite" || !this.#workspaceWrite) return policy;
|
|
@@ -5548,13 +4264,6 @@ var CodexRunner = class {
|
|
|
5548
4264
|
...this.#workspaceWrite
|
|
5549
4265
|
};
|
|
5550
4266
|
}
|
|
5551
|
-
/**
|
|
5552
|
-
* The session's live connection with its thread loaded, (re)building both as
|
|
5553
|
-
* needed: spawn + `initialize`/`initialized` on a fresh child, then
|
|
5554
|
-
* `thread/start` (new) or `thread/resume` (a create-request `resume`, or a
|
|
5555
|
-
* thread orphaned by a dead child). The response's resolved model/effort are
|
|
5556
|
-
* kept so per-turn overrides can name "the profile default" explicitly.
|
|
5557
|
-
*/
|
|
5558
4267
|
async #ensureThread() {
|
|
5559
4268
|
if (this.#closed) throw new Error("session is closed");
|
|
5560
4269
|
let connection = this.#connection;
|
|
@@ -5577,18 +4286,11 @@ var CodexRunner = class {
|
|
|
5577
4286
|
this.#activeTurn?.reject(new Error(message));
|
|
5578
4287
|
});
|
|
5579
4288
|
try {
|
|
5580
|
-
await connection.request("initialize",
|
|
5581
|
-
clientInfo: {
|
|
5582
|
-
name: "workerdeck",
|
|
5583
|
-
title: "WorkerDeck",
|
|
5584
|
-
version: `protocol-${PROTOCOL_VERSION}`
|
|
5585
|
-
},
|
|
5586
|
-
capabilities: { experimentalApi: true }
|
|
5587
|
-
});
|
|
4289
|
+
await connection.request("initialize", INITIALIZE_PARAMS);
|
|
5588
4290
|
} catch (error) {
|
|
5589
4291
|
connection.close();
|
|
5590
4292
|
if (this.#connection === connection) this.#connection = void 0;
|
|
5591
|
-
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);
|
|
4293
|
+
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 });
|
|
5592
4294
|
throw error;
|
|
5593
4295
|
}
|
|
5594
4296
|
connection.notify("initialized");
|
|
@@ -5619,21 +4321,6 @@ var CodexRunner = class {
|
|
|
5619
4321
|
this.#refreshSkills(connection);
|
|
5620
4322
|
return connection;
|
|
5621
4323
|
}
|
|
5622
|
-
/**
|
|
5623
|
-
* Re-read `skills/list` and publish it, if it changed.
|
|
5624
|
-
*
|
|
5625
|
-
* **`cwds` is passed explicitly, and must be.** The schema documents the empty
|
|
5626
|
-
* case as "the current session working directory", which reads like the
|
|
5627
|
-
* thread's — it is not. Measured against 0.146.0: with no `cwds`, and *after*
|
|
5628
|
-
* a `thread/start` carrying this session's cwd, the response comes back keyed
|
|
5629
|
-
* to the app-server child's own process directory (for WorkerDeck, wherever
|
|
5630
|
-
* the gateway was launched) and reports no repo-scoped skills at all. So a
|
|
5631
|
-
* project's own `.codex/skills/**` were invisible until this argument existed.
|
|
5632
|
-
*
|
|
5633
|
-
* Best-effort throughout. A binary too old to know the method, a broken
|
|
5634
|
-
* manifest, a child that died mid-call — none of that is worth failing a
|
|
5635
|
-
* session over, and the panel simply stays absent.
|
|
5636
|
-
*/
|
|
5637
4324
|
async #refreshSkills(connection) {
|
|
5638
4325
|
if (this.#skillsRefresh) return this.#skillsRefresh;
|
|
5639
4326
|
const run = (async () => {
|
|
@@ -5663,33 +4350,8 @@ var CodexRunner = class {
|
|
|
5663
4350
|
this.#skillsRefresh = run;
|
|
5664
4351
|
return run;
|
|
5665
4352
|
}
|
|
5666
|
-
/**
|
|
5667
|
-
* The session's MCP servers, live from the binary.
|
|
5668
|
-
*
|
|
5669
|
-
* Two sources merged, because codex splits them: `mcpServerStatus/list` says
|
|
5670
|
-
* what is configured and what each server exposes (including every tool's
|
|
5671
|
-
* full JSON Schema, which the Agent SDK does not give us), and the
|
|
5672
|
-
* `mcpServer/startupStatus/updated` notifications say which of them are
|
|
5673
|
-
* actually up.
|
|
5674
|
-
*
|
|
5675
|
-
* Answers **before the session has connected**, over a throwaway child, for
|
|
5676
|
-
* the same reason the skill list does: a codex session spawns nothing until
|
|
5677
|
-
* it has work, and a panel that said "no MCP servers configured" until the
|
|
5678
|
-
* first turn would be stating something false about the operator's config.
|
|
5679
|
-
* The request blocks until the servers are enumerated (measured: complete on
|
|
5680
|
-
* the very first call), so there is no half-populated answer to race.
|
|
5681
|
-
*
|
|
5682
|
-
* Resolves undefined only when there is genuinely nothing to say — the
|
|
5683
|
-
* session is closed, or the child could not be spoken to. The route turns
|
|
5684
|
-
* that into a 501.
|
|
5685
|
-
*
|
|
5686
|
-
* **Listing only.** There is no per-server reconnect or toggle on this
|
|
5687
|
-
* transport — hence no `reconnectMcpServer`/`setMcpServerEnabled` here, and
|
|
5688
|
-
* `ENGINE_CAPABILITIES.codex.mcpServerActions: false` so clients render the
|
|
5689
|
-
* panel read-only instead of offering buttons that cannot work.
|
|
5690
|
-
*/
|
|
5691
4353
|
async mcpServers() {
|
|
5692
|
-
if (this.#closed) return
|
|
4354
|
+
if (this.#closed) return;
|
|
5693
4355
|
const live = this.#connection;
|
|
5694
4356
|
let scratch;
|
|
5695
4357
|
try {
|
|
@@ -5700,15 +4362,6 @@ var CodexRunner = class {
|
|
|
5700
4362
|
scratch?.close();
|
|
5701
4363
|
}
|
|
5702
4364
|
}
|
|
5703
|
-
/**
|
|
5704
|
-
* Announce a file the ENGINE wrote on the host, so a client can fetch it
|
|
5705
|
-
* without the operator having declared its directory as a host-file root.
|
|
5706
|
-
*
|
|
5707
|
-
* Deliberately narrow: only paths codex reports as *written by its own tool*
|
|
5708
|
-
* belong here. A path the model merely read (`imageView`) is an agent-chosen
|
|
5709
|
-
* claim, and those keep going through `/fs/*` and its root allowlist — see
|
|
5710
|
-
* the note on `file_produced` in the protocol.
|
|
5711
|
-
*/
|
|
5712
4365
|
#emitFileProduced(path, toolUseId) {
|
|
5713
4366
|
if (this.#producedPaths.has(path)) return;
|
|
5714
4367
|
this.#producedPaths.add(path);
|
|
@@ -5726,16 +4379,6 @@ var CodexRunner = class {
|
|
|
5726
4379
|
toolUseId
|
|
5727
4380
|
});
|
|
5728
4381
|
}
|
|
5729
|
-
/**
|
|
5730
|
-
* On resume, replay the thread's prior turns as `replay: true` events,
|
|
5731
|
-
* seq'd before any live turn — the SessionRunner backfill contract, fed
|
|
5732
|
-
* from `thread/resume`'s own `thread.turns`. When the resume response says
|
|
5733
|
-
* that page is partial (`turnsBackwardsCursor`), the FULL rollout history
|
|
5734
|
-
* is fetched via `thread/read {includeTurns: true}` instead — and if even
|
|
5735
|
-
* that fails, the partial page is replayed under a visible notice rather
|
|
5736
|
-
* than silently posing as the whole thread. Best-effort like the Claude
|
|
5737
|
-
* backfill: an unreadable history never blocks the resume itself.
|
|
5738
|
-
*/
|
|
5739
4382
|
async #backfillHistory() {
|
|
5740
4383
|
try {
|
|
5741
4384
|
if (this.#closed) return;
|
|
@@ -5764,7 +4407,6 @@ var CodexRunner = class {
|
|
|
5764
4407
|
this.#setStatus("idle");
|
|
5765
4408
|
}
|
|
5766
4409
|
}
|
|
5767
|
-
/** Replay historical turns through the SAME item mapping the live path uses. */
|
|
5768
4410
|
#replayTurns(turns) {
|
|
5769
4411
|
for (const turn of turns) {
|
|
5770
4412
|
if (this.#closed) return;
|
|
@@ -5793,8 +4435,6 @@ var CodexRunner = class {
|
|
|
5793
4435
|
}
|
|
5794
4436
|
}
|
|
5795
4437
|
}
|
|
5796
|
-
/** Fresh per-turn state — one per live turn, and one per REPLAYED turn (the
|
|
5797
|
-
* nonce is the item-id namespace, and its per-turn-ness is the invariant). */
|
|
5798
4438
|
#newTurnState() {
|
|
5799
4439
|
return {
|
|
5800
4440
|
nonce: randomUUID(),
|
|
@@ -5883,10 +4523,6 @@ var CodexRunner = class {
|
|
|
5883
4523
|
}
|
|
5884
4524
|
this.#notifications[method]?.(params);
|
|
5885
4525
|
}
|
|
5886
|
-
/** Whether a notification is about the session's own thread. A notification
|
|
5887
|
-
* with no `threadId` counts as the root's: every thread-scoped method the
|
|
5888
|
-
* schema defines carries one, so an absent id means an older or narrower
|
|
5889
|
-
* shape, not a sub-agent. */
|
|
5890
4526
|
#isRootThread(params) {
|
|
5891
4527
|
const threadId = this.#threadIdOf(params);
|
|
5892
4528
|
if (threadId === void 0) return true;
|
|
@@ -5896,42 +4532,18 @@ var CodexRunner = class {
|
|
|
5896
4532
|
const threadId = params?.threadId;
|
|
5897
4533
|
return typeof threadId === "string" ? threadId : void 0;
|
|
5898
4534
|
}
|
|
5899
|
-
/**
|
|
5900
|
-
* The agent behind a notification's `threadId` — the attribution every item
|
|
5901
|
-
* and delta handler asks before emitting, so two agents streaming
|
|
5902
|
-
* concurrently into this one connection come apart again by the id each
|
|
5903
|
-
* frame carries, never by any mutable "current agent".
|
|
5904
|
-
*
|
|
5905
|
-
* A non-root thread with no record still gets one: a thread emitting items on
|
|
5906
|
-
* this connection *is* an agent, whatever announced it (codex runs threads of
|
|
5907
|
-
* its own for review/compact, and a `subAgentActivity` could in principle be
|
|
5908
|
-
* missed) — the claude tracker's nested-event fallback, on a stronger signal.
|
|
5909
|
-
* The minted record is label-less and its anchor is authored here, because an
|
|
5910
|
-
* attributed event whose parent id matches no top-level `tool_use` would
|
|
5911
|
-
* render inline rather than as a frame; a late `started` edge fills the name
|
|
5912
|
-
* in. Root-thread traffic — and, defensively, the pre-thread shapes with no
|
|
5913
|
-
* id at all — stays unattributed (`undefined`).
|
|
5914
|
-
*/
|
|
5915
4535
|
#agentFor(params) {
|
|
5916
4536
|
const threadId = this.#threadIdOf(params);
|
|
5917
|
-
if (threadId === void 0 || threadId === this.#sdkSessionId) return
|
|
4537
|
+
if (threadId === void 0 || threadId === this.#sdkSessionId) return;
|
|
5918
4538
|
const known = this.#agents.get(threadId);
|
|
5919
4539
|
if (known) return known;
|
|
5920
|
-
if (this.#clearedThreads.has(threadId)) return
|
|
4540
|
+
if (this.#clearedThreads.has(threadId)) return;
|
|
5921
4541
|
const nonce = this.#activeTurn?.nonce ?? "codex";
|
|
5922
4542
|
const record = this.#agents.open(threadId, `${nonce}:agent:${threadId}`, void 0, Date.now());
|
|
5923
4543
|
record.anchored = true;
|
|
5924
4544
|
this.#emitToolUse(record.toolUseId, CODEX_AGENT_TOOL, { agentThreadId: threadId });
|
|
5925
4545
|
return record;
|
|
5926
4546
|
}
|
|
5927
|
-
/**
|
|
5928
|
-
* A child thread's `turn/completed` is that AGENT's completion — the one
|
|
5929
|
-
* codex sends (`subAgentActivity` has no 'completed' kind, verified live).
|
|
5930
|
-
* The verdict is the turn's own status, and the report is the completed
|
|
5931
|
-
* turn's final message, delivered as the anchor's `tool_result` so the row
|
|
5932
|
-
* settles exactly the way a claude `Task`'s does. Deliberately not gated on
|
|
5933
|
-
* `#activeTurn`: an agent finishing between root turns still finished.
|
|
5934
|
-
*/
|
|
5935
4547
|
#settleAgentTurn(params) {
|
|
5936
4548
|
const threadId = this.#threadIdOf(params);
|
|
5937
4549
|
const record = threadId ? this.#agents.get(threadId) : void 0;
|
|
@@ -5942,11 +4554,6 @@ var CodexRunner = class {
|
|
|
5942
4554
|
const report = (turn ? turnReport(turn) : void 0) ?? turn?.error?.message ?? (status === "done" ? "" : turn?.status ?? "failed");
|
|
5943
4555
|
this.#emitToolResult(record.toolUseId, report, status === "failed");
|
|
5944
4556
|
}
|
|
5945
|
-
/** Reasoning deltas arrive on two methods that differ only in which section
|
|
5946
|
-
* counter they advance; the section key carries the method so the two streams
|
|
5947
|
-
* never share a boundary (and item ids are per-thread, so two agents' streams
|
|
5948
|
-
* never share one either). Section boundaries (a new summary/content entry)
|
|
5949
|
-
* render as paragraph breaks — the completed item joins sections with '\n\n'. */
|
|
5950
4557
|
#reasoningDelta(method) {
|
|
5951
4558
|
return (params) => {
|
|
5952
4559
|
const active = this.#activeTurn;
|
|
@@ -5954,26 +4561,23 @@ var CodexRunner = class {
|
|
|
5954
4561
|
const payload = params;
|
|
5955
4562
|
if (typeof payload?.delta !== "string" || !payload.delta) return;
|
|
5956
4563
|
const index = payload.contentIndex ?? payload.summaryIndex ?? 0;
|
|
5957
|
-
const
|
|
4564
|
+
const agent = this.#agentFor(params);
|
|
4565
|
+
const key = `${agent?.toolUseId ?? ""}:${payload.itemId ?? ""}:${method}`;
|
|
5958
4566
|
const previous = active.sectionIndex.get(key);
|
|
5959
4567
|
active.sectionIndex.set(key, index);
|
|
5960
4568
|
const separator = previous !== void 0 && index > previous ? "\n\n" : "";
|
|
5961
4569
|
this.#emitDelta({
|
|
5962
4570
|
type: "thinking_delta",
|
|
5963
4571
|
thinking: separator + payload.delta
|
|
5964
|
-
},
|
|
4572
|
+
}, agent?.toolUseId ?? null);
|
|
5965
4573
|
};
|
|
5966
4574
|
}
|
|
5967
|
-
/** One item-progress handler serves `item/started` and `item/updated`. */
|
|
5968
4575
|
#itemProgress = (params) => {
|
|
5969
4576
|
const active = this.#activeTurn;
|
|
5970
4577
|
if (!active) return;
|
|
5971
4578
|
const item = params?.item;
|
|
5972
4579
|
if (item) this.#handleItemProgress(item, active, this.#agentFor(params));
|
|
5973
4580
|
};
|
|
5974
|
-
/** The notification dispatch table — every method the child emits that this
|
|
5975
|
-
* runner maps, in one place. Handlers read `this.#activeTurn` themselves:
|
|
5976
|
-
* dispatch is synchronous, so the read is the same one the old switch made. */
|
|
5977
4581
|
#notifications = {
|
|
5978
4582
|
"thread/started": (params) => {
|
|
5979
4583
|
const thread = params?.thread;
|
|
@@ -6068,26 +4672,17 @@ var CodexRunner = class {
|
|
|
6068
4672
|
return;
|
|
6069
4673
|
}
|
|
6070
4674
|
},
|
|
6071
|
-
|
|
4675
|
+
error: (params) => {
|
|
6072
4676
|
const active = this.#activeTurn;
|
|
6073
4677
|
const error = params?.error;
|
|
6074
4678
|
if (active && typeof error?.message === "string") active.lastError = error.message;
|
|
6075
4679
|
}
|
|
6076
4680
|
};
|
|
6077
|
-
/** Answer a server→client request: the ask channels become pending
|
|
6078
|
-
* permission requests; anything else gets a JSON-RPC -32601 rather than a
|
|
6079
|
-
* hang (an unanswered server request wedges the turn). */
|
|
6080
4681
|
async #answerServerRequest(method, params, wireId) {
|
|
6081
4682
|
const channel = APPROVAL_CHANNELS[method];
|
|
6082
4683
|
if (channel) return this.#requestApproval(channel, method, params, wireId);
|
|
6083
4684
|
throw new JsonRpcError(-32601, `workerdeck does not handle server request '${method}'`);
|
|
6084
4685
|
}
|
|
6085
|
-
/**
|
|
6086
|
-
* Surface one ask-channel request as a pending {@link PermissionRequest};
|
|
6087
|
-
* the returned promise is the JSON-RPC response, resolved when a
|
|
6088
|
-
* `permission_decision` lands — or by the timeout, an interrupt, turn end,
|
|
6089
|
-
* session close, or codex resolving it itself. Never left hanging.
|
|
6090
|
-
*/
|
|
6091
4686
|
#requestApproval(channel, method, params, wireId) {
|
|
6092
4687
|
if (method === "item/tool/requestUserInput") {
|
|
6093
4688
|
const behavior = this.#config.questionBehavior ?? "ask";
|
|
@@ -6126,9 +4721,6 @@ var CodexRunner = class {
|
|
|
6126
4721
|
if (this.#activeTurn) this.#setStatus("awaiting_approval");
|
|
6127
4722
|
});
|
|
6128
4723
|
}
|
|
6129
|
-
/** 'auto'/'deny' sessions settle codex questions synchronously instead of
|
|
6130
|
-
* pending. Request/resolved events still fire so transcripts and job
|
|
6131
|
-
* webhooks show what was chosen. */
|
|
6132
4724
|
#resolveQuestionByPolicy(channel, params, mode) {
|
|
6133
4725
|
const itemId = channel.itemId(params);
|
|
6134
4726
|
const request = {
|
|
@@ -6163,13 +4755,6 @@ var CodexRunner = class {
|
|
|
6163
4755
|
});
|
|
6164
4756
|
return { answers };
|
|
6165
4757
|
}
|
|
6166
|
-
/**
|
|
6167
|
-
* Settle one pending approval: pick the channel's wire response for the
|
|
6168
|
-
* decision, answer the JSON-RPC request, and emit `permission_resolved`.
|
|
6169
|
-
* An allow the request offered no plain accept for becomes the channel's
|
|
6170
|
-
* denial, said out loud — never a silently widened grant, and never a
|
|
6171
|
-
* decision the request didn't offer.
|
|
6172
|
-
*/
|
|
6173
4758
|
#settleApproval(id, pending, decision, resolvedBy) {
|
|
6174
4759
|
clearTimeout(pending.timer);
|
|
6175
4760
|
this.#approvals.delete(id);
|
|
@@ -6197,9 +4782,6 @@ var CodexRunner = class {
|
|
|
6197
4782
|
if (behavior === "deny" && decision.behavior === "deny" && decision.interrupt && sent.decision !== "cancel") this.#interruptTurn();
|
|
6198
4783
|
if (!this.#closed && this.#approvals.size === 0 && this.#status === "awaiting_approval") this.#setStatus("running");
|
|
6199
4784
|
}
|
|
6200
|
-
/** Tool calls surface as tool_use when they start; text and reasoning stream
|
|
6201
|
-
* natively via the delta notifications. `agent` is the sub-agent whose thread
|
|
6202
|
-
* the item arrived on — undefined for the session's own. */
|
|
6203
4785
|
#handleItemProgress(item, active, agent) {
|
|
6204
4786
|
const id = `${active.nonce}:${item.id}`;
|
|
6205
4787
|
if (item.type === "subAgentActivity") {
|
|
@@ -6243,14 +4825,6 @@ var CodexRunner = class {
|
|
|
6243
4825
|
}
|
|
6244
4826
|
});
|
|
6245
4827
|
}
|
|
6246
|
-
/**
|
|
6247
|
-
* The completed-item mapping, one handler per member of the {@link AppServerItem}
|
|
6248
|
-
* union. The mapped type is the invariant made checkable: model a new item
|
|
6249
|
-
* type in `types.ts` and this table fails to compile until it says what the
|
|
6250
|
-
* item becomes on the wire — the old switch silently fell through to the
|
|
6251
|
-
* unknown-item passthrough instead. (The runtime still receives types the
|
|
6252
|
-
* union has never heard of; those take the passthrough above.)
|
|
6253
|
-
*/
|
|
6254
4828
|
#itemCompleted = {
|
|
6255
4829
|
userMessage: (item, active, _id, agent) => {
|
|
6256
4830
|
if (!agent) return;
|
|
@@ -6390,10 +4964,6 @@ var CodexRunner = class {
|
|
|
6390
4964
|
uuid
|
|
6391
4965
|
});
|
|
6392
4966
|
}
|
|
6393
|
-
/** `agent` (rather than a bare parent id) because a nested call is also the
|
|
6394
|
-
* agent's progress reading: `SubagentInfo.toolCount` ticks here, once per
|
|
6395
|
-
* card — the `counted` set is what keeps an upserted re-emission (the
|
|
6396
|
-
* finished imageGeneration input) from counting one picture twice. */
|
|
6397
4967
|
#emitToolUse(id, name, input, agent) {
|
|
6398
4968
|
if (agent && !agent.counted.has(id)) {
|
|
6399
4969
|
agent.counted.add(id);
|
|
@@ -6433,14 +5003,6 @@ var CodexRunner = class {
|
|
|
6433
5003
|
uuid: `${toolUseId}-result`
|
|
6434
5004
|
});
|
|
6435
5005
|
}
|
|
6436
|
-
/**
|
|
6437
|
-
* Per-turn usage re-mapped to the Anthropic accounting convention the whole
|
|
6438
|
-
* stack assumes (GOTCHAS §Codex engine): OpenAI's `inputTokens` includes the
|
|
6439
|
-
* cached share, so input excludes it (else queue token budgets double-count
|
|
6440
|
-
* cache-heavy runs); reasoning tokens are billed output; `totalCostUsd: 0` =
|
|
6441
|
-
* unknown, the AiSdkRunner precedent. Usage is summed from the turn's
|
|
6442
|
-
* `thread/tokenUsage/updated` notifications — `turn/completed` carries none.
|
|
6443
|
-
*/
|
|
6444
5006
|
#finishTurn(kind, startedAt, active, errors) {
|
|
6445
5007
|
for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
|
|
6446
5008
|
behavior: "deny",
|
|
@@ -6468,23 +5030,6 @@ var CodexRunner = class {
|
|
|
6468
5030
|
this.#emitContextUsage(active);
|
|
6469
5031
|
this.#setStatus("idle");
|
|
6470
5032
|
}
|
|
6471
|
-
/**
|
|
6472
|
-
* Subscription windows, mapped onto the protocol's named vocabulary.
|
|
6473
|
-
*
|
|
6474
|
-
* The shapes disagree: codex reports windows *positionally* (`primary` /
|
|
6475
|
-
* `secondary`) with a length in minutes, while `RateLimitInfo.rateLimitType`
|
|
6476
|
-
* is a name whose meaning clients already know — iOS labels `seven_day` as
|
|
6477
|
-
* "Weekly" and derives the pace marker's denominator from it. Naming the
|
|
6478
|
-
* window by its measured duration is therefore the honest mapping rather
|
|
6479
|
-
* than a borrowed one: codex's primary window is 10080 minutes, which *is*
|
|
6480
|
-
* seven days. A duration we have no name for keeps an explicit
|
|
6481
|
-
* `window_<n>m` key — clients render it verbatim and simply draw no pace
|
|
6482
|
-
* marker, which beats mislabeling it as a week.
|
|
6483
|
-
*
|
|
6484
|
-
* `status` is 'allowed' by construction (the session is running), matching
|
|
6485
|
-
* `rateLimitEventsFromUsage`; codex's `rateLimitReachedType` is the one
|
|
6486
|
-
* signal that a limit is actually biting, so it becomes 'rejected'.
|
|
6487
|
-
*/
|
|
6488
5033
|
#emitRateLimits(limits) {
|
|
6489
5034
|
if (!limits) return;
|
|
6490
5035
|
const status = limits.rateLimitReachedType ? "rejected" : "allowed";
|
|
@@ -6508,16 +5053,6 @@ var CodexRunner = class {
|
|
|
6508
5053
|
});
|
|
6509
5054
|
}
|
|
6510
5055
|
}
|
|
6511
|
-
/**
|
|
6512
|
-
* Context occupancy, after the turn — the same cadence the Claude runner
|
|
6513
|
-
* polls `getContextUsage()` on, so clients need nothing new.
|
|
6514
|
-
*
|
|
6515
|
-
* Emitted only when the binary gave BOTH numbers: the protocol is explicit
|
|
6516
|
-
* that a client renders nothing rather than a 0% ring, and a window of
|
|
6517
|
-
* `null` (which app-server does send) would otherwise divide into a
|
|
6518
|
-
* meaningless percentage. `categories` is empty because codex publishes no
|
|
6519
|
-
* breakdown — clients must not render an empty "Breakdown" section for it.
|
|
6520
|
-
*/
|
|
6521
5056
|
#emitContextUsage(active) {
|
|
6522
5057
|
const totalTokens = active.contextTokens;
|
|
6523
5058
|
const maxTokens = active.contextWindow;
|
|
@@ -6534,9 +5069,10 @@ var CodexRunner = class {
|
|
|
6534
5069
|
});
|
|
6535
5070
|
}
|
|
6536
5071
|
#setStatus(status, detail) {
|
|
6537
|
-
if (this.#status === status) return;
|
|
5072
|
+
if (this.#status === status && this.#statusDetail === detail) return;
|
|
6538
5073
|
if (this.#status === "closed" || this.#status === "failed") return;
|
|
6539
5074
|
this.#status = status;
|
|
5075
|
+
this.#statusDetail = detail;
|
|
6540
5076
|
this.#emit({
|
|
6541
5077
|
type: "status_changed",
|
|
6542
5078
|
status,
|
|
@@ -6548,32 +5084,26 @@ var CodexRunner = class {
|
|
|
6548
5084
|
...body,
|
|
6549
5085
|
replay: true
|
|
6550
5086
|
};
|
|
6551
|
-
|
|
6552
|
-
...body,
|
|
6553
|
-
seq: ++this.#seq,
|
|
6554
|
-
ts: Date.now()
|
|
6555
|
-
};
|
|
6556
|
-
this.#lastActivityAt = event.ts;
|
|
6557
|
-
this.#activityCount += transcriptActivity(body);
|
|
6558
|
-
this.#contextUsage = contextReading(body) ?? this.#contextUsage;
|
|
6559
|
-
if (body.type === "conversation_reset") {
|
|
6560
|
-
this.#resetSeq = event.seq;
|
|
6561
|
-
this.#contextUsage = void 0;
|
|
6562
|
-
}
|
|
6563
|
-
this.#events.push(event);
|
|
6564
|
-
this.#subscribers.emit(event);
|
|
5087
|
+
this.#subscribers.emit(this.#log.append(body));
|
|
6565
5088
|
}
|
|
6566
5089
|
};
|
|
6567
5090
|
//#endregion
|
|
6568
5091
|
//#region src/engines/codex/catalog.ts
|
|
6569
5092
|
/**
|
|
6570
|
-
* The Codex engine's model catalog, seeded from the binary's own embedded
|
|
6571
|
-
*
|
|
6572
|
-
*
|
|
6573
|
-
*
|
|
5093
|
+
* The Codex engine's model catalog, seeded from the binary's own embedded model
|
|
5094
|
+
* table (see `provenance` for the version) — that table, not the SDK's stale
|
|
5095
|
+
* `ModelReasoningEffort` union, is the truth about which reasoning efforts each
|
|
5096
|
+
* model takes. Mapping decisions: the internal `codex-auto-review` row is dropped
|
|
5097
|
+
* (the codex analogue of the CLI's `default` sentinel), `primary` mirrors the
|
|
5098
|
+
* binary's own `visibility` field so both UIs group the way codex's picker does,
|
|
5099
|
+
* and `reasoningEfforts` carries `supported_reasoning_levels` verbatim — `max`
|
|
5100
|
+
* and `ultra` go beyond the SDK union, so trust the binary and keep strings open.
|
|
6574
5101
|
*
|
|
6575
|
-
* **Refresh procedure** (release checklist): extract the embedded JSON from
|
|
6576
|
-
*
|
|
5102
|
+
* **Refresh procedure** (release checklist): extract the embedded JSON from the
|
|
5103
|
+
* platform binary and diff. The two-hop resolve is NOT optional — under pnpm's
|
|
5104
|
+
* strict layout the platform package is a dependency of `@openai/codex` and
|
|
5105
|
+
* resolves only from that wrapper's location (MODULE_NOT_FOUND otherwise), the
|
|
5106
|
+
* same two hops `resolveBundledCodexExecutable` makes.
|
|
6577
5107
|
*
|
|
6578
5108
|
* node -e 'const d=require("fs").readFileSync(process.argv[1]);
|
|
6579
5109
|
* const s=d.indexOf(`{\n "models": [`);
|
|
@@ -6585,20 +5115,6 @@ var CodexRunner = class {
|
|
|
6585
5115
|
* const w=require.resolve("@openai/codex/package.json");
|
|
6586
5116
|
* createRequire(w).resolve("@openai/codex-darwin-arm64/package.json")
|
|
6587
5117
|
* .replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
|
|
6588
|
-
*
|
|
6589
|
-
* The two-hop resolve is NOT optional: under pnpm's strict layout the platform
|
|
6590
|
-
* package is a dependency of `@openai/codex`, so it resolves only from that
|
|
6591
|
-
* wrapper's location, never from the repo root. Resolving it directly throws
|
|
6592
|
-
* MODULE_NOT_FOUND — the same two hops `resolveBundledCodexExecutable` makes.
|
|
6593
|
-
*
|
|
6594
|
-
* Mapping decisions:
|
|
6595
|
-
* - the internal `codex-auto-review` row is dropped (the codex analogue of
|
|
6596
|
-
* dropping the CLI's `default` sentinel);
|
|
6597
|
-
* - `primary` mirrors the binary's own `visibility` field ('list' = shown in
|
|
6598
|
-
* its picker, 'hide' = its "older models"), so both UIs group the way
|
|
6599
|
-
* codex's own picker does;
|
|
6600
|
-
* - `reasoningEfforts` carries `supported_reasoning_levels` verbatim — note
|
|
6601
|
-
* `max`/`ultra` beyond the SDK union; trust the binary, keep strings open.
|
|
6602
5118
|
*/
|
|
6603
5119
|
const CODEX_CATALOG = {
|
|
6604
5120
|
provenance: "embedded model presets of @openai/codex@0.149.0 (darwin-arm64 binary), extracted 2026-08-22",
|
|
@@ -6701,19 +5217,7 @@ const CODEX_CATALOG = {
|
|
|
6701
5217
|
};
|
|
6702
5218
|
//#endregion
|
|
6703
5219
|
//#region src/engines/codex/process.ts
|
|
6704
|
-
/** How much stderr to keep for the exit diagnostic. The binary logs startup
|
|
6705
|
-
* noise there; only the tail explains a death. */
|
|
6706
5220
|
const STDERR_TAIL_BYTES = 4096;
|
|
6707
|
-
/**
|
|
6708
|
-
* Spawn one `codex app-server` child and frame JSON-RPC over its stdio — the
|
|
6709
|
-
* real {@link AppServerConnectFn}. The child's env is passed **complete**
|
|
6710
|
-
* (a provided spawn env replaces process.env, never merges with it), with the
|
|
6711
|
-
* profile's CODEX_HOME pin already applied by the runner.
|
|
6712
|
-
*
|
|
6713
|
-
* No spawn cwd: the working directory is a thread/turn parameter, and a cwd
|
|
6714
|
-
* that doesn't exist should fail the *turn* with codex's own error, not the
|
|
6715
|
-
* spawn.
|
|
6716
|
-
*/
|
|
6717
5221
|
function connectAppServer(options) {
|
|
6718
5222
|
const child = spawn(options.executable, ["app-server"], {
|
|
6719
5223
|
env: options.env,
|
|
@@ -6762,17 +5266,9 @@ function connectAppServer(options) {
|
|
|
6762
5266
|
//#endregion
|
|
6763
5267
|
//#region src/engines/codex/adapter.ts
|
|
6764
5268
|
const NOT_INSTALLED = "@openai/codex is not installed — add it (an optional peer of @workerdeck/core) to run codex profiles";
|
|
6765
|
-
/**
|
|
6766
|
-
* The codex binary sessions will run: the per-platform package installed next
|
|
6767
|
-
* to `@openai/codex`, `vendor/<target-triple>/bin/codex` — the same file the
|
|
6768
|
-
* npm wrapper's own `bin/codex.js` launcher execs. Probing this binary rather
|
|
6769
|
-
* than whatever `codex` is on PATH means the availability answer is about the
|
|
6770
|
-
* executable sessions will actually run. Undefined when it can't be found;
|
|
6771
|
-
* callers degrade to 'unknown'.
|
|
6772
|
-
*/
|
|
6773
5269
|
function resolveBundledCodexExecutable() {
|
|
6774
5270
|
const triple = targetTriple();
|
|
6775
|
-
if (!triple) return
|
|
5271
|
+
if (!triple) return;
|
|
6776
5272
|
try {
|
|
6777
5273
|
const path = createRequire(createRequire(import.meta.url).resolve("@openai/codex/package.json")).resolve(`@openai/codex-${platformPackageSuffix()}/package.json`).replace(/package\.json$/, `vendor/${triple}/bin/codex`);
|
|
6778
5274
|
if (existsSync(path)) return path;
|
|
@@ -6787,34 +5283,13 @@ function targetTriple() {
|
|
|
6787
5283
|
function platformPackageSuffix() {
|
|
6788
5284
|
return `${process.platform}-${process.arch}`;
|
|
6789
5285
|
}
|
|
6790
|
-
/**
|
|
6791
|
-
* Availability, mirroring **the app-server surface's actual credential chain**
|
|
6792
|
-
* (verified 2026-08-05 against 0.146.0 by driving the raw binary): auth comes
|
|
6793
|
-
* solely from the CODEX_HOME auth store (`codex login`, file or keyring). The
|
|
6794
|
-
* env-key routes are dead ends here — `CODEX_API_KEY` is read only by
|
|
6795
|
-
* `codex exec` (a turn goes out with no credential at all: "Missing bearer"),
|
|
6796
|
-
* and `OPENAI_API_KEY` was never read by either surface. So, in order:
|
|
6797
|
-
*
|
|
6798
|
-
* 1. Binary resolvable, else unavailable with the install reason;
|
|
6799
|
-
* 2. `codex login status` under the profile's complete session env:
|
|
6800
|
-
* exit 0 → available; the "Not logged in" verdict → unavailable, with an
|
|
6801
|
-
* exact remedy when a stranded env key explains the misconfiguration;
|
|
6802
|
-
* anything else (a stray CODEX_ACCESS_TOKEN JWT error, a crashed spawn) →
|
|
6803
|
-
* 'unknown' — the checkClaudeAuth never-overclaim discipline.
|
|
6804
|
-
*
|
|
6805
|
-
* Only the exit code and the fixed verdict line are consulted — never
|
|
6806
|
-
* surfaced: `login status` output includes a masked key fragment. The
|
|
6807
|
-
* `smoke:codex --canary` run is the drift alarm for all of this.
|
|
6808
|
-
*/
|
|
6809
5286
|
async function checkCodexAvailability(profile, env, options = {}) {
|
|
6810
5287
|
const executable = resolveBundledCodexExecutable();
|
|
6811
5288
|
if (!executable) return {
|
|
6812
5289
|
available: false,
|
|
6813
5290
|
reason: NOT_INSTALLED
|
|
6814
5291
|
};
|
|
6815
|
-
const childEnv =
|
|
6816
|
-
for (const [key, value] of Object.entries(env)) if (value !== void 0) childEnv[key] = value;
|
|
6817
|
-
if (profile.codexHome) childEnv.CODEX_HOME = profile.codexHome;
|
|
5292
|
+
const childEnv = codexChildEnv(env, profile.codexHome);
|
|
6818
5293
|
return new Promise((resolve) => {
|
|
6819
5294
|
execFile(executable, ["login", "status"], {
|
|
6820
5295
|
env: childEnv,
|
|
@@ -6836,13 +5311,8 @@ async function checkCodexAvailability(profile, env, options = {}) {
|
|
|
6836
5311
|
});
|
|
6837
5312
|
});
|
|
6838
5313
|
}
|
|
6839
|
-
/** `thread/list` page size (its own default is 25) and a hard page bound so a
|
|
6840
|
-
* misbehaving cursor can never spin the listing forever. */
|
|
6841
5314
|
const LIST_PAGE_SIZE = 100;
|
|
6842
5315
|
const MAX_LIST_PAGES = 40;
|
|
6843
|
-
/** thread/list's `cwd` filter is an EXACT path match (measured, 0.146.0), so
|
|
6844
|
-
* offer both the spelled and canonical forms — macOS listings would otherwise
|
|
6845
|
-
* miss `/tmp/...` threads recorded under `/private/tmp/...`. */
|
|
6846
5316
|
function cwdFilter(dir) {
|
|
6847
5317
|
const forms = new Set([dir]);
|
|
6848
5318
|
try {
|
|
@@ -6850,10 +5320,9 @@ function cwdFilter(dir) {
|
|
|
6850
5320
|
} catch {}
|
|
6851
5321
|
return [...forms];
|
|
6852
5322
|
}
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
* `sessionId` field is not it. */
|
|
5323
|
+
function secondsToMs(value) {
|
|
5324
|
+
return typeof value === "number" && Number.isFinite(value) ? value * 1e3 : void 0;
|
|
5325
|
+
}
|
|
6857
5326
|
function summarizeThread(row) {
|
|
6858
5327
|
const name = typeof row.name === "string" && row.name.length > 0 ? row.name : void 0;
|
|
6859
5328
|
const preview = typeof row.preview === "string" && row.preview.length > 0 ? row.preview : void 0;
|
|
@@ -6868,30 +5337,12 @@ function summarizeThread(row) {
|
|
|
6868
5337
|
cwd: typeof row.cwd === "string" ? row.cwd : void 0
|
|
6869
5338
|
};
|
|
6870
5339
|
}
|
|
6871
|
-
/**
|
|
6872
|
-
* CODEX_HOME's threads over ONE short-lived `codex app-server` child: the
|
|
6873
|
-
* runner's own handshake (`experimentalApi` and all — one code path, no
|
|
6874
|
-
* second vocabulary to drift), `thread/list` pages walked by cursor, child
|
|
6875
|
-
* closed before returning. Requires no live session and costs no tokens —
|
|
6876
|
-
* it is how "resume" is offered before anything is running. The `connectFn`
|
|
6877
|
-
* seam exists for the scripted-peer tests; the adapter passes the real
|
|
6878
|
-
* spawn.
|
|
6879
|
-
*/
|
|
6880
5340
|
async function listCodexSessions(options) {
|
|
6881
|
-
const childEnv =
|
|
6882
|
-
for (const [key, value] of Object.entries(options.env)) if (value !== void 0) childEnv[key] = value;
|
|
6883
|
-
if (options.profile?.codexHome) childEnv.CODEX_HOME = options.profile.codexHome;
|
|
5341
|
+
const childEnv = codexChildEnv(options.env, options.profile?.codexHome);
|
|
6884
5342
|
const connection = options.connectFn({ env: childEnv });
|
|
6885
5343
|
const rows = [];
|
|
6886
5344
|
try {
|
|
6887
|
-
await connection.request("initialize",
|
|
6888
|
-
clientInfo: {
|
|
6889
|
-
name: "workerdeck",
|
|
6890
|
-
title: "WorkerDeck",
|
|
6891
|
-
version: `protocol-${PROTOCOL_VERSION}`
|
|
6892
|
-
},
|
|
6893
|
-
capabilities: { experimentalApi: true }
|
|
6894
|
-
});
|
|
5345
|
+
await connection.request("initialize", INITIALIZE_PARAMS);
|
|
6895
5346
|
connection.notify("initialized");
|
|
6896
5347
|
const base = {
|
|
6897
5348
|
limit: LIST_PAGE_SIZE,
|
|
@@ -6918,15 +5369,6 @@ async function listCodexSessions(options) {
|
|
|
6918
5369
|
const start = options.offset ?? 0;
|
|
6919
5370
|
return options.limit === void 0 ? summaries.slice(start) : summaries.slice(start, start + options.limit);
|
|
6920
5371
|
}
|
|
6921
|
-
/**
|
|
6922
|
-
* OpenAI Codex as an engine: the codex CLI binary driven over its `app-server`
|
|
6923
|
-
* JSON-RPC surface — structurally the Claude engine's sibling (a local agent
|
|
6924
|
-
* binary with sessions, sandboxing and resume, resolving its own credentials
|
|
6925
|
-
* from the operator's environment). `@openai/codex` — the npm package that
|
|
6926
|
-
* carries the binary — is an **optional peer**: absent, every codex profile
|
|
6927
|
-
* reports unavailable and createRunner throws the same message, and no
|
|
6928
|
-
* consumer downloads a ~40 MB per-platform binary it never uses.
|
|
6929
|
-
*/
|
|
6930
5372
|
const codexAdapter = {
|
|
6931
5373
|
engine: "codex",
|
|
6932
5374
|
capabilities: ENGINE_CAPABILITIES.codex,
|
|
@@ -6959,17 +5401,6 @@ const codexAdapter = {
|
|
|
6959
5401
|
};
|
|
6960
5402
|
//#endregion
|
|
6961
5403
|
//#region src/engines/provider/adapter.ts
|
|
6962
|
-
/**
|
|
6963
|
-
* The model-agnostic provider engine as a pseudo-adapter: capabilities and an
|
|
6964
|
-
* env-var probe live here, but its runners are assembled by the host's
|
|
6965
|
-
* `createEngineRunner` hook (which is where provider credentials are resolved
|
|
6966
|
-
* and model SDKs are imported — neither belongs in this repo's import graph).
|
|
6967
|
-
* The server routes provider creates to the hook; `createRunner` here throws
|
|
6968
|
-
* so a mis-routed call fails loudly instead of quietly building nothing.
|
|
6969
|
-
*
|
|
6970
|
-
* The catalog is empty by the same token: provider model ids are operator-
|
|
6971
|
-
* declared per profile (`provider.models`), not shipped with releases.
|
|
6972
|
-
*/
|
|
6973
5404
|
const providerAdapter = {
|
|
6974
5405
|
engine: "provider",
|
|
6975
5406
|
capabilities: ENGINE_CAPABILITIES.provider,
|
|
@@ -6998,7 +5429,6 @@ const ADAPTERS = {
|
|
|
6998
5429
|
codex: codexAdapter,
|
|
6999
5430
|
provider: providerAdapter
|
|
7000
5431
|
};
|
|
7001
|
-
/** The in-repo adapter for an engine. An absent `engine` means 'claude'. */
|
|
7002
5432
|
function getEngineAdapter(engine) {
|
|
7003
5433
|
return ADAPTERS[engine ?? "claude"];
|
|
7004
5434
|
}
|