@workerdeck/core 2.7.2 → 2.9.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/README.md +28 -28
- package/build/index.d.mts +2 -0
- package/build/index.mjs +113 -27
- package/build/index.mjs.map +1 -1
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@ attach/replay; `AiSdkRunner` does the same for any provider the AI SDK supports.
|
|
|
7
7
|
transport.
|
|
8
8
|
|
|
9
9
|
Part of [WorkerDeck](https://github.com/workerdeck/workerdeck). A `SessionRunner`
|
|
10
|
-
behaves like Claude Code launched in the session's directory
|
|
11
|
-
permission system
|
|
10
|
+
behaves like Claude Code launched in the session's directory - same skills, same `CLAUDE.md`, same
|
|
11
|
+
permission system - and both runners emit
|
|
12
12
|
[`@workerdeck/protocol`](https://www.npmjs.com/package/@workerdeck/protocol) events.
|
|
13
13
|
[`@workerdeck/server`](https://www.npmjs.com/package/@workerdeck/server) bridges runners to
|
|
14
14
|
HTTP + WebSocket; use core directly when you want sessions in-process with no server.
|
|
@@ -24,8 +24,8 @@ Node ≥ 22 and a real filesystem. WorkerDeck implements no Anthropic auth: the
|
|
|
24
24
|
credentials from the operator's environment (`ANTHROPIC_API_KEY`, Bedrock/Vertex, or a personal
|
|
25
25
|
`claude login`).
|
|
26
26
|
|
|
27
|
-
The provider engine additionally wants `ai` (AI SDK v7), your provider package, and
|
|
28
|
-
`eval_script`
|
|
27
|
+
The provider engine additionally wants `ai` (AI SDK v7), your provider package, and - for
|
|
28
|
+
`eval_script` - [`@workerdeck/sandbox`](https://www.npmjs.com/package/@workerdeck/sandbox);
|
|
29
29
|
all optional, and unused if you only run Claude sessions.
|
|
30
30
|
|
|
31
31
|
## Usage
|
|
@@ -63,28 +63,28 @@ await done
|
|
|
63
63
|
Other controls: `interrupt()`, `setPermissionMode(mode)`, `setModel(model?)`, `close(reason?)`,
|
|
64
64
|
`fail(message)` for host-enforced policy, and `info()` for a protocol `SessionInfo` snapshot
|
|
65
65
|
(status, cost, pending approval count, title). `runner.id` is the server-side id;
|
|
66
|
-
`runner.sdkSessionId` is the Agent SDK's
|
|
66
|
+
`runner.sdkSessionId` is the Agent SDK's - the one you pass back as `resume`.
|
|
67
67
|
|
|
68
68
|
## Approvals, event log, resume
|
|
69
69
|
|
|
70
|
-
- **Pending approvals**
|
|
70
|
+
- **Pending approvals** - the runner's `canUseTool` hook turns each uncovered tool call into a
|
|
71
71
|
`permission_requested` event and a `PendingApproval` that blocks the tool until
|
|
72
72
|
`resolvePermission()` (or the timeout) settles it. Allowing echoes the tool input back as
|
|
73
|
-
`updatedInput`
|
|
73
|
+
`updatedInput` - the SDK requires a record even for an unmodified allow. `AskUserQuestion`
|
|
74
74
|
rides the same path; `questionBehavior: 'auto' | 'deny'` policy-resolves it for unattended runs.
|
|
75
|
-
- **Event log**
|
|
75
|
+
- **Event log** - every event gets a monotonic `seq`; `subscribe(listener, afterSeq)` replays the
|
|
76
76
|
buffer past `afterSeq` before delivering live events, so late attachers always catch up.
|
|
77
|
-
- **Resume**
|
|
77
|
+
- **Resume** - pass `resume: sdkSessionId` (optionally `forkSession`). The SDK only re-streams
|
|
78
78
|
user messages, so the runner backfills the full prior transcript from the SDK's on-disk store
|
|
79
79
|
as `replay: true` events before the query starts (`backfillHistory: false` to skip).
|
|
80
|
-
- **Capabilities + usage**
|
|
80
|
+
- **Capabilities + usage** - after init (and eagerly for promptless sessions) the runner fetches
|
|
81
81
|
supported models/slash commands and a context-window snapshot, emitting `capabilities` and
|
|
82
82
|
`context_usage` events; context usage is re-polled after every turn.
|
|
83
83
|
|
|
84
84
|
## The second engine
|
|
85
85
|
|
|
86
86
|
`AiSdkRunner` runs the same protocol against any provider the [AI SDK](https://ai-sdk.dev)
|
|
87
|
-
supports
|
|
87
|
+
supports - no CLI process, no config directory. `createEngineSession()` assembles one: the model,
|
|
88
88
|
the capability-scoped tool set, and the executor that runs tool calls.
|
|
89
89
|
|
|
90
90
|
```ts
|
|
@@ -93,7 +93,7 @@ import { loadEngine } from '@workerdeck/sandbox'
|
|
|
93
93
|
import { createEngineSession, QuickJsExecutor } from '@workerdeck/core'
|
|
94
94
|
|
|
95
95
|
// Server-side, the WASM guest is loaded once for the process and shared by every
|
|
96
|
-
// session. The variant package is a peer dependency you install yourself
|
|
96
|
+
// session. The variant package is a peer dependency you install yourself - core
|
|
97
97
|
// does not pick one for you, because the browser build and the server build are
|
|
98
98
|
// different artifacts and only you know which side this is.
|
|
99
99
|
const executor = new QuickJsExecutor({ engine: await loadEngine(variant), defaultTimeoutMs: 15_000 })
|
|
@@ -118,7 +118,7 @@ Three seams matter here:
|
|
|
118
118
|
with this process's authority and must declare `execute`; `sandboxed` means it rides the executor
|
|
119
119
|
seam and must *not*. Both contradictions are refused at assembly rather than at runtime, because
|
|
120
120
|
a sandboxed tool that quietly ran in-process would defeat the only thing sandboxing it was for.
|
|
121
|
-
- **`ToolExecutor` decides where code runs**, and that is a real architectural choice
|
|
121
|
+
- **`ToolExecutor` decides where code runs**, and that is a real architectural choice - see below.
|
|
122
122
|
|
|
123
123
|
### Which executor?
|
|
124
124
|
|
|
@@ -127,14 +127,14 @@ Three seams matter here:
|
|
|
127
127
|
| Runs where | this Node process, WASM guest | the attached client | wherever you send it |
|
|
128
128
|
| Needs a client attached | no | **yes** | no |
|
|
129
129
|
| Data locality | data must reach the server | client-held data never leaves the tab | n/a |
|
|
130
|
-
| Trust | you own both sides | results are **untrusted input**
|
|
130
|
+
| Trust | you own both sides | results are **untrusted input** - the sandboxed party answers | depends |
|
|
131
131
|
| Latency | in-process | a WS round trip | unbounded (the session parks) |
|
|
132
132
|
|
|
133
133
|
The question to ask is **where the data the loop reasons over already lives**:
|
|
134
134
|
|
|
135
135
|
- In your database or on your disk → in-process. Pushing execution into the tab buys nothing and
|
|
136
136
|
hands an executor to the party you are sandboxing against.
|
|
137
|
-
- In the user's browser
|
|
137
|
+
- In the user's browser - a document they are editing, a file they dropped, something you would
|
|
138
138
|
rather not receive at all → the bridge. This is the case it exists for.
|
|
139
139
|
- Somewhere that answers in minutes or hours (a queue, a human, a build) → deferred, and let the
|
|
140
140
|
session park.
|
|
@@ -151,7 +151,7 @@ session mix all three.
|
|
|
151
151
|
|
|
152
152
|
`DeferredExecutor` dispatches a call and doesn't wait. The runner then **parks**: `park()` returns
|
|
153
153
|
a `RunnerSnapshot`, the process can tear the runner down, and passing that snapshot back as
|
|
154
|
-
`restore` rebuilds the session as itself
|
|
154
|
+
`restore` rebuilds the session as itself - same id, same event log, same seq numbering, mid-turn,
|
|
155
155
|
scratch filesystem included.
|
|
156
156
|
|
|
157
157
|
```ts
|
|
@@ -162,12 +162,12 @@ selectExecutor: () => new DeferredExecutor({
|
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
[`@workerdeck/server`](https://www.npmjs.com/package/@workerdeck/server) drives both halves
|
|
165
|
-
for you
|
|
165
|
+
for you - a `SessionStore` plus `POST /executions/:id/result` - but the mechanism is here, and works
|
|
166
166
|
with no server at all.
|
|
167
167
|
|
|
168
168
|
`snapshot()` is the same value **without** the teardown: the runner stays live, attached and warm.
|
|
169
169
|
That separation is what makes a provider session survive a process restart, since it has no
|
|
170
|
-
engine-side store to resume from the way claude and codex do
|
|
170
|
+
engine-side store to resume from the way claude and codex do - the host writes the snapshot through
|
|
171
171
|
after each turn and rebuilds from the last one. The gate differs from `park()`'s in one direction
|
|
172
172
|
only: it refuses a turn in flight and pending *in-process* executions (whose results die with the
|
|
173
173
|
process), and allows the idle case `park()` exists to refuse.
|
|
@@ -182,18 +182,18 @@ Things the compiler will not tell you, each of which has cost someone real time:
|
|
|
182
182
|
the temptation to truncate into a snapshot: it would break the fetch for exactly the sessions
|
|
183
183
|
most likely to be read late.
|
|
184
184
|
|
|
185
|
-
- **Image refs happen there too
|
|
185
|
+
- **Image refs happen there too - and on the live path as well.** `subscribe(..., { imageRefs })`
|
|
186
186
|
replaces a `tool_result`'s base64 `image` parts with `image_ref` addresses, and unlike truncation
|
|
187
187
|
it applies to live events as well as the replay, because a client's one render path is
|
|
188
188
|
ref-then-fetch. The same "never at emit" rule holds for the same reason: `#events` keeps every
|
|
189
189
|
byte, which is what the fetch route serves back. `SubscriberSet` (`src/lib/subscribers.ts`) is
|
|
190
|
-
where that per-subscriber decision lives
|
|
190
|
+
where that per-subscriber decision lives - a subscriber is a listener *plus what it asked for*,
|
|
191
191
|
so the three runners no longer each own a copy of the answer. Consumers that subscribe with no
|
|
192
|
-
options
|
|
192
|
+
options - parking, notifications, the queue - see everything, as they do for every rule here.
|
|
193
193
|
|
|
194
194
|
- **A declared MCP server that never connected is refused, not degraded.** If a profile's
|
|
195
195
|
`session.mcpServers` names a server and it isn't there, `createEngineSession` throws. The old
|
|
196
|
-
behaviour
|
|
196
|
+
behaviour - start anyway, minus those tools - produced a session that reported perfectly healthy
|
|
197
197
|
while the agent apologised its way through every request that needed it. Pass
|
|
198
198
|
`connectMcpTools(servers, { required: true })` to fail at connect time instead, and hand the
|
|
199
199
|
resulting connection over as `mcp` (not just `mcp.tools`) so the check is exact.
|
|
@@ -201,7 +201,7 @@ Things the compiler will not tell you, each of which has cost someone real time:
|
|
|
201
201
|
`GET` before it sends anything. Mounted under a framework's default 404, the whole connect fails
|
|
202
202
|
with an error that names neither the method nor the route.
|
|
203
203
|
- **Never seed the VFS by hand on a restore.** Use `seedVfs`, which is ignored when
|
|
204
|
-
`config.restore` is set. Building `config.vfs` yourself still works and still wins
|
|
204
|
+
`config.restore` is set. Building `config.vfs` yourself still works and still wins - and then
|
|
205
205
|
overwriting the files the parked turn wrote is yours to avoid.
|
|
206
206
|
- **Forward the host's `id`.** `createEngineSession({ id })` is how a session comes back as
|
|
207
207
|
*itself* across a gateway restart. Dropping it strands every client's route and unread mark, and
|
|
@@ -209,11 +209,11 @@ Things the compiler will not tell you, each of which has cost someone real time:
|
|
|
209
209
|
- **`onClose` runs on park as well as close.** Parking releases the same resources; a disposer that
|
|
210
210
|
assumes the session is over will close an MCP connection the woken session still needs to rebuild.
|
|
211
211
|
- **Authoritative tools are never bridged.** `withMcpTools` marks everything authoritative by
|
|
212
|
-
construction. If you want a host tool the tab may run, declare it `sandboxed` in `tools`
|
|
212
|
+
construction. If you want a host tool the tab may run, declare it `sandboxed` in `tools` - and
|
|
213
213
|
then treat its results as untrusted input, because the tab produced them.
|
|
214
214
|
- **Never make a tool's operation depend on a field being absent.** "Create when `id` is missing,
|
|
215
|
-
overwrite when it is present" is the shape that breaks: models send `""`
|
|
216
|
-
`" "`
|
|
215
|
+
overwrite when it is present" is the shape that breaks: models send `""` - and, observed live,
|
|
216
|
+
`" "` - rather than omitting, and some providers mark every property required so the model
|
|
217
217
|
*cannot* omit. `z.string().min(1).optional()` does not save it (a space has length 1). Split it
|
|
218
218
|
into two tools with required arguments, and trim-and-blank-check optional strings inside `run`.
|
|
219
219
|
|
|
@@ -222,9 +222,9 @@ Things the compiler will not tell you, each of which has cost someone real time:
|
|
|
222
222
|
`InputQueue` (the push-based `AsyncIterable` bridging `sendMessage()` into the SDK's streaming
|
|
223
223
|
prompt), `normalizeSdkMessage`/`toApiMessage` (SDKMessage → protocol event normalization),
|
|
224
224
|
`connectMcpTools` for live MCP over http/sse, and `createWebFetch` with its SSRF guard
|
|
225
|
-
(`isPrivateAddress`). Tests inject a fake `queryFn`
|
|
225
|
+
(`isPrivateAddress`). Tests inject a fake `queryFn` - no real CLI spawn needed.
|
|
226
226
|
|
|
227
227
|
## License
|
|
228
228
|
|
|
229
|
-
MIT © Tobias Strebitzer
|
|
229
|
+
MIT © Tobias Strebitzer -
|
|
230
230
|
[LICENSE](https://github.com/workerdeck/workerdeck/blob/master/LICENSE)
|
package/build/index.d.mts
CHANGED
|
@@ -104,6 +104,7 @@ interface Runner {
|
|
|
104
104
|
eventAt?(seq: number): SessionEvent | undefined;
|
|
105
105
|
sendMessage(text: string, attachments?: readonly AttachmentInput[]): void;
|
|
106
106
|
queueLocalCommand?(result: LocalCommandResult): void;
|
|
107
|
+
refreshUsage?(): Promise<void>;
|
|
107
108
|
mcpServers?(): Promise<McpServerStatusInfo[] | undefined>;
|
|
108
109
|
reconnectMcpServer?(name: string): Promise<void>;
|
|
109
110
|
setMcpServerEnabled?(name: string, enabled: boolean): Promise<void>;
|
|
@@ -176,6 +177,7 @@ declare class SessionRunner implements Runner {
|
|
|
176
177
|
close(reason?: 'client' | 'server' | 'error'): void;
|
|
177
178
|
eventAt(seq: number): SessionEvent | undefined;
|
|
178
179
|
subscribe(listener: SessionEventListener, afterSeq?: number, options?: SubscribeOptions): () => void;
|
|
180
|
+
refreshUsage(minIntervalMs?: number): Promise<void>;
|
|
179
181
|
}
|
|
180
182
|
//#endregion
|
|
181
183
|
//#region src/lib/replay.d.ts
|
package/build/index.mjs
CHANGED
|
@@ -904,6 +904,7 @@ function toolUseBlocks(content) {
|
|
|
904
904
|
}
|
|
905
905
|
//#endregion
|
|
906
906
|
//#region src/engines/claude/runner.ts
|
|
907
|
+
const USAGE_REFRESH_MIN_MS = 6e4;
|
|
907
908
|
var SessionRunner = class {
|
|
908
909
|
id;
|
|
909
910
|
createdAt;
|
|
@@ -929,8 +930,11 @@ var SessionRunner = class {
|
|
|
929
930
|
#idleWhileCompacting = false;
|
|
930
931
|
#query;
|
|
931
932
|
#capabilitiesEmitted = false;
|
|
933
|
+
#models;
|
|
934
|
+
#defaultModel;
|
|
932
935
|
#subscriptionType;
|
|
933
936
|
#engineTitle;
|
|
937
|
+
#lastRateLimitPoll = 0;
|
|
934
938
|
#started = false;
|
|
935
939
|
#closed = false;
|
|
936
940
|
#runPromise;
|
|
@@ -1254,6 +1258,10 @@ var SessionRunner = class {
|
|
|
1254
1258
|
else if (msg.state === "running") this.#setStatus("running");
|
|
1255
1259
|
return;
|
|
1256
1260
|
}
|
|
1261
|
+
if (msg.type === "system" && msg.subtype === "commands_changed") {
|
|
1262
|
+
if (this.#capabilitiesEmitted && !this.#closed) this.#emitCapabilities(msg.commands);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1257
1265
|
if (msg.type === "system" && msg.subtype === "status") this.#handleCompactionStatus(msg);
|
|
1258
1266
|
if (msg.type === "system" && msg.subtype === "compact_boundary") {
|
|
1259
1267
|
const meta = msg.compact_metadata;
|
|
@@ -1330,19 +1338,24 @@ var SessionRunner = class {
|
|
|
1330
1338
|
const [models, commands] = await Promise.all([query.supportedModels(), query.supportedCommands()]);
|
|
1331
1339
|
if (this.#closed || this.#capabilitiesEmitted) return;
|
|
1332
1340
|
this.#capabilitiesEmitted = true;
|
|
1333
|
-
this.#
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
defaultModel: defaultModelFromSdk(models),
|
|
1337
|
-
commands: commands.map((c) => ({
|
|
1338
|
-
name: c.name,
|
|
1339
|
-
description: c.description,
|
|
1340
|
-
argumentHint: c.argumentHint,
|
|
1341
|
-
aliases: c.aliases
|
|
1342
|
-
}))
|
|
1343
|
-
});
|
|
1341
|
+
this.#models = modelOptionsFromSdk(models);
|
|
1342
|
+
this.#defaultModel = defaultModelFromSdk(models);
|
|
1343
|
+
this.#emitCapabilities(commands);
|
|
1344
1344
|
} catch {}
|
|
1345
1345
|
}
|
|
1346
|
+
#emitCapabilities(commands) {
|
|
1347
|
+
this.#emit({
|
|
1348
|
+
type: "capabilities",
|
|
1349
|
+
models: this.#models ?? [],
|
|
1350
|
+
defaultModel: this.#defaultModel,
|
|
1351
|
+
commands: commands.map((c) => ({
|
|
1352
|
+
name: c.name,
|
|
1353
|
+
description: c.description,
|
|
1354
|
+
argumentHint: c.argumentHint,
|
|
1355
|
+
aliases: c.aliases
|
|
1356
|
+
}))
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1346
1359
|
async #fetchEngineTitle() {
|
|
1347
1360
|
if (hostTitle(this.#config.meta)) return;
|
|
1348
1361
|
const sdkSessionId = this.#sdkSessionId;
|
|
@@ -1378,7 +1391,12 @@ var SessionRunner = class {
|
|
|
1378
1391
|
});
|
|
1379
1392
|
} catch {}
|
|
1380
1393
|
}
|
|
1394
|
+
async refreshUsage(minIntervalMs = USAGE_REFRESH_MIN_MS) {
|
|
1395
|
+
if (Date.now() - this.#lastRateLimitPoll < minIntervalMs) return;
|
|
1396
|
+
await this.#fetchRateLimits();
|
|
1397
|
+
}
|
|
1381
1398
|
async #fetchRateLimits() {
|
|
1399
|
+
this.#lastRateLimitPoll = Date.now();
|
|
1382
1400
|
const query = this.#query;
|
|
1383
1401
|
const fetchUsage = query?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
|
|
1384
1402
|
if (typeof fetchUsage !== "function") return;
|
|
@@ -1449,7 +1467,7 @@ var SessionRunner = class {
|
|
|
1449
1467
|
request
|
|
1450
1468
|
});
|
|
1451
1469
|
if (mode === "deny") {
|
|
1452
|
-
const message = "Interactive questions are disabled for this session
|
|
1470
|
+
const message = "Interactive questions are disabled for this session - choose the most reasonable option yourself and continue.";
|
|
1453
1471
|
this.#emit({
|
|
1454
1472
|
type: "permission_resolved",
|
|
1455
1473
|
requestId: request.id,
|
|
@@ -2924,8 +2942,8 @@ function withHostTools(context, hostTools, kind = "host tool") {
|
|
|
2924
2942
|
for (const [name, { tool: hostTool, trust, title }] of entries) {
|
|
2925
2943
|
if (name in tools) throw new Error(`${kind} '${name}' collides with an existing tool of the same name`);
|
|
2926
2944
|
const executes = typeof hostTool.execute === "function";
|
|
2927
|
-
if (trust === "sandboxed" && executes) throw new Error(`${kind} '${name}' is declared sandboxed but has an \`execute\`
|
|
2928
|
-
if (trust === "authoritative" && !executes) throw new Error(`${kind} '${name}' is declared authoritative but has no \`execute\`
|
|
2945
|
+
if (trust === "sandboxed" && executes) throw new Error(`${kind} '${name}' is declared sandboxed but has an \`execute\` - it would run in this process with full authority. Drop \`execute\` so it rides the ToolExecutor seam.`);
|
|
2946
|
+
if (trust === "authoritative" && !executes) throw new Error(`${kind} '${name}' is declared authoritative but has no \`execute\` - nothing would ever answer its calls and the turn would stall.`);
|
|
2929
2947
|
definitions.push({
|
|
2930
2948
|
name,
|
|
2931
2949
|
trust,
|
|
@@ -3429,7 +3447,7 @@ const claudeAdapter = {
|
|
|
3429
3447
|
if (status === "logged_in") return { available: true };
|
|
3430
3448
|
if (status === "logged_out") return {
|
|
3431
3449
|
available: false,
|
|
3432
|
-
reason: `no usable Claude credentials for this profile's environment
|
|
3450
|
+
reason: `no usable Claude credentials for this profile's environment - log in under its config dir (CLAUDE_CONFIG_DIR=${profile.configDir ?? "~/.claude"} claude auth login) or set ANTHROPIC_API_KEY`
|
|
3433
3451
|
};
|
|
3434
3452
|
return { available: "unknown" };
|
|
3435
3453
|
},
|
|
@@ -3898,7 +3916,7 @@ function untrustedProjectNotice(options) {
|
|
|
3898
3916
|
if (ignored.length === 0) return;
|
|
3899
3917
|
const trustDir = gitRoot ?? cwd;
|
|
3900
3918
|
const configs = ignored.map((layer) => join(layer, ".codex", "config.toml"));
|
|
3901
|
-
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
|
|
3919
|
+
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}.`;
|
|
3902
3920
|
}
|
|
3903
3921
|
//#endregion
|
|
3904
3922
|
//#region src/engines/codex/runner.ts
|
|
@@ -4193,6 +4211,10 @@ const APPROVAL_CHANNELS = {
|
|
|
4193
4211
|
deny: (_raw, interrupt) => ({ response: { action: interrupt ? "cancel" : "decline" } })
|
|
4194
4212
|
}
|
|
4195
4213
|
};
|
|
4214
|
+
function steerUnsupported(error) {
|
|
4215
|
+
if (!(error instanceof JsonRpcError)) return false;
|
|
4216
|
+
return error.code === -32601 || error.code === -32600 && error.message.includes("unknown variant `turn/steer`");
|
|
4217
|
+
}
|
|
4196
4218
|
function rateLimitWindowName(minutes) {
|
|
4197
4219
|
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return;
|
|
4198
4220
|
if (minutes === 300) return "five_hour";
|
|
@@ -4237,6 +4259,8 @@ var CodexRunner = class {
|
|
|
4237
4259
|
#mcpStatus = /* @__PURE__ */ new Map();
|
|
4238
4260
|
#agents = new CodexAgentTracker();
|
|
4239
4261
|
#clearedThreads = /* @__PURE__ */ new Set();
|
|
4262
|
+
#cannotSteer = /* @__PURE__ */ new WeakSet();
|
|
4263
|
+
#clearsPending = 0;
|
|
4240
4264
|
constructor(config, id = randomUUID()) {
|
|
4241
4265
|
const mode = config.permissionMode ?? "default";
|
|
4242
4266
|
if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
|
|
@@ -4372,9 +4396,52 @@ var CodexRunner = class {
|
|
|
4372
4396
|
});
|
|
4373
4397
|
if (this.#backfillPending) this.#turnChain = this.#turnChain.then(echo);
|
|
4374
4398
|
else echo();
|
|
4399
|
+
this.#dispatch(input);
|
|
4400
|
+
}
|
|
4401
|
+
#dispatch(input) {
|
|
4402
|
+
const active = this.#activeTurn;
|
|
4403
|
+
if (!active || !this.#steerable(active)) {
|
|
4404
|
+
this.#enqueueTurn(input);
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
active.steerChain = active.steerChain.then(async () => {
|
|
4408
|
+
await active.steerGate;
|
|
4409
|
+
await this.#steer(active, input);
|
|
4410
|
+
});
|
|
4411
|
+
}
|
|
4412
|
+
#steerable(active) {
|
|
4413
|
+
if (active.settled || active.interrupted || this.#clearsPending > 0) return false;
|
|
4414
|
+
return !this.#connection || !this.#cannotSteer.has(this.#connection);
|
|
4415
|
+
}
|
|
4416
|
+
async #steer(active, input) {
|
|
4417
|
+
const connection = this.#connection;
|
|
4418
|
+
const threadId = this.#sdkSessionId;
|
|
4419
|
+
const turnId = active.turnId;
|
|
4420
|
+
if (!connection || !threadId || !turnId || active.settled || active.interrupted || this.#cannotSteer.has(connection)) {
|
|
4421
|
+
this.#enqueueTurn(input);
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4424
|
+
try {
|
|
4425
|
+
await connection.request("turn/steer", {
|
|
4426
|
+
threadId,
|
|
4427
|
+
expectedTurnId: turnId,
|
|
4428
|
+
input
|
|
4429
|
+
});
|
|
4430
|
+
} catch (error) {
|
|
4431
|
+
if (steerUnsupported(error)) this.#cannotSteer.add(connection);
|
|
4432
|
+
this.#enqueueTurn(input);
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
#enqueueTurn(input) {
|
|
4436
|
+
if (this.#closed) return;
|
|
4375
4437
|
this.#queue.push({ input });
|
|
4376
4438
|
this.#scheduleTurn();
|
|
4377
4439
|
}
|
|
4440
|
+
#adoptTurnId(active, turnId) {
|
|
4441
|
+
if (active.turnId || typeof turnId !== "string" || !turnId) return;
|
|
4442
|
+
active.turnId = turnId;
|
|
4443
|
+
active.openSteerGate();
|
|
4444
|
+
}
|
|
4378
4445
|
#buildInput(text, attachments) {
|
|
4379
4446
|
const parts = [];
|
|
4380
4447
|
for (const attachment of attachments) {
|
|
@@ -4445,7 +4512,10 @@ var CodexRunner = class {
|
|
|
4445
4512
|
}
|
|
4446
4513
|
async clearContext() {
|
|
4447
4514
|
if (this.#closed) throw new Error("session is closed");
|
|
4448
|
-
|
|
4515
|
+
this.#clearsPending += 1;
|
|
4516
|
+
const run = this.#turnChain.then(() => this.#clearNow()).finally(() => {
|
|
4517
|
+
this.#clearsPending -= 1;
|
|
4518
|
+
});
|
|
4449
4519
|
this.#turnChain = run.then(() => void 0, () => void 0);
|
|
4450
4520
|
await run;
|
|
4451
4521
|
}
|
|
@@ -4600,7 +4670,7 @@ var CodexRunner = class {
|
|
|
4600
4670
|
} catch (error) {
|
|
4601
4671
|
connection.close();
|
|
4602
4672
|
if (this.#connection === connection) this.#connection = void 0;
|
|
4603
|
-
if (error instanceof JsonRpcError) throw new Error("codex app-server rejected initialize (capabilities.experimentalApi: true
|
|
4673
|
+
if (error instanceof JsonRpcError) throw new Error("codex app-server rejected initialize (capabilities.experimentalApi: true is required for the granular approval policy, and WorkerDeck has no non-experimental fallback): " + error.message, { cause: error });
|
|
4604
4674
|
throw error;
|
|
4605
4675
|
}
|
|
4606
4676
|
connection.notify("initialized");
|
|
@@ -4709,7 +4779,7 @@ var CodexRunner = class {
|
|
|
4709
4779
|
}
|
|
4710
4780
|
if (partialReason) this.#emit({
|
|
4711
4781
|
type: "session_error",
|
|
4712
|
-
message: `Resumed thread history is incomplete
|
|
4782
|
+
message: `Resumed thread history is incomplete: older turns could not be loaded (${partialReason})`
|
|
4713
4783
|
});
|
|
4714
4784
|
this.#replayTurns(turns);
|
|
4715
4785
|
} catch {} finally {
|
|
@@ -4746,6 +4816,10 @@ var CodexRunner = class {
|
|
|
4746
4816
|
}
|
|
4747
4817
|
}
|
|
4748
4818
|
#newTurnState() {
|
|
4819
|
+
let openSteerGate;
|
|
4820
|
+
const steerGate = new Promise((resolve) => {
|
|
4821
|
+
openSteerGate = resolve;
|
|
4822
|
+
});
|
|
4749
4823
|
return {
|
|
4750
4824
|
nonce: randomUUID(),
|
|
4751
4825
|
interrupted: false,
|
|
@@ -4761,6 +4835,9 @@ var CodexRunner = class {
|
|
|
4761
4835
|
toolUseEmitted: /* @__PURE__ */ new Set(),
|
|
4762
4836
|
sectionIndex: /* @__PURE__ */ new Map(),
|
|
4763
4837
|
settled: false,
|
|
4838
|
+
steerGate,
|
|
4839
|
+
openSteerGate,
|
|
4840
|
+
steerChain: Promise.resolve(),
|
|
4764
4841
|
resolve: () => {},
|
|
4765
4842
|
reject: () => {}
|
|
4766
4843
|
};
|
|
@@ -4776,11 +4853,13 @@ var CodexRunner = class {
|
|
|
4776
4853
|
active.resolve = (turnResult) => {
|
|
4777
4854
|
if (active.settled) return;
|
|
4778
4855
|
active.settled = true;
|
|
4856
|
+
active.openSteerGate();
|
|
4779
4857
|
resolve(turnResult);
|
|
4780
4858
|
};
|
|
4781
4859
|
active.reject = (error) => {
|
|
4782
4860
|
if (active.settled) return;
|
|
4783
4861
|
active.settled = true;
|
|
4862
|
+
active.openSteerGate();
|
|
4784
4863
|
reject(error);
|
|
4785
4864
|
};
|
|
4786
4865
|
});
|
|
@@ -4802,7 +4881,7 @@ var CodexRunner = class {
|
|
|
4802
4881
|
connection.request("turn/start", params).then((result) => {
|
|
4803
4882
|
const started = result?.turn;
|
|
4804
4883
|
if (!started) return;
|
|
4805
|
-
active
|
|
4884
|
+
this.#adoptTurnId(active, started.id);
|
|
4806
4885
|
if (started.status && started.status !== "inProgress") active.resolve(started);
|
|
4807
4886
|
}, (error) => active.reject(error instanceof Error ? error : new Error(String(error))));
|
|
4808
4887
|
const result = await outcome;
|
|
@@ -4896,7 +4975,7 @@ var CodexRunner = class {
|
|
|
4896
4975
|
"turn/started": (params) => {
|
|
4897
4976
|
const active = this.#activeTurn;
|
|
4898
4977
|
const turn = params?.turn;
|
|
4899
|
-
if (active && turn
|
|
4978
|
+
if (active && turn) this.#adoptTurnId(active, turn.id);
|
|
4900
4979
|
},
|
|
4901
4980
|
"turn/completed": (params) => {
|
|
4902
4981
|
const active = this.#activeTurn;
|
|
@@ -5039,7 +5118,7 @@ var CodexRunner = class {
|
|
|
5039
5118
|
requestId: request.id,
|
|
5040
5119
|
behavior: "deny",
|
|
5041
5120
|
resolvedBy: "policy",
|
|
5042
|
-
message: "Interactive questions are disabled for this session
|
|
5121
|
+
message: "Interactive questions are disabled for this session: choose the most reasonable option yourself and continue."
|
|
5043
5122
|
});
|
|
5044
5123
|
return { answers: {} };
|
|
5045
5124
|
}
|
|
@@ -5068,7 +5147,7 @@ var CodexRunner = class {
|
|
|
5068
5147
|
else {
|
|
5069
5148
|
behavior = "deny";
|
|
5070
5149
|
resolvedBy = "policy";
|
|
5071
|
-
message = "codex offered no plain accept for this request (only broader session/policy grants)
|
|
5150
|
+
message = "codex offered no plain accept for this request (only broader session/policy grants), denied instead";
|
|
5072
5151
|
sent = pending.channel.deny(pending.params, false, pending.offered);
|
|
5073
5152
|
}
|
|
5074
5153
|
} else sent = pending.channel.deny(pending.params, decision.interrupt === true, pending.offered);
|
|
@@ -5224,7 +5303,7 @@ var CodexRunner = class {
|
|
|
5224
5303
|
agentThreadId: item.agentThreadId,
|
|
5225
5304
|
...item.agentPath ? { agentPath: item.agentPath } : {}
|
|
5226
5305
|
}, agent);
|
|
5227
|
-
this.#emitToolResult(id, "(ran in its own thread
|
|
5306
|
+
this.#emitToolResult(id, "(ran in its own thread, so its work is not part of this thread's stored history)", false, void 0, agent?.toolUseId ?? null);
|
|
5228
5307
|
return;
|
|
5229
5308
|
}
|
|
5230
5309
|
const record = this.#agents.get(item.agentThreadId) ?? this.#agents.open(item.agentThreadId, id, void 0, Date.now());
|
|
@@ -5246,6 +5325,13 @@ var CodexRunner = class {
|
|
|
5246
5325
|
}
|
|
5247
5326
|
return;
|
|
5248
5327
|
}
|
|
5328
|
+
if (item.kind === "completed") {
|
|
5329
|
+
if (record.status === "running") {
|
|
5330
|
+
this.#agents.settle(record, "done");
|
|
5331
|
+
this.#emitToolResult(record.toolUseId, "", false);
|
|
5332
|
+
}
|
|
5333
|
+
return;
|
|
5334
|
+
}
|
|
5249
5335
|
if (item.kind !== "started" && record.status !== "running") this.#agents.revive(record);
|
|
5250
5336
|
},
|
|
5251
5337
|
collabAgentToolCall: (item, active, id, agent) => {
|
|
@@ -5546,7 +5632,7 @@ function connectAppServer(options) {
|
|
|
5546
5632
|
}
|
|
5547
5633
|
//#endregion
|
|
5548
5634
|
//#region src/engines/codex/adapter.ts
|
|
5549
|
-
const NOT_INSTALLED = "@openai/codex is not installed
|
|
5635
|
+
const NOT_INSTALLED = "@openai/codex is not installed - add it (an optional peer of @workerdeck/core) to run codex profiles";
|
|
5550
5636
|
function resolveBundledCodexExecutable() {
|
|
5551
5637
|
const triple = targetTriple();
|
|
5552
5638
|
if (!triple) return;
|
|
@@ -5582,10 +5668,10 @@ async function checkCodexAvailability(profile, env, options = {}) {
|
|
|
5582
5668
|
return;
|
|
5583
5669
|
}
|
|
5584
5670
|
if (`${stdout}\n${stderr}`.includes("Not logged in")) {
|
|
5585
|
-
const hint = childEnv.CODEX_API_KEY ? " CODEX_API_KEY is read only by `codex exec`, never by the app-server
|
|
5671
|
+
const hint = childEnv.CODEX_API_KEY ? " CODEX_API_KEY is read only by `codex exec`, never by the app-server - run `codex login --with-api-key` under this profile’s CODEX_HOME to persist it." : childEnv.OPENAI_API_KEY ? " OPENAI_API_KEY is not used by codex - run `codex login --with-api-key` under this profile’s CODEX_HOME." : "";
|
|
5586
5672
|
resolve({
|
|
5587
5673
|
available: false,
|
|
5588
|
-
reason: `codex is not logged in for this profile's environment
|
|
5674
|
+
reason: `codex is not logged in for this profile's environment - run \`codex login\`` + (profile.codexHome ? ` with CODEX_HOME=${profile.codexHome}` : "") + `.${hint}`
|
|
5589
5675
|
});
|
|
5590
5676
|
return;
|
|
5591
5677
|
}
|