@workerdeck/core 0.17.0 → 0.19.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.mjs CHANGED
@@ -1,15 +1,15 @@
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, 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 } from "@workerdeck/protocol";
5
5
  import { ToolLoopAgent, generateText, isStepCount, tool } from "ai";
6
6
  import { execFile, spawn } from "node:child_process";
7
- import { existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
8
8
  import { createVfs, runScript } from "@workerdeck/sandbox";
9
9
  import { z } from "zod";
10
10
  import { lookup } from "node:dns/promises";
11
- import { tmpdir } from "node:os";
12
- import { join } from "node:path";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { dirname, join, resolve, sep } from "node:path";
13
13
  //#region src/lib/attachments.ts
14
14
  /** The four the Anthropic API accepts. Notably absent: image/heic — an iPhone's
15
15
  * native photo format, which clients must transcode before upload. */
@@ -1100,6 +1100,13 @@ var SessionRunner = class {
1100
1100
  #events = [];
1101
1101
  #subscribers = new SubscriberSet();
1102
1102
  #seq = 0;
1103
+ /**
1104
+ * Latest context-window reading, retained from the last `context_usage` this
1105
+ * runner emitted so `GET /sessions` can answer it without an attach — see
1106
+ * `SessionInfo.contextUsage`. Folded in the emit path, so it is by
1107
+ * construction the same number the transcript last drew.
1108
+ */
1109
+ #contextUsage;
1103
1110
  #activityCount = 0;
1104
1111
  /**
1105
1112
  * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
@@ -1194,6 +1201,7 @@ var SessionRunner = class {
1194
1201
  createdAt: this.createdAt,
1195
1202
  lastSeq: this.#seq,
1196
1203
  activityCount: this.#activityCount,
1204
+ contextUsage: this.#contextUsage,
1197
1205
  pendingPermissionCount: this.#pending.size,
1198
1206
  subagents: this.#subagents.list(),
1199
1207
  meta: this.#config.meta,
@@ -1768,7 +1776,11 @@ var SessionRunner = class {
1768
1776
  };
1769
1777
  this.#lastActivityAt = event.ts;
1770
1778
  this.#activityCount += transcriptActivity(body);
1771
- if (body.type === "conversation_reset") this.#resetSeq = event.seq;
1779
+ this.#contextUsage = contextReading(body) ?? this.#contextUsage;
1780
+ if (body.type === "conversation_reset") {
1781
+ this.#resetSeq = event.seq;
1782
+ this.#contextUsage = void 0;
1783
+ }
1772
1784
  this.#subagents.observe(body, event.ts);
1773
1785
  this.#events.push(event);
1774
1786
  this.#subscribers.emit(event);
@@ -1816,6 +1828,13 @@ var AiSdkRunner = class {
1816
1828
  #events = [];
1817
1829
  #subscribers = new SubscriberSet();
1818
1830
  #seq = 0;
1831
+ /**
1832
+ * Latest context-window reading, retained from the last `context_usage` this
1833
+ * runner emitted so `GET /sessions` can answer it without an attach — see
1834
+ * `SessionInfo.contextUsage`. Folded in the emit path, so it is by
1835
+ * construction the same number the transcript last drew.
1836
+ */
1837
+ #contextUsage;
1819
1838
  #activityCount = 0;
1820
1839
  #status = "starting";
1821
1840
  #permissionMode;
@@ -1865,7 +1884,12 @@ var AiSdkRunner = class {
1865
1884
  if (!state || !Array.isArray(state.messages)) throw new Error("session snapshot is missing its provider-engine state");
1866
1885
  this.#seq = snapshot.seq;
1867
1886
  this.#events = [...snapshot.events];
1868
- this.#activityCount = this.#events.reduce((total, event) => total + transcriptActivity(event), 0);
1887
+ this.#activityCount = 0;
1888
+ for (const event of this.#events) {
1889
+ this.#activityCount += transcriptActivity(event);
1890
+ if (event.type === "conversation_reset") this.#contextUsage = void 0;
1891
+ else this.#contextUsage = contextReading(event) ?? this.#contextUsage;
1892
+ }
1869
1893
  this.#messages = [...state.messages];
1870
1894
  for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
1871
1895
  this.#dispatched = new Set(state.dispatched);
@@ -1916,6 +1940,7 @@ var AiSdkRunner = class {
1916
1940
  createdAt: this.createdAt,
1917
1941
  lastSeq: this.#seq,
1918
1942
  activityCount: this.#activityCount,
1943
+ contextUsage: this.#contextUsage,
1919
1944
  pendingPermissionCount: 0,
1920
1945
  meta: this.#config.meta,
1921
1946
  scope: this.#config.scope,
@@ -2602,6 +2627,8 @@ var AiSdkRunner = class {
2602
2627
  };
2603
2628
  this.#lastActivityAt = event.ts;
2604
2629
  this.#activityCount += transcriptActivity(body);
2630
+ this.#contextUsage = contextReading(body) ?? this.#contextUsage;
2631
+ if (body.type === "conversation_reset") this.#contextUsage = void 0;
2605
2632
  this.#events.push(event);
2606
2633
  this.#subscribers.emit(event);
2607
2634
  }
@@ -3822,8 +3849,6 @@ const claudeAdapter = {
3822
3849
  }));
3823
3850
  }
3824
3851
  };
3825
- //#endregion
3826
- //#region src/engines/codex/jsonrpc.ts
3827
3852
  /**
3828
3853
  * A JSON-RPC error response from the peer, or one we return to it. `code`
3829
3854
  * follows the JSON-RPC 2.0 reserved ranges (-32601 = method not found).
@@ -3855,9 +3880,12 @@ var JsonRpcStdioConnection = class {
3855
3880
  #buffer = "";
3856
3881
  #closed = false;
3857
3882
  #notificationHandler;
3883
+ /** Where {@link CODEX_TRACE_ENV} pointed, or undefined — read once. */
3884
+ #trace;
3858
3885
  #requestHandler;
3859
3886
  constructor(options) {
3860
3887
  this.#output = options.output;
3888
+ this.#trace = process.env["WORKERDECK_CODEX_TRACE"] || void 0;
3861
3889
  options.input.on("data", (chunk) => this.#feed(String(chunk)));
3862
3890
  options.input.on("error", () => {});
3863
3891
  options.output.on("error", () => {});
@@ -3918,9 +3946,28 @@ var JsonRpcStdioConnection = class {
3918
3946
  } catch {
3919
3947
  continue;
3920
3948
  }
3949
+ this.#traceLine(message);
3921
3950
  this.#dispatch(message);
3922
3951
  }
3923
3952
  }
3953
+ /**
3954
+ * Append one inbound message to the trace file, when the operator asked for
3955
+ * one. **Notifications and server→client requests only** — a response body is
3956
+ * not needed to answer the questions this exists for, and `account/*` results
3957
+ * are the one place app-server traffic can carry a masked credential
3958
+ * fragment, which nothing of ours writes to disk (see the auth red lines).
3959
+ * Best-effort and synchronous: a debug sink that loses lines proves nothing,
3960
+ * and a debug sink that throws must not take the session with it.
3961
+ */
3962
+ #traceLine(message) {
3963
+ if (!this.#trace) return;
3964
+ const method = message.method;
3965
+ if (typeof method !== "string") return;
3966
+ if (method.startsWith("account/") || method.startsWith("login")) return;
3967
+ try {
3968
+ appendFileSync(this.#trace, JSON.stringify(message) + "\n");
3969
+ } catch {}
3970
+ }
3924
3971
  #dispatch(message) {
3925
3972
  const { id, method } = message;
3926
3973
  if (typeof method === "string") {
@@ -3959,6 +4006,441 @@ var JsonRpcStdioConnection = class {
3959
4006
  }
3960
4007
  };
3961
4008
  //#endregion
4009
+ //#region src/engines/codex/subagents.ts
4010
+ /**
4011
+ * The codex side of `SessionInfo.subagents` — and the attribution table that
4012
+ * gives every event a spawned agent produces its `parentToolUseId`.
4013
+ *
4014
+ * Codex's signal is stronger than the claude engine's, so this is deliberately
4015
+ * NOT that tracker generalised (`engines/claude/subagents.ts` infers spawns
4016
+ * from tool names and verdicts from result-text sniffing, ~290 lines of module
4017
+ * doc explaining the inference). Here nothing is inferred: `subAgentActivity
4018
+ * {kind: 'started'}` on the owning thread positively announces an agent, names
4019
+ * it (`agentPath`), keys it (`agentThreadId` — the id every one of its later
4020
+ * notifications carries) and hands over the model's own `spawn_agent` call id;
4021
+ * the agent's end is its own thread's `turn/completed`, status included. So a
4022
+ * record is keyed by **thread id** — the wire's handle — while exposing a
4023
+ * **tool-use id** — the protocol's: `parentToolUseId` on nested events must
4024
+ * equal the anchor `tool_use`'s id for `subagentItems` (the frame membership
4025
+ * rule every client shares) to reassemble the sidechain, and this map is where
4026
+ * the two vocabularies meet.
4027
+ *
4028
+ * Two decisions worth their prose:
4029
+ *
4030
+ * **A record survives the runner's turns.** Codex agents are designed to
4031
+ * outlive the root turn that spawned them (`sendInput`/`resumeAgent` address a
4032
+ * thread that kept existing), so — unlike a pending approval — nothing here is
4033
+ * swept when a root turn ends. What does end every agent is the app-server
4034
+ * process itself: the runner calls {@link sweep} when the child dies or the
4035
+ * session closes, because an agent whose host process is gone can never report,
4036
+ * and `running` on a closed session would be a lie a polled list re-renders
4037
+ * forever (the claude tracker's argument, inherited whole).
4038
+ *
4039
+ * **The settled tail is bounded, running records never are** — the same
4040
+ * {@link SUBAGENT_HISTORY} discipline as the claude tracker, and enforced at
4041
+ * settle time for the same reason: a settle happens once per agent, `list()`
4042
+ * once per row of a 1.2s-polled sessions list.
4043
+ */
4044
+ var CodexAgentTracker = class {
4045
+ #byThread = /* @__PURE__ */ new Map();
4046
+ #settleCounter = 0;
4047
+ /** The record whose thread this is — the attribution lookup. */
4048
+ get(agentThreadId) {
4049
+ return this.#byThread.get(agentThreadId);
4050
+ }
4051
+ /** Open (or return) the record for a thread. Fill-in, never overwrite: a
4052
+ * label-less fallback record keeps its accumulated count and its already
4053
+ * published toolUseId when the announcing item arrives late. */
4054
+ open(agentThreadId, toolUseId, agentType, ts) {
4055
+ let record = this.#byThread.get(agentThreadId);
4056
+ if (!record) {
4057
+ record = {
4058
+ agentThreadId,
4059
+ toolUseId,
4060
+ status: "running",
4061
+ startedAt: ts,
4062
+ toolCount: 0,
4063
+ counted: /* @__PURE__ */ new Set()
4064
+ };
4065
+ this.#byThread.set(agentThreadId, record);
4066
+ }
4067
+ record.agentType ??= agentType;
4068
+ return record;
4069
+ }
4070
+ /** The agent's thread ran again (`kind: 'interacted'`, or a fresh
4071
+ * `turn/started` on its thread): a settled verdict no longer describes it. */
4072
+ revive(record) {
4073
+ record.status = "running";
4074
+ record.settledOrder = void 0;
4075
+ }
4076
+ #settle(record, status) {
4077
+ record.status = status;
4078
+ record.settledOrder = ++this.#settleCounter;
4079
+ let settled = 0;
4080
+ for (const r of this.#byThread.values()) if (r.settledOrder !== void 0) settled++;
4081
+ while (settled > SUBAGENT_HISTORY) {
4082
+ let oldest;
4083
+ for (const r of this.#byThread.values()) {
4084
+ if (r.settledOrder === void 0) continue;
4085
+ if (!oldest || r.settledOrder < oldest.settledOrder) oldest = r;
4086
+ }
4087
+ if (!oldest) break;
4088
+ this.#byThread.delete(oldest.agentThreadId);
4089
+ settled--;
4090
+ }
4091
+ }
4092
+ /** A real verdict for one agent — its thread's `turn/completed`, or the
4093
+ * `interrupted` activity edge. */
4094
+ settle(record, status) {
4095
+ if (record.status === status) return;
4096
+ this.#settle(record, status);
4097
+ }
4098
+ /** The process the agents lived in is gone (child death, session close):
4099
+ * everything still running is settled as failed — the report can never come. */
4100
+ sweep() {
4101
+ for (const record of this.#byThread.values()) if (record.status === "running") this.#settle(record, "failed");
4102
+ }
4103
+ /** The rollup as `SessionInfo.subagents` serves it — spawn order, fresh
4104
+ * objects, and `undefined` when there is nothing to say (absent and empty
4105
+ * mean the same thing to a client, and bytes on a polled list are paid for). */
4106
+ list() {
4107
+ if (this.#byThread.size === 0) return void 0;
4108
+ const out = [];
4109
+ for (const r of this.#byThread.values()) out.push({
4110
+ toolUseId: r.toolUseId,
4111
+ agentType: r.agentType,
4112
+ status: r.status,
4113
+ startedAt: r.startedAt,
4114
+ toolCount: r.toolCount
4115
+ });
4116
+ return out;
4117
+ }
4118
+ };
4119
+ //#endregion
4120
+ //#region src/engines/codex/trust.ts
4121
+ /**
4122
+ * Codex project trust: will this session's cwd get its `.codex/config.toml`?
4123
+ *
4124
+ * Codex only layers a project's `.codex/config.toml` onto the operator's base
4125
+ * config when the project is *trusted* (a `[projects."<path>"]
4126
+ * trust_level = "trusted"` entry in `$CODEX_HOME/config.toml`), and the
4127
+ * app-server surface has no trust prompt — that lives in the TUI. So under
4128
+ * WorkerDeck an untrusted project's config, MCP servers included, is silently
4129
+ * ignored: no error, no notice, servers just missing. The runner asks this
4130
+ * module at session start whether that is about to happen, so the transcript
4131
+ * can say so.
4132
+ *
4133
+ * Semantics, all measured against both the bundled 0.146.0 and 0.149.0
4134
+ * (2026-08-22, via `codex mcp list` from probe cwds and via `thread/start` +
4135
+ * `mcpServerStatus/list` on the app-server surface — identical answers):
4136
+ *
4137
+ * - **Discovery**: config layers come from the cwd and its ancestors up to and
4138
+ * including the nearest directory containing `.git` (dir or file). With no
4139
+ * git anywhere above, the cwd alone is consulted. Directories above the
4140
+ * nearest git root never contribute, trusted or not.
4141
+ * - **Trust per layer**: an exact entry for the layer's own canonical path
4142
+ * decides (an explicit `"untrusted"` beats inherited trust); without one the
4143
+ * layer inherits from the chain's git root — trusted iff the git root has a
4144
+ * trusted entry, where a linked worktree's root also counts its main
4145
+ * repository's entry (the `.git` file's gitdir names it). A trusted
4146
+ * mid-chain directory does NOT trust its children, and plain path
4147
+ * containment without git confers nothing.
4148
+ * - **Canonical paths**: codex matches entries against the canonicalized cwd —
4149
+ * a macOS `/tmp/...` entry never matches the `/private/tmp/...` it points
4150
+ * at, while the reverse spelling works (and the app-server canonicalizes its
4151
+ * `cwd` param too). Both sides here are realpath'd, which can only err
4152
+ * toward silence.
4153
+ * - **The gate is sandbox-scoped**: `thread/start` under `workspace-write` or
4154
+ * `danger-full-access` (permission modes `acceptEdits`/`bypassPermissions`)
4155
+ * WRITES the trust entry itself and loads the config — only `read-only`
4156
+ * (mode `default`) leaves the project untrusted and the config ignored. A
4157
+ * later `turn/start` with a wider sandboxPolicy does not heal the thread
4158
+ * (measured): the caller probes `default`-mode sessions only, and the notice
4159
+ * stays true for the session it opens.
4160
+ * - `trust_level`'s vocabulary is exactly `trusted`/`untrusted`; any other
4161
+ * value fails codex's bootstrap outright ("unknown variant"), so a config
4162
+ * carrying one probes silent — that session announces its own failure.
4163
+ *
4164
+ * The correctness bar for every degrade path: a FALSE notice — warning about a
4165
+ * project codex actually trusts — is worse than a missed one. The narrow TOML
4166
+ * reader below refuses (→ silence) anything it cannot interpret with
4167
+ * certainty, rather than guessing.
4168
+ */
4169
+ const BARE_KEY = /[A-Za-z0-9_-]/;
4170
+ function skipWs(text, pos) {
4171
+ let i = pos;
4172
+ while (i < text.length && (text[i] === " " || text[i] === " ")) i++;
4173
+ return i;
4174
+ }
4175
+ /** One-line TOML basic string starting at `pos` (which must be `"`). Undefined
4176
+ * on an escape TOML doesn't define or a close quote that never comes — the
4177
+ * caller refuses the file rather than guessing what codex would read. */
4178
+ function parseBasicString(text, pos) {
4179
+ let out = "";
4180
+ let i = pos + 1;
4181
+ while (i < text.length) {
4182
+ const ch = text[i];
4183
+ if (ch === "\"") return {
4184
+ value: out,
4185
+ end: i + 1
4186
+ };
4187
+ if (ch === "\\") {
4188
+ const esc = text[i + 1];
4189
+ if (esc === "b") out += "\b";
4190
+ else if (esc === "t") out += " ";
4191
+ else if (esc === "n") out += "\n";
4192
+ else if (esc === "f") out += "\f";
4193
+ else if (esc === "r") out += "\r";
4194
+ else if (esc === "\"") out += "\"";
4195
+ else if (esc === "\\") out += "\\";
4196
+ else if (esc === "u" || esc === "U") {
4197
+ const width = esc === "u" ? 4 : 8;
4198
+ const hex = text.slice(i + 2, i + 2 + width);
4199
+ if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return void 0;
4200
+ const code = Number.parseInt(hex, 16);
4201
+ if (code > 1114111) return void 0;
4202
+ out += String.fromCodePoint(code);
4203
+ i += width;
4204
+ } else return void 0;
4205
+ i += 2;
4206
+ continue;
4207
+ }
4208
+ out += ch;
4209
+ i++;
4210
+ }
4211
+ }
4212
+ /** One-line TOML literal string starting at `pos` (which must be `'`). */
4213
+ function parseLiteralString(text, pos) {
4214
+ const close = text.indexOf("'", pos + 1);
4215
+ if (close === -1) return void 0;
4216
+ return {
4217
+ value: text.slice(pos + 1, close),
4218
+ end: close + 1
4219
+ };
4220
+ }
4221
+ /** A dotted key path — bare, `"basic"` and `'literal'` keys, whitespace around
4222
+ * the dots — as found in table headers and on the left of assignments. */
4223
+ function parseKeyPath(text, pos) {
4224
+ const keys = [];
4225
+ let i = pos;
4226
+ for (;;) {
4227
+ i = skipWs(text, i);
4228
+ const ch = text[i];
4229
+ if (ch === "\"" || ch === "'") {
4230
+ const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4231
+ if (!str) return void 0;
4232
+ keys.push(str.value);
4233
+ i = str.end;
4234
+ } else if (ch !== void 0 && BARE_KEY.test(ch)) {
4235
+ let end = i;
4236
+ while (end < text.length && BARE_KEY.test(text[end])) end++;
4237
+ keys.push(text.slice(i, end));
4238
+ i = end;
4239
+ } else return;
4240
+ i = skipWs(text, i);
4241
+ if (text[i] !== ".") return {
4242
+ value: keys,
4243
+ end: i
4244
+ };
4245
+ i++;
4246
+ }
4247
+ }
4248
+ /**
4249
+ * Scan an assignment's value (or the continuation line of a multi-line array),
4250
+ * confirming where it ends. Returns the bracket depth carried onto the next
4251
+ * line (0 = the value is complete) plus the string itself when the whole value
4252
+ * was one plain one-line string. Undefined refuses the file: multi-line
4253
+ * strings are where a line reader starts misreading string *content* as
4254
+ * sections and entries — the exact mistake that could flip a real trust entry
4255
+ * — so they are not parsed around, they end the attempt.
4256
+ */
4257
+ function scanValueLine(text, pos, depth) {
4258
+ let i = skipWs(text, pos);
4259
+ if (depth === 0 && (text[i] === "\"" || text[i] === "'")) {
4260
+ if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return void 0;
4261
+ const str = text[i] === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4262
+ if (!str) return void 0;
4263
+ const rest = skipWs(text, str.end);
4264
+ if (rest < text.length && text[rest] !== "#") return void 0;
4265
+ return {
4266
+ depth: 0,
4267
+ value: str.value
4268
+ };
4269
+ }
4270
+ while (i < text.length) {
4271
+ const ch = text[i];
4272
+ if (ch === "#") break;
4273
+ if (ch === "\"" || ch === "'") {
4274
+ if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return void 0;
4275
+ const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4276
+ if (!str) return void 0;
4277
+ i = str.end;
4278
+ continue;
4279
+ }
4280
+ if (ch === "[" || ch === "{") depth++;
4281
+ else if (ch === "]" || ch === "}") {
4282
+ depth--;
4283
+ if (depth < 0) return void 0;
4284
+ }
4285
+ i++;
4286
+ }
4287
+ return { depth };
4288
+ }
4289
+ /**
4290
+ * The `[projects."<path>"] trust_level = "..."` entries of a codex
4291
+ * `config.toml`, by a deliberately narrow reader (core takes no TOML
4292
+ * dependency for this). Handles what codex itself writes plus the reasonable
4293
+ * hand-edits — comments, CRLF, whitespace, quoted keys with escapes, literal
4294
+ * and bare keys, `[projects]`-with-dotted-keys and top-level dotted forms,
4295
+ * single-line inline tables, multi-line arrays — and returns **undefined for
4296
+ * anything else it meets anywhere in the file** (multi-line strings,
4297
+ * `projects` as an inline table, array-of-tables, junk): the caller treats
4298
+ * undefined as "cannot know" and stays silent. Conflicting duplicate entries
4299
+ * also refuse — invalid for TOML, and guessing wrong is a false notice.
4300
+ */
4301
+ function parseProjectTrustEntries(source) {
4302
+ const entries = /* @__PURE__ */ new Map();
4303
+ let section = [];
4304
+ let carryDepth = 0;
4305
+ for (const rawLine of source.split("\n")) {
4306
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
4307
+ if (carryDepth > 0) {
4308
+ const scanned = scanValueLine(line, 0, carryDepth);
4309
+ if (!scanned) return void 0;
4310
+ carryDepth = scanned.depth;
4311
+ continue;
4312
+ }
4313
+ const start = skipWs(line, 0);
4314
+ if (start >= line.length || line[start] === "#") continue;
4315
+ if (line[start] === "[") {
4316
+ const array = line.startsWith("[[", start);
4317
+ const path = parseKeyPath(line, start + (array ? 2 : 1));
4318
+ if (!path) return void 0;
4319
+ const close = array ? "]]" : "]";
4320
+ if (!line.startsWith(close, path.end)) return void 0;
4321
+ const rest = skipWs(line, path.end + close.length);
4322
+ if (rest < line.length && line[rest] !== "#") return void 0;
4323
+ if (array && path.value[0] === "projects") return void 0;
4324
+ section = path.value;
4325
+ continue;
4326
+ }
4327
+ const key = parseKeyPath(line, start);
4328
+ if (!key) return void 0;
4329
+ if (line[key.end] !== "=") return void 0;
4330
+ const scanned = scanValueLine(line, key.end + 1, 0);
4331
+ if (!scanned) return void 0;
4332
+ carryDepth = scanned.depth;
4333
+ const full = [...section, ...key.value];
4334
+ if (full[0] !== "projects") continue;
4335
+ if (full.length < 3) return void 0;
4336
+ if (full.length === 3 && full[2] === "trust_level") {
4337
+ if (carryDepth !== 0 || scanned.value === void 0) return void 0;
4338
+ const project = full[1];
4339
+ const existing = entries.get(project);
4340
+ if (existing !== void 0 && existing !== scanned.value) return void 0;
4341
+ entries.set(project, scanned.value);
4342
+ }
4343
+ }
4344
+ if (carryDepth > 0) return void 0;
4345
+ return entries;
4346
+ }
4347
+ /**
4348
+ * A linked worktree inherits trust from its main repository's entry (measured:
4349
+ * trusting the main repo path loads the worktree's project config). The
4350
+ * worktree's `.git` is a FILE whose `gitdir:` line names
4351
+ * `<main>/.git/worktrees/<name>`; the directory owning that `.git` is the
4352
+ * anchor to look up. Anything unreadable or shaped differently resolves false
4353
+ * — this route can only ADD trust, i.e. silence, never a false notice.
4354
+ */
4355
+ function mainRepositoryTrusted(gitRootDir, canonical) {
4356
+ const gitPath = join(gitRootDir, ".git");
4357
+ try {
4358
+ if (!statSync(gitPath).isFile()) return false;
4359
+ const match = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(readFileSync(gitPath, "utf8"));
4360
+ if (!match) return false;
4361
+ const gitdir = resolve(gitRootDir, match[1]);
4362
+ const at = gitdir.lastIndexOf(`${sep}.git${sep}`);
4363
+ if (at <= 0) return false;
4364
+ let main = gitdir.slice(0, at);
4365
+ try {
4366
+ main = realpathSync(main);
4367
+ } catch {}
4368
+ return canonical.get(main) === "trusted";
4369
+ } catch {
4370
+ return false;
4371
+ }
4372
+ }
4373
+ /**
4374
+ * The notice for a codex session about to run on a cwd whose
4375
+ * `.codex/config.toml` codex will ignore, or undefined when there is nothing
4376
+ * to say — no project config anywhere codex would look, the project is
4377
+ * trusted, or the situation cannot be established with certainty. Read-only
4378
+ * throughout: WorkerDeck never writes trust entries (adjacent to the auth red
4379
+ * lines — trusting a directory is the operator's decision, made in codex's
4380
+ * own prompt or by their own hand).
4381
+ */
4382
+ function untrustedProjectNotice(options) {
4383
+ let cwd;
4384
+ try {
4385
+ cwd = realpathSync(options.cwd);
4386
+ } catch {
4387
+ return;
4388
+ }
4389
+ let home = resolve(options.codexHome);
4390
+ try {
4391
+ home = realpathSync(options.codexHome);
4392
+ } catch {}
4393
+ const chain = [];
4394
+ let dir = cwd;
4395
+ for (;;) {
4396
+ chain.push(dir);
4397
+ if (existsSync(join(dir, ".git"))) break;
4398
+ const parent = dirname(dir);
4399
+ if (parent === dir) break;
4400
+ dir = parent;
4401
+ }
4402
+ const anchor = chain[chain.length - 1];
4403
+ const gitRoot = existsSync(join(anchor, ".git")) ? anchor : void 0;
4404
+ const layers = (gitRoot ? chain : [cwd]).filter((layer) => {
4405
+ if (!existsSync(join(layer, ".codex", "config.toml"))) return false;
4406
+ try {
4407
+ return realpathSync(join(layer, ".codex")) !== home;
4408
+ } catch {
4409
+ return false;
4410
+ }
4411
+ });
4412
+ if (layers.length === 0) return void 0;
4413
+ const homeConfigPath = join(options.codexHome, "config.toml");
4414
+ let source = "";
4415
+ try {
4416
+ source = readFileSync(homeConfigPath, "utf8");
4417
+ } catch (error) {
4418
+ if (error.code !== "ENOENT") return void 0;
4419
+ }
4420
+ const entries = parseProjectTrustEntries(source);
4421
+ if (!entries) return void 0;
4422
+ for (const value of entries.values()) if (value !== "trusted" && value !== "untrusted") return void 0;
4423
+ const canonical = /* @__PURE__ */ new Map();
4424
+ for (const [key, value] of entries) {
4425
+ let path = key;
4426
+ try {
4427
+ path = realpathSync(key);
4428
+ } catch {}
4429
+ if (canonical.get(path) === "trusted") continue;
4430
+ canonical.set(path, value);
4431
+ }
4432
+ const rootTrusted = gitRoot !== void 0 && (canonical.get(gitRoot) === "trusted" || mainRepositoryTrusted(gitRoot, canonical));
4433
+ const ignored = layers.filter((layer) => {
4434
+ const entry = canonical.get(layer);
4435
+ if (entry !== void 0) return entry !== "trusted";
4436
+ return !rootTrusted;
4437
+ });
4438
+ if (ignored.length === 0) return void 0;
4439
+ const trustDir = gitRoot ?? cwd;
4440
+ const configs = ignored.map((layer) => join(layer, ".codex", "config.toml"));
4441
+ 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}.`;
4442
+ }
4443
+ //#endregion
3962
4444
  //#region src/engines/codex/runner.ts
3963
4445
  /**
3964
4446
  * thread/start's sandbox axis (string form) — our permission modes as codex
@@ -3966,16 +4448,30 @@ var JsonRpcStdioConnection = class {
3966
4448
  * the OS sandbox and — with the ask policy below — escalates to a real
3967
4449
  * question), `acceptEdits` → workspace-write (in-workspace writes sail
3968
4450
  * through, the acceptEdits grant), `bypassPermissions` → danger-full-access.
4451
+ * `auto` rides the SAME sandbox as acceptEdits — it is not a wider grant, it
4452
+ * only moves *who answers* the approvals (see {@link APPROVALS_REVIEWER_BY_MODE}).
3969
4453
  */
3970
4454
  const THREAD_SANDBOX_BY_MODE = {
3971
4455
  default: "read-only",
3972
4456
  acceptEdits: "workspace-write",
4457
+ auto: "workspace-write",
3973
4458
  bypassPermissions: "danger-full-access"
3974
4459
  };
3975
- /** turn/start's sandboxPolicy axis (object form — same policy, second shape). */
4460
+ /**
4461
+ * turn/start's sandboxPolicy axis (object form — same policy, second shape).
4462
+ *
4463
+ * The `workspaceWrite` entries here are a SHAPE, not the whole policy: every
4464
+ * unstated field of that variant is serde-defaulted by the app-server, so
4465
+ * sending it bare silently overrides the operator's `[sandbox_workspace_write]`
4466
+ * — `network_access` back to false, `writable_roots` back to empty — on every
4467
+ * turn. {@link CodexRunner.#turnSandboxPolicy} restates those fields from
4468
+ * `config/read`; nothing else may send this map's `workspaceWrite` entries
4469
+ * directly.
4470
+ */
3976
4471
  const TURN_SANDBOX_BY_MODE = {
3977
4472
  default: { type: "readOnly" },
3978
4473
  acceptEdits: { type: "workspaceWrite" },
4474
+ auto: { type: "workspaceWrite" },
3979
4475
  bypassPermissions: { type: "dangerFullAccess" }
3980
4476
  };
3981
4477
  /**
@@ -3999,16 +4495,52 @@ const GRANULAR_ASK = { granular: {
3999
4495
  request_permissions: true,
4000
4496
  skill_approval: true
4001
4497
  } };
4498
+ const GRANULAR_NEVER = { granular: {
4499
+ sandbox_approval: false,
4500
+ rules: false,
4501
+ mcp_elicitations: false,
4502
+ request_permissions: false,
4503
+ skill_approval: false
4504
+ } };
4505
+ /**
4506
+ * Notifications whose meaning is scoped to ONE thread, and which are therefore
4507
+ * only ever read off the session's own. Everything else (items, deltas) is
4508
+ * accepted from any thread on the connection — see `#handleNotification`.
4509
+ */
4510
+ const THREAD_SCOPED_NOTIFICATIONS = new Set([
4511
+ "turn/started",
4512
+ "turn/completed",
4513
+ "thread/tokenUsage/updated"
4514
+ ]);
4002
4515
  const APPROVAL_POLICY_BY_MODE = {
4003
4516
  default: GRANULAR_ASK,
4004
4517
  acceptEdits: GRANULAR_ASK,
4005
- bypassPermissions: { granular: {
4006
- sandbox_approval: false,
4007
- rules: false,
4008
- mcp_elicitations: false,
4009
- request_permissions: false,
4010
- skill_approval: false
4011
- } }
4518
+ auto: GRANULAR_ASK,
4519
+ bypassPermissions: GRANULAR_NEVER
4520
+ };
4521
+ /**
4522
+ * The THIRD approval axis — *who reviews*, independent of the sandbox axis and
4523
+ * the ask axis above. Codex's `approvalsReviewer` (thread/start and turn/start,
4524
+ * present since 0.146.0) routes every approval request either to the user
4525
+ * (`'user'`, codex's own default) or to `'auto_review'`: a prompted subagent
4526
+ * that gathers context and applies a risk framework before allowing or denying.
4527
+ * That is codex's "Approve for me" preset, and our `auto` mode is exactly it.
4528
+ *
4529
+ * Sent explicitly for EVERY mode rather than omitted for the default — a thread
4530
+ * inherits `approvalsReviewer` across turns ("this turn and subsequent turns"),
4531
+ * so leaving it unset would let a stale reviewer from an earlier turn survive a
4532
+ * mode switch back to a user-reviewed mode. Stating it every time makes the
4533
+ * mode the single source of truth.
4534
+ *
4535
+ * NOTE the asymmetry with the Claude engine's `auto`: that classifier is
4536
+ * operator-configurable (`autoMode.environment`, allow/soft_deny/hard_deny);
4537
+ * this reviewer has no configuration surface at all.
4538
+ */
4539
+ const APPROVALS_REVIEWER_BY_MODE = {
4540
+ default: "user",
4541
+ acceptEdits: "user",
4542
+ auto: "auto_review",
4543
+ bypassPermissions: "user"
4012
4544
  };
4013
4545
  /** Fallback timeout for a pending approval nobody answers — the SessionRunner
4014
4546
  * default, so unattended codex sessions land the same way Claude ones do. */
@@ -4019,6 +4551,47 @@ const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
4019
4551
  * host filesystem, an inline preview) off it.
4020
4552
  */
4021
4553
  const CODEX_IMAGE_TOOL = "CodexImageGeneration";
4554
+ /**
4555
+ * Tool name for a spawned agent's anchor `tool_use` — the claude engine's
4556
+ * `Task` in this engine's vocabulary. Codex never sends such a call: the model's
4557
+ * `spawn_agent` surfaces only as the `subAgentActivity` marker item, so the
4558
+ * runner authors the call itself, because everything downstream is built on a
4559
+ * top-level `tool_use` existing — `terminalBlocks` absorbs a sidechain into the
4560
+ * call whose id its events carry as `parentToolUseId`, the takeover frames by
4561
+ * it, and `taskIdentity` labels it from the input's `subagent_type`. Not a new
4562
+ * wire idea, just a row: the same shape every other codex tool card uses.
4563
+ */
4564
+ const CODEX_AGENT_TOOL = "CodexAgent";
4565
+ /** Tool name for the model's collab-agent calls (`wait`, `sendInput`, …), the
4566
+ * `tool` field carried in the input. One name for the whole open axis rather
4567
+ * than a name per verb, so a future verb renders instead of vanishing. */
4568
+ const CODEX_COLLAB_TOOL = "CodexCollab";
4569
+ /** An agent's name is its path's basename: '/root/date_one' → 'date_one'. */
4570
+ function agentName(agentPath) {
4571
+ if (typeof agentPath !== "string") return void 0;
4572
+ return agentPath.split("/").filter(Boolean).at(-1) || void 0;
4573
+ }
4574
+ /** The collab card's input: the verb always, the rich fields only when codex
4575
+ * actually filled them (measured against 0.146.0 they arrive empty — the card
4576
+ * must not render five null columns to say 'wait'). */
4577
+ function collabInput(item) {
4578
+ return {
4579
+ tool: item.tool,
4580
+ ...item.receiverThreadIds?.length ? { receiverThreadIds: item.receiverThreadIds } : {},
4581
+ ...item.prompt ? { prompt: item.prompt } : {},
4582
+ ...item.model ? { model: item.model } : {}
4583
+ };
4584
+ }
4585
+ /** A completed turn's answer, from its summary `items` page — the last
4586
+ * `agentMessage` text. For a sub-agent's thread this is the agent's report,
4587
+ * which is exactly what belongs in the anchor's `tool_result`. */
4588
+ function turnReport(turn) {
4589
+ const items = Array.isArray(turn.items) ? turn.items : [];
4590
+ for (let index = items.length - 1; index >= 0; index--) {
4591
+ const item = items[index];
4592
+ if (item?.type === "agentMessage" && typeof item.text === "string" && item.text) return item.text;
4593
+ }
4594
+ }
4022
4595
  /** Longest `result` worth putting in a tool card. The field is free-form and
4023
4596
  * undocumented; anything past this is assumed to be an encoded image rather
4024
4597
  * than a sentence, and encoded images do not go in the event log. */
@@ -4362,6 +4935,13 @@ var CodexRunner = class {
4362
4935
  #events = [];
4363
4936
  #subscribers = new SubscriberSet();
4364
4937
  #seq = 0;
4938
+ /**
4939
+ * Latest context-window reading, retained from the last `context_usage` this
4940
+ * runner emitted so `GET /sessions` can answer it without an attach — see
4941
+ * `SessionInfo.contextUsage`. Folded in the emit path, so it is by
4942
+ * construction the same number the transcript last drew.
4943
+ */
4944
+ #contextUsage;
4365
4945
  #activityCount = 0;
4366
4946
  #status = "starting";
4367
4947
  #sdkSessionId;
@@ -4379,6 +4959,8 @@ var CodexRunner = class {
4379
4959
  #turnChain = Promise.resolve();
4380
4960
  #activeTurn;
4381
4961
  #connection;
4962
+ /** Per-child, from `config/read`; undefined = read failed, send the bare shape. */
4963
+ #workspaceWrite;
4382
4964
  #threadLoaded = false;
4383
4965
  #numTurns = 0;
4384
4966
  #totalCostUsd;
@@ -4417,6 +4999,12 @@ var CodexRunner = class {
4417
4999
  * `mcpServerStatus/list` does not carry a status field at all, so without
4418
5000
  * this every server would read as "configured" and never as up or down. */
4419
5001
  #mcpStatus = /* @__PURE__ */ new Map();
5002
+ /** The spawned agents, keyed by their thread ids — the attribution table
5003
+ * behind `parentToolUseId` and the rollup behind `info().subagents`. Runner-
5004
+ * level, not per-turn: an agent's thread outlives the root turn that spawned
5005
+ * it, and only the child process dying (or the session closing) ends them
5006
+ * all — see the module doc in `subagents.ts`. */
5007
+ #agents = new CodexAgentTracker();
4420
5008
  constructor(config, id = randomUUID()) {
4421
5009
  const mode = config.permissionMode ?? "default";
4422
5010
  if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
@@ -4468,13 +5056,15 @@ var CodexRunner = class {
4468
5056
  createdAt: this.createdAt,
4469
5057
  lastSeq: this.#seq,
4470
5058
  activityCount: this.#activityCount,
5059
+ contextUsage: this.#contextUsage,
4471
5060
  pendingPermissionCount: this.#approvals.size,
4472
5061
  meta: this.#config.meta,
4473
5062
  scope: this.#config.scope,
4474
5063
  title: this.#title(),
4475
5064
  totalCostUsd: this.#totalCostUsd,
4476
5065
  numTurns: this.#numTurns || void 0,
4477
- lastActivityAt: this.#lastActivityAt
5066
+ lastActivityAt: this.#lastActivityAt,
5067
+ subagents: this.#agents.list()
4478
5068
  };
4479
5069
  }
4480
5070
  #title() {
@@ -4498,6 +5088,7 @@ var CodexRunner = class {
4498
5088
  start() {
4499
5089
  if (this.#started) return this.#turnChain;
4500
5090
  this.#started = true;
5091
+ this.#warnUntrustedProject();
4501
5092
  if (this.#config.resume && this.#config.backfillHistory !== false) {
4502
5093
  this.#backfillPending = true;
4503
5094
  this.#turnChain = this.#turnChain.then(() => this.#backfillHistory());
@@ -4507,6 +5098,37 @@ var CodexRunner = class {
4507
5098
  return this.#turnChain;
4508
5099
  }
4509
5100
  /**
5101
+ * One-time transcript notice for the codex trust gap: a `default`-mode
5102
+ * session (read-only sandbox) on an untrusted cwd has its
5103
+ * `.codex/config.toml` — MCP servers included — silently ignored, and the
5104
+ * app-server surface has no trust prompt to say so (the TUI's prompt is
5105
+ * where the entry normally gets written). `acceptEdits`/`bypassPermissions`
5106
+ * sessions are exempt because their `thread/start` (workspace-write /
5107
+ * danger-full-access sandbox) writes the trust entry itself and loads the
5108
+ * config — measured against 0.146.0 and 0.149.0; a notice there would be
5109
+ * false. Emitted as `session_error`, which both clients render as an inline
5110
+ * notice while the session keeps running (the backfill-history precedent),
5111
+ * so nothing new rides the wire. Every degrade path is silence: a false
5112
+ * warning on a trusted project is worse than a missed one.
5113
+ */
5114
+ #warnUntrustedProject() {
5115
+ if (this.#permissionMode !== "default") return;
5116
+ try {
5117
+ const env = this.#childEnv();
5118
+ const pin = env.CODEX_HOME;
5119
+ if (pin !== void 0 && pin.length === 0) return;
5120
+ const codexHome = pin ?? join(env.HOME ?? homedir(), ".codex");
5121
+ const message = untrustedProjectNotice({
5122
+ cwd: this.#cwd,
5123
+ codexHome
5124
+ });
5125
+ if (message) this.#emit({
5126
+ type: "session_error",
5127
+ message
5128
+ });
5129
+ } catch {}
5130
+ }
5131
+ /**
4510
5132
  * List skills over a **throwaway** connection, for a session with nothing else
4511
5133
  * to do yet.
4512
5134
  *
@@ -4688,6 +5310,7 @@ var CodexRunner = class {
4688
5310
  }, "policy");
4689
5311
  this.#connection?.close();
4690
5312
  this.#connection = void 0;
5313
+ this.#agents.sweep();
4691
5314
  this.#activeTurn?.reject(/* @__PURE__ */ new Error("session closed"));
4692
5315
  if (this.#imageDir) try {
4693
5316
  rmSync(this.#imageDir, {
@@ -4714,6 +5337,48 @@ var CodexRunner = class {
4714
5337
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
4715
5338
  }
4716
5339
  /**
5340
+ * Read `[sandbox_workspace_write]` as codex resolves it for this session's
5341
+ * cwd, once per child, so {@link CodexRunner.#turnSandboxPolicy} can restate
5342
+ * it verbatim.
5343
+ *
5344
+ * Why this exists at all: `turn/start`'s object-form sandbox policy is
5345
+ * serde-defaulted field by field, so `{type: 'workspaceWrite'}` bare means
5346
+ * `networkAccess: false, writableRoots: []` NO MATTER what the operator
5347
+ * configured — and we must keep sending the object every turn, because
5348
+ * restating it is what makes a between-turns permission-mode switch take
5349
+ * effect. Measured against 0.149.0 with `network_access = true` set: the
5350
+ * bare object produced `curl: (6) Could not resolve host`, the fully-stated
5351
+ * object and an omitted policy both produced `200`. `read-only` is not
5352
+ * affected — the setting is scoped to workspace-write, as its name says, and
5353
+ * a read-only sandbox has no network either way.
5354
+ *
5355
+ * A failure here is not fatal: `#workspaceWrite` stays undefined and we send
5356
+ * the bare shape, which is exactly the behaviour that shipped before.
5357
+ */
5358
+ async #readWorkspaceWrite(connection) {
5359
+ this.#workspaceWrite = void 0;
5360
+ try {
5361
+ const block = (await connection.request("config/read", { cwd: this.#cwd }))?.config?.sandbox_workspace_write;
5362
+ if (!block) return;
5363
+ const roots = block.writable_roots;
5364
+ this.#workspaceWrite = {
5365
+ writableRoots: Array.isArray(roots) ? roots.filter((r) => typeof r === "string") : [],
5366
+ networkAccess: block.network_access === true,
5367
+ excludeTmpdirEnvVar: block.exclude_tmpdir_env_var === true,
5368
+ excludeSlashTmp: block.exclude_slash_tmp === true
5369
+ };
5370
+ } catch {}
5371
+ }
5372
+ /** The mode's turn-level sandbox policy, with the operator's workspace-write settings intact. */
5373
+ #turnSandboxPolicy() {
5374
+ const policy = TURN_SANDBOX_BY_MODE[this.#permissionMode];
5375
+ if (policy?.type !== "workspaceWrite" || !this.#workspaceWrite) return policy;
5376
+ return {
5377
+ type: "workspaceWrite",
5378
+ ...this.#workspaceWrite
5379
+ };
5380
+ }
5381
+ /**
4717
5382
  * The session's live connection with its thread loaded, (re)building both as
4718
5383
  * needed: spawn + `initialize`/`initialized` on a fresh child, then
4719
5384
  * `thread/start` (new) or `thread/resume` (a create-request `resume`, or a
@@ -4738,6 +5403,7 @@ var CodexRunner = class {
4738
5403
  behavior: "deny",
4739
5404
  message
4740
5405
  }, "policy");
5406
+ this.#agents.sweep();
4741
5407
  this.#activeTurn?.reject(new Error(message));
4742
5408
  });
4743
5409
  try {
@@ -4756,12 +5422,14 @@ var CodexRunner = class {
4756
5422
  throw error;
4757
5423
  }
4758
5424
  connection.notify("initialized");
5425
+ await this.#readWorkspaceWrite(connection);
4759
5426
  }
4760
5427
  if (!this.#threadLoaded) {
4761
5428
  const options = {
4762
5429
  cwd: this.#cwd,
4763
5430
  approvalPolicy: APPROVAL_POLICY_BY_MODE[this.#permissionMode],
4764
- sandbox: THREAD_SANDBOX_BY_MODE[this.#permissionMode]
5431
+ sandbox: THREAD_SANDBOX_BY_MODE[this.#permissionMode],
5432
+ approvalsReviewer: APPROVALS_REVIEWER_BY_MODE[this.#permissionMode]
4765
5433
  };
4766
5434
  if (this.#model) options.model = this.#model;
4767
5435
  const resuming = this.#sdkSessionId !== void 0;
@@ -5004,7 +5672,8 @@ var CodexRunner = class {
5004
5672
  input: turn.input,
5005
5673
  cwd: this.#cwd,
5006
5674
  approvalPolicy: APPROVAL_POLICY_BY_MODE[this.#permissionMode],
5007
- sandboxPolicy: TURN_SANDBOX_BY_MODE[this.#permissionMode]
5675
+ sandboxPolicy: this.#turnSandboxPolicy(),
5676
+ approvalsReviewer: APPROVALS_REVIEWER_BY_MODE[this.#permissionMode]
5008
5677
  };
5009
5678
  const model = this.#model ?? this.#resolvedModel;
5010
5679
  if (model) params.model = model;
@@ -5033,11 +5702,79 @@ var CodexRunner = class {
5033
5702
  }
5034
5703
  #handleNotification(method, params) {
5035
5704
  if (this.#closed) return;
5705
+ if (THREAD_SCOPED_NOTIFICATIONS.has(method) && !this.#isRootThread(params)) {
5706
+ if (method === "turn/completed") this.#settleAgentTurn(params);
5707
+ else if (method === "turn/started") {
5708
+ const threadId = this.#threadIdOf(params);
5709
+ const record = threadId ? this.#agents.get(threadId) : void 0;
5710
+ if (record && record.status !== "running") this.#agents.revive(record);
5711
+ }
5712
+ return;
5713
+ }
5036
5714
  this.#notifications[method]?.(params);
5037
5715
  }
5716
+ /** Whether a notification is about the session's own thread. A notification
5717
+ * with no `threadId` counts as the root's: every thread-scoped method the
5718
+ * schema defines carries one, so an absent id means an older or narrower
5719
+ * shape, not a sub-agent. */
5720
+ #isRootThread(params) {
5721
+ const threadId = this.#threadIdOf(params);
5722
+ if (threadId === void 0) return true;
5723
+ return threadId === this.#sdkSessionId;
5724
+ }
5725
+ #threadIdOf(params) {
5726
+ const threadId = params?.threadId;
5727
+ return typeof threadId === "string" ? threadId : void 0;
5728
+ }
5729
+ /**
5730
+ * The agent behind a notification's `threadId` — the attribution every item
5731
+ * and delta handler asks before emitting, so two agents streaming
5732
+ * concurrently into this one connection come apart again by the id each
5733
+ * frame carries, never by any mutable "current agent".
5734
+ *
5735
+ * A non-root thread with no record still gets one: a thread emitting items on
5736
+ * this connection *is* an agent, whatever announced it (codex runs threads of
5737
+ * its own for review/compact, and a `subAgentActivity` could in principle be
5738
+ * missed) — the claude tracker's nested-event fallback, on a stronger signal.
5739
+ * The minted record is label-less and its anchor is authored here, because an
5740
+ * attributed event whose parent id matches no top-level `tool_use` would
5741
+ * render inline rather than as a frame; a late `started` edge fills the name
5742
+ * in. Root-thread traffic — and, defensively, the pre-thread shapes with no
5743
+ * id at all — stays unattributed (`undefined`).
5744
+ */
5745
+ #agentFor(params) {
5746
+ const threadId = this.#threadIdOf(params);
5747
+ if (threadId === void 0 || threadId === this.#sdkSessionId) return void 0;
5748
+ const known = this.#agents.get(threadId);
5749
+ if (known) return known;
5750
+ const nonce = this.#activeTurn?.nonce ?? "codex";
5751
+ const record = this.#agents.open(threadId, `${nonce}:agent:${threadId}`, void 0, Date.now());
5752
+ record.anchored = true;
5753
+ this.#emitToolUse(record.toolUseId, CODEX_AGENT_TOOL, { agentThreadId: threadId });
5754
+ return record;
5755
+ }
5756
+ /**
5757
+ * A child thread's `turn/completed` is that AGENT's completion — the one
5758
+ * codex sends (`subAgentActivity` has no 'completed' kind, verified live).
5759
+ * The verdict is the turn's own status, and the report is the completed
5760
+ * turn's final message, delivered as the anchor's `tool_result` so the row
5761
+ * settles exactly the way a claude `Task`'s does. Deliberately not gated on
5762
+ * `#activeTurn`: an agent finishing between root turns still finished.
5763
+ */
5764
+ #settleAgentTurn(params) {
5765
+ const threadId = this.#threadIdOf(params);
5766
+ const record = threadId ? this.#agents.get(threadId) : void 0;
5767
+ if (!record || record.status !== "running") return;
5768
+ const turn = params?.turn;
5769
+ const status = turn?.status === "completed" ? "done" : "failed";
5770
+ this.#agents.settle(record, status);
5771
+ const report = (turn ? turnReport(turn) : void 0) ?? turn?.error?.message ?? (status === "done" ? "" : turn?.status ?? "failed");
5772
+ this.#emitToolResult(record.toolUseId, report, status === "failed");
5773
+ }
5038
5774
  /** Reasoning deltas arrive on two methods that differ only in which section
5039
5775
  * counter they advance; the section key carries the method so the two streams
5040
- * never share a boundary. Section boundaries (a new summary/content entry)
5776
+ * never share a boundary (and item ids are per-thread, so two agents' streams
5777
+ * never share one either). Section boundaries (a new summary/content entry)
5041
5778
  * render as paragraph breaks — the completed item joins sections with '\n\n'. */
5042
5779
  #reasoningDelta(method) {
5043
5780
  return (params) => {
@@ -5053,7 +5790,7 @@ var CodexRunner = class {
5053
5790
  this.#emitDelta({
5054
5791
  type: "thinking_delta",
5055
5792
  thinking: separator + payload.delta
5056
- });
5793
+ }, this.#agentFor(params)?.toolUseId ?? null);
5057
5794
  };
5058
5795
  }
5059
5796
  /** One item-progress handler serves `item/started` and `item/updated`. */
@@ -5061,7 +5798,7 @@ var CodexRunner = class {
5061
5798
  const active = this.#activeTurn;
5062
5799
  if (!active) return;
5063
5800
  const item = params?.item;
5064
- if (item) this.#handleItemProgress(item, active);
5801
+ if (item) this.#handleItemProgress(item, active, this.#agentFor(params));
5065
5802
  };
5066
5803
  /** The notification dispatch table — every method the child emits that this
5067
5804
  * runner maps, in one place. Handlers read `this.#activeTurn` themselves:
@@ -5079,7 +5816,9 @@ var CodexRunner = class {
5079
5816
  "turn/completed": (params) => {
5080
5817
  const active = this.#activeTurn;
5081
5818
  const turn = params?.turn;
5082
- if (active && turn) active.resolve(turn);
5819
+ if (!active || !turn) return;
5820
+ if (active.turnId && turn.id && turn.id !== active.turnId) return;
5821
+ active.resolve(turn);
5083
5822
  },
5084
5823
  "item/started": this.#itemProgress,
5085
5824
  "item/updated": this.#itemProgress,
@@ -5087,7 +5826,7 @@ var CodexRunner = class {
5087
5826
  const active = this.#activeTurn;
5088
5827
  if (!active) return;
5089
5828
  const item = params?.item;
5090
- if (item) this.#handleItemCompleted(item, active);
5829
+ if (item) this.#handleItemCompleted(item, active, this.#agentFor(params));
5091
5830
  },
5092
5831
  "item/agentMessage/delta": (params) => {
5093
5832
  if (!this.#activeTurn) return;
@@ -5095,7 +5834,7 @@ var CodexRunner = class {
5095
5834
  if (typeof delta === "string" && delta) this.#emitDelta({
5096
5835
  type: "text_delta",
5097
5836
  text: delta
5098
- });
5837
+ }, this.#agentFor(params)?.toolUseId ?? null);
5099
5838
  },
5100
5839
  "item/reasoning/textDelta": this.#reasoningDelta("item/reasoning/textDelta"),
5101
5840
  "item/reasoning/summaryTextDelta": this.#reasoningDelta("item/reasoning/summaryTextDelta"),
@@ -5288,30 +6027,40 @@ var CodexRunner = class {
5288
6027
  if (!this.#closed && this.#approvals.size === 0 && this.#status === "awaiting_approval") this.#setStatus("running");
5289
6028
  }
5290
6029
  /** Tool calls surface as tool_use when they start; text and reasoning stream
5291
- * natively via the delta notifications. */
5292
- #handleItemProgress(item, active) {
6030
+ * natively via the delta notifications. `agent` is the sub-agent whose thread
6031
+ * the item arrived on — undefined for the session's own. */
6032
+ #handleItemProgress(item, active, agent) {
5293
6033
  const id = `${active.nonce}:${item.id}`;
6034
+ if (item.type === "subAgentActivity") {
6035
+ this.#itemCompleted.subAgentActivity(item, active, id, agent);
6036
+ return;
6037
+ }
5294
6038
  if (item.type === "commandExecution" && !active.toolUseEmitted.has(id)) {
5295
6039
  active.toolUseEmitted.add(id);
5296
- this.#emitToolUse(id, "CodexCommand", { command: item.command });
6040
+ this.#emitToolUse(id, "CodexCommand", { command: item.command }, agent);
5297
6041
  return;
5298
6042
  }
5299
6043
  if (item.type === "mcpToolCall" && !active.toolUseEmitted.has(id)) {
5300
6044
  active.toolUseEmitted.add(id);
5301
- this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments);
6045
+ this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments, agent);
6046
+ return;
6047
+ }
6048
+ if (item.type === "collabAgentToolCall" && !active.toolUseEmitted.has(id)) {
6049
+ active.toolUseEmitted.add(id);
6050
+ this.#emitToolUse(id, CODEX_COLLAB_TOOL, collabInput(item), agent);
5302
6051
  return;
5303
6052
  }
5304
6053
  if (item.type === "imageGeneration" && !active.toolUseEmitted.has(id)) {
5305
6054
  active.toolUseEmitted.add(id);
5306
- this.#emitToolUse(id, CODEX_IMAGE_TOOL, imageGenerationInput(item));
6055
+ this.#emitToolUse(id, CODEX_IMAGE_TOOL, imageGenerationInput(item), agent);
5307
6056
  if (item.savedPath) this.#emitFileProduced(item.savedPath, id);
5308
6057
  }
5309
6058
  }
5310
- #handleItemCompleted(item, active) {
6059
+ #handleItemCompleted(item, active, agent) {
5311
6060
  const id = `${active.nonce}:${item.id}`;
5312
6061
  const handler = this.#itemCompleted[item.type];
5313
6062
  if (handler) {
5314
- handler(item, active, id);
6063
+ handler(item, active, id, agent);
5315
6064
  return;
5316
6065
  }
5317
6066
  const unknown = item;
@@ -5332,67 +6081,121 @@ var CodexRunner = class {
5332
6081
  * union has never heard of; those take the passthrough above.)
5333
6082
  */
5334
6083
  #itemCompleted = {
5335
- userMessage: () => {},
5336
- agentMessage: (item, active, id) => {
6084
+ userMessage: (item, active, _id, agent) => {
6085
+ if (!agent) return;
6086
+ const text = historyUserText(item);
6087
+ if (!text) return;
6088
+ this.#emit({
6089
+ type: "user_message",
6090
+ message: {
6091
+ role: "user",
6092
+ content: text
6093
+ },
6094
+ parentToolUseId: agent.toolUseId,
6095
+ uuid: `${active.nonce}:${item.id}`
6096
+ });
6097
+ },
6098
+ agentMessage: (item, active, id, agent) => {
5337
6099
  const text = typeof item.text === "string" ? item.text : "";
5338
6100
  this.#emitAssistant(id, [{
5339
6101
  type: "text",
5340
6102
  text
5341
- }]);
5342
- active.finalText = text;
6103
+ }], agent?.toolUseId ?? null);
6104
+ if (!agent) active.finalText = text;
5343
6105
  },
5344
- reasoning: (item, _active, id) => {
6106
+ reasoning: (item, _active, id, agent) => {
5345
6107
  const summary = Array.isArray(item.summary) ? item.summary.filter(Boolean) : [];
5346
6108
  const content = Array.isArray(item.content) ? item.content.filter(Boolean) : [];
5347
6109
  const thinking = (summary.length > 0 ? summary : content).join("\n\n");
5348
6110
  if (thinking) this.#emitAssistant(id, [{
5349
6111
  type: "thinking",
5350
6112
  thinking
5351
- }]);
6113
+ }], agent?.toolUseId ?? null);
5352
6114
  },
5353
- commandExecution: (item, active, id) => {
6115
+ commandExecution: (item, active, id, agent) => {
5354
6116
  if (!active.toolUseEmitted.has(id)) {
5355
6117
  active.toolUseEmitted.add(id);
5356
- this.#emitToolUse(id, "CodexCommand", { command: item.command });
6118
+ this.#emitToolUse(id, "CodexCommand", { command: item.command }, agent);
5357
6119
  }
5358
6120
  const exitCode = item.exitCode ?? void 0;
5359
6121
  const failed = item.status === "failed" || item.status === "declined" || exitCode !== void 0 && exitCode !== 0;
5360
6122
  const output = (item.aggregatedOutput ?? "") + (exitCode !== void 0 && exitCode !== 0 ? `\n(exit code ${exitCode})` : "");
5361
- this.#emitToolResult(id, output, failed);
6123
+ this.#emitToolResult(id, output, failed, void 0, agent?.toolUseId ?? null);
5362
6124
  },
5363
- fileChange: (item, _active, id) => {
5364
- this.#emitToolUse(id, "CodexFileChange", { changes: item.changes });
6125
+ fileChange: (item, _active, id, agent) => {
6126
+ this.#emitToolUse(id, "CodexFileChange", { changes: item.changes }, agent);
5365
6127
  const lines = item.changes.map((change) => {
5366
6128
  return `${(typeof change.kind === "string" ? change.kind : change.kind?.type) ?? "change"}: ${change.path}`;
5367
6129
  });
5368
6130
  const only = item.changes.length === 1 ? item.changes[0] : void 0;
5369
- this.#emitToolResult(id, lines.join("\n") || item.status, item.status === "failed" || item.status === "declined", only?.diff ? parseUnifiedDiff(only.diff, only.path) : void 0);
6131
+ this.#emitToolResult(id, lines.join("\n") || item.status, item.status === "failed" || item.status === "declined", only?.diff ? parseUnifiedDiff(only.diff, only.path) : void 0, agent?.toolUseId ?? null);
5370
6132
  },
5371
- mcpToolCall: (item, active, id) => {
6133
+ mcpToolCall: (item, active, id, agent) => {
5372
6134
  if (!active.toolUseEmitted.has(id)) {
5373
6135
  active.toolUseEmitted.add(id);
5374
- this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments);
6136
+ this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments, agent);
5375
6137
  }
5376
6138
  const isError = item.error !== void 0 && item.error !== null || item.status === "failed";
5377
- this.#emitToolResult(id, item.error?.message ?? (item.result === void 0 || item.result === null ? "" : JSON.stringify(item.result)), isError);
6139
+ this.#emitToolResult(id, item.error?.message ?? (item.result === void 0 || item.result === null ? "" : JSON.stringify(item.result)), isError, void 0, agent?.toolUseId ?? null);
5378
6140
  },
5379
- webSearch: (item, _active, id) => {
5380
- this.#emitToolUse(id, "CodexWebSearch", { query: item.query });
5381
- this.#emitToolResult(id, "", false);
6141
+ webSearch: (item, _active, id, agent) => {
6142
+ this.#emitToolUse(id, "CodexWebSearch", { query: item.query }, agent);
6143
+ this.#emitToolResult(id, "", false, void 0, agent?.toolUseId ?? null);
5382
6144
  },
5383
- imageGeneration: (item, active, id) => {
6145
+ imageGeneration: (item, active, id, agent) => {
5384
6146
  active.toolUseEmitted.add(id);
5385
- this.#emitToolUse(id, CODEX_IMAGE_TOOL, imageGenerationInput(item));
6147
+ this.#emitToolUse(id, CODEX_IMAGE_TOOL, imageGenerationInput(item), agent);
5386
6148
  if (item.savedPath) this.#emitFileProduced(item.savedPath, id);
5387
6149
  const lines = [item.savedPath ? `Saved to ${item.savedPath}` : "No saved path reported", ...shortResult(item.result) ? [item.result] : []];
5388
- this.#emitToolResult(id, lines.join("\n"), item.status === "failed");
6150
+ this.#emitToolResult(id, lines.join("\n"), item.status === "failed", void 0, agent?.toolUseId ?? null);
6151
+ },
6152
+ imageView: (item, _active, id, agent) => {
6153
+ this.#emitToolUse(id, "CodexImageView", { path: item.path }, agent);
6154
+ this.#emitToolResult(id, item.path, false, void 0, agent?.toolUseId ?? null);
6155
+ },
6156
+ subAgentActivity: (item, _active, id, agent) => {
6157
+ if (this.#replayingHistory) {
6158
+ if (item.kind !== "started") return;
6159
+ this.#emitToolUse(id, CODEX_AGENT_TOOL, {
6160
+ ...agentName(item.agentPath) ? { subagent_type: agentName(item.agentPath) } : {},
6161
+ agentThreadId: item.agentThreadId,
6162
+ ...item.agentPath ? { agentPath: item.agentPath } : {}
6163
+ }, agent);
6164
+ 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);
6165
+ return;
6166
+ }
6167
+ const record = this.#agents.get(item.agentThreadId) ?? this.#agents.open(item.agentThreadId, id, void 0, Date.now());
6168
+ const name = agentName(item.agentPath);
6169
+ const relabel = record.agentType === void 0 && name !== void 0;
6170
+ if (relabel) record.agentType = name;
6171
+ if (!record.anchored || relabel) {
6172
+ record.anchored = true;
6173
+ this.#emitToolUse(record.toolUseId, CODEX_AGENT_TOOL, {
6174
+ ...record.agentType ? { subagent_type: record.agentType } : {},
6175
+ agentThreadId: item.agentThreadId,
6176
+ ...item.agentPath ? { agentPath: item.agentPath } : {}
6177
+ }, agent);
6178
+ }
6179
+ if (item.kind === "interrupted") {
6180
+ if (record.status === "running") {
6181
+ this.#agents.settle(record, "failed");
6182
+ this.#emitToolResult(record.toolUseId, "interrupted", true);
6183
+ }
6184
+ return;
6185
+ }
6186
+ if (item.kind !== "started" && record.status !== "running") this.#agents.revive(record);
5389
6187
  },
5390
- imageView: (item, _active, id) => {
5391
- this.#emitToolUse(id, "CodexImageView", { path: item.path });
5392
- this.#emitToolResult(id, item.path, false);
6188
+ collabAgentToolCall: (item, active, id, agent) => {
6189
+ if (!active.toolUseEmitted.has(id)) {
6190
+ active.toolUseEmitted.add(id);
6191
+ this.#emitToolUse(id, CODEX_COLLAB_TOOL, collabInput(item), agent);
6192
+ }
6193
+ if (item.status === "inProgress") return;
6194
+ const failed = item.status === "failed" || item.status === "declined";
6195
+ this.#emitToolResult(id, failed ? item.status : "", failed, void 0, agent?.toolUseId ?? null);
5393
6196
  }
5394
6197
  };
5395
- #emitDelta(delta) {
6198
+ #emitDelta(delta, parent) {
5396
6199
  if (this.#config.includePartialMessages === false) return;
5397
6200
  this.#emit({
5398
6201
  type: "stream_delta",
@@ -5400,11 +6203,11 @@ var CodexRunner = class {
5400
6203
  type: "content_block_delta",
5401
6204
  delta
5402
6205
  },
5403
- parentToolUseId: null,
6206
+ parentToolUseId: parent,
5404
6207
  uuid: randomUUID()
5405
6208
  });
5406
6209
  }
5407
- #emitAssistant(uuid, content) {
6210
+ #emitAssistant(uuid, content, parent) {
5408
6211
  this.#emit({
5409
6212
  type: "assistant_message",
5410
6213
  message: {
@@ -5412,11 +6215,19 @@ var CodexRunner = class {
5412
6215
  content,
5413
6216
  model: this.#model ?? this.#resolvedModel
5414
6217
  },
5415
- parentToolUseId: null,
6218
+ parentToolUseId: parent,
5416
6219
  uuid
5417
6220
  });
5418
6221
  }
5419
- #emitToolUse(id, name, input) {
6222
+ /** `agent` (rather than a bare parent id) because a nested call is also the
6223
+ * agent's progress reading: `SubagentInfo.toolCount` ticks here, once per
6224
+ * card — the `counted` set is what keeps an upserted re-emission (the
6225
+ * finished imageGeneration input) from counting one picture twice. */
6226
+ #emitToolUse(id, name, input, agent) {
6227
+ if (agent && !agent.counted.has(id)) {
6228
+ agent.counted.add(id);
6229
+ agent.toolCount += 1;
6230
+ }
5420
6231
  this.#emit({
5421
6232
  type: "assistant_message",
5422
6233
  message: {
@@ -5429,11 +6240,11 @@ var CodexRunner = class {
5429
6240
  }],
5430
6241
  model: this.#model ?? this.#resolvedModel
5431
6242
  },
5432
- parentToolUseId: null,
6243
+ parentToolUseId: agent?.toolUseId ?? null,
5433
6244
  uuid: `${id}-use`
5434
6245
  });
5435
6246
  }
5436
- #emitToolResult(toolUseId, content, isError, patch) {
6247
+ #emitToolResult(toolUseId, content, isError, patch, parent = null) {
5437
6248
  this.#emit({
5438
6249
  type: "user_message",
5439
6250
  message: {
@@ -5445,7 +6256,7 @@ var CodexRunner = class {
5445
6256
  is_error: isError || void 0
5446
6257
  }]
5447
6258
  },
5448
- parentToolUseId: null,
6259
+ parentToolUseId: parent,
5449
6260
  synthetic: true,
5450
6261
  patch,
5451
6262
  uuid: `${toolUseId}-result`
@@ -5573,6 +6384,8 @@ var CodexRunner = class {
5573
6384
  };
5574
6385
  this.#lastActivityAt = event.ts;
5575
6386
  this.#activityCount += transcriptActivity(body);
6387
+ this.#contextUsage = contextReading(body) ?? this.#contextUsage;
6388
+ if (body.type === "conversation_reset") this.#contextUsage = void 0;
5576
6389
  this.#events.push(event);
5577
6390
  this.#subscribers.emit(event);
5578
6391
  }
@@ -5594,7 +6407,15 @@ var CodexRunner = class {
5594
6407
  * const c=JSON.parse(d.slice(s,i));
5595
6408
  * for(const m of c.models) console.log(m.slug, m.display_name,
5596
6409
  * m.visibility, m.supported_reasoning_levels.map(l=>l.effort).join(","))'\
5597
- * "$(node -p 'require.resolve("@openai/codex-darwin-arm64/package.json").replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
6410
+ * "$(node -p 'const{createRequire}=require("module");
6411
+ * const w=require.resolve("@openai/codex/package.json");
6412
+ * createRequire(w).resolve("@openai/codex-darwin-arm64/package.json")
6413
+ * .replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
6414
+ *
6415
+ * The two-hop resolve is NOT optional: under pnpm's strict layout the platform
6416
+ * package is a dependency of `@openai/codex`, so it resolves only from that
6417
+ * wrapper's location, never from the repo root. Resolving it directly throws
6418
+ * MODULE_NOT_FOUND — the same two hops `resolveBundledCodexExecutable` makes.
5598
6419
  *
5599
6420
  * Mapping decisions:
5600
6421
  * - the internal `codex-auto-review` row is dropped (the codex analogue of
@@ -5606,7 +6427,7 @@ var CodexRunner = class {
5606
6427
  * `max`/`ultra` beyond the SDK union; trust the binary, keep strings open.
5607
6428
  */
5608
6429
  const CODEX_CATALOG = {
5609
- provenance: "embedded model presets of @openai/codex@0.146.0 (darwin-arm64 binary), extracted 2026-08-05",
6430
+ provenance: "embedded model presets of @openai/codex@0.149.0 (darwin-arm64 binary), extracted 2026-08-22",
5610
6431
  models: [
5611
6432
  {
5612
6433
  value: "gpt-5.6-sol",