@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 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 — same skills, same `CLAUDE.md`, same
11
- permission system — and both runners emit
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 — for
28
- `eval_script` — [`@workerdeck/sandbox`](https://www.npmjs.com/package/@workerdeck/sandbox);
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 — the one you pass back as `resume`.
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** — the runner's `canUseTool` hook turns each uncovered tool call into a
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` — the SDK requires a record even for an unmodified allow. `AskUserQuestion`
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** — every event gets a monotonic `seq`; `subscribe(listener, afterSeq)` replays the
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** — pass `resume: sdkSessionId` (optionally `forkSession`). The SDK only re-streams
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** — after init (and eagerly for promptless sessions) the runner fetches
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 — no CLI process, no config directory. `createEngineSession()` assembles one: the model,
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 — core
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 — see below.
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** — the sandboxed party answers | depends |
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 — a document they are editing, a file they dropped, something you would
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 — same id, same event log, same seq numbering, mid-turn,
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 — a `SessionStore` plus `POST /executions/:id/result` — but the mechanism is here, and works
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 — the host writes the snapshot through
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 — and on the live path as well.** `subscribe(..., { imageRefs })`
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 — a subscriber is a listener *plus what it asked for*,
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 — parking, notifications, the queue — see everything, as they do for every rule here.
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 — start anyway, minus those tools — produced a session that reported perfectly healthy
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 — and then
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` — and
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 `""` — and, observed live,
216
- `" "` — rather than omitting, and some providers mark every property required so the model
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` — no real CLI spawn needed.
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.#emit({
1334
- type: "capabilities",
1335
- models: modelOptionsFromSdk(models),
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 — choose the most reasonable option yourself and continue.";
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\` — it would run in this process with full authority. Drop \`execute\` so it rides the ToolExecutor seam.`);
2928
- 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.`);
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 — log in under its config dir (CLAUDE_CONFIG_DIR=${profile.configDir ?? "~/.claude"} claude auth login) or set ANTHROPIC_API_KEY`
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 — 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}.`;
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
- const run = this.#turnChain.then(() => this.#clearNow());
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 — required for the granular approval policy, and WorkerDeck has no non-experimental fallback): " + error.message, { cause: error });
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 — older turns could not be loaded (${partialReason})`
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.turnId ??= started.id;
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 && !active.turnId) active.turnId = turn.id;
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 — choose the most reasonable option yourself and continue."
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) — denied instead";
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 — its work is not part of this thread's stored history)", false, void 0, agent?.toolUseId ?? null);
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 — add it (an optional peer of @workerdeck/core) to run codex profiles";
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 — 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." : "";
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 — run \`codex login\`` + (profile.codexHome ? ` with CODEX_HOME=${profile.codexHome}` : "") + `.${hint}`
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
  }