@workerdeck/core 0.18.0 → 0.20.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
@@ -4,12 +4,12 @@ import { getSessionInfo, getSessionMessages, listSessions, query } from "@anthro
4
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 { appendFileSync, 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. */
@@ -607,8 +607,12 @@ function staleReplaySeqs(events, afterSeq) {
607
607
  * 1. `afterSeq` — the caller already holds everything at or below it.
608
608
  * 2. `resetSeq` — transcript *content* strictly below the latest
609
609
  * `conversation_reset` is skipped, so a re-attach cannot resurrect a cleared
610
- * conversation while state events still replay. Claude's alone; the other
611
- * engines pass 0.
610
+ * conversation while state events still replay. **Every engine that can emit
611
+ * a reset must track it and pass it** — this was Claude's alone only for as
612
+ * long as Claude's was the only engine that could produce the event, and the
613
+ * failure when a runner forgets is quiet: the end state is right for a
614
+ * current reducer, so nothing looks broken while every attach re-sends the
615
+ * whole cleared conversation for the process's lifetime.
612
616
  * 3. `coalesceReplay` — last-write-wins state readings superseded later in the
613
617
  * same replay (`staleReplaySeqs`), plus everything `replayRetains` says the
614
618
  * reducer reads and discards. Opt-in, and only sound for a consumer whose
@@ -1309,6 +1313,28 @@ var SessionRunner = class {
1309
1313
  async interrupt() {
1310
1314
  await this.#query?.interrupt();
1311
1315
  }
1316
+ /**
1317
+ * Reset the conversation by sending the `/clear` the CLI already honors.
1318
+ *
1319
+ * Deliberately not a second mechanism: this engine's reset arrives *from the
1320
+ * SDK*, and `normalizeSdkMessage` turns the CLI's report of it into the
1321
+ * `conversation_reset` event (adopting the new conversation id and re-polling
1322
+ * context usage on the way through). Reimplementing the clear here would give
1323
+ * one engine two ways to reach the same state, and only one of them would get
1324
+ * the id adoption right. So the command and the composer's `/clear` are one
1325
+ * behaviour, and this method is the thin end of it.
1326
+ *
1327
+ * The one place it differs from the other two engines: this resolves when the
1328
+ * `/clear` has been **handed to the CLI**, not when the reset has happened —
1329
+ * the CLI queues its own streamed input, so waiting is its job, and there is
1330
+ * no chain here to ride. The observable contract is the same (a clear sent
1331
+ * mid-turn queues rather than cutting the turn short); only the moment the
1332
+ * promise settles is weaker, and no caller depends on it.
1333
+ */
1334
+ async clearContext() {
1335
+ if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
1336
+ this.sendMessage("/clear");
1337
+ }
1312
1338
  async setPermissionMode(mode) {
1313
1339
  await this.#query?.setPermissionMode(mode);
1314
1340
  this.#permissionMode = mode;
@@ -1836,6 +1862,15 @@ var AiSdkRunner = class {
1836
1862
  */
1837
1863
  #contextUsage;
1838
1864
  #activityCount = 0;
1865
+ /**
1866
+ * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
1867
+ * never truncated — it still carries the state-bearing events (`capabilities`,
1868
+ * `system_init`, …) a fresh attacher depends on and which are not re-emitted —
1869
+ * but `subscribe()` skips transcript *content* strictly below this mark, so a
1870
+ * replay does not resurrect a cleared conversation. A later reset supersedes
1871
+ * an earlier one by overwriting it.
1872
+ */
1873
+ #resetSeq = 0;
1839
1874
  #status = "starting";
1840
1875
  #permissionMode;
1841
1876
  #messages = [];
@@ -1887,8 +1922,10 @@ var AiSdkRunner = class {
1887
1922
  this.#activityCount = 0;
1888
1923
  for (const event of this.#events) {
1889
1924
  this.#activityCount += transcriptActivity(event);
1890
- if (event.type === "conversation_reset") this.#contextUsage = void 0;
1891
- else this.#contextUsage = contextReading(event) ?? this.#contextUsage;
1925
+ if (event.type === "conversation_reset") {
1926
+ this.#resetSeq = event.seq;
1927
+ this.#contextUsage = void 0;
1928
+ } else this.#contextUsage = contextReading(event) ?? this.#contextUsage;
1892
1929
  }
1893
1930
  this.#messages = [...state.messages];
1894
1931
  for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
@@ -2158,6 +2195,30 @@ var AiSdkRunner = class {
2158
2195
  }
2159
2196
  return result.text;
2160
2197
  }
2198
+ /**
2199
+ * Reset the conversation: drop the message array the next turn would have
2200
+ * been built from. There is no engine round trip — this runner *is* where the
2201
+ * transcript lives, so clearing it is the whole operation.
2202
+ *
2203
+ * Two things ride along, both already written elsewhere and both load-bearing
2204
+ * here. `#emit`'s `conversation_reset` arm retires `#contextUsage` (the
2205
+ * reading described a conversation that no longer exists), and the same arm
2206
+ * in `restore` keeps a parked session that comes back after a clear from
2207
+ * resurrecting it. Pending tool calls are NOT swept: a parked call is work a
2208
+ * backend still owes an answer for, and a clear is not an interrupt — the
2209
+ * refusal below is what keeps the two apart.
2210
+ */
2211
+ async clearContext() {
2212
+ if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
2213
+ const run = this.#turnChain.then(() => {
2214
+ if (this.#closed) throw new Error("session is closed");
2215
+ if (this.#pendingToolCalls.size > 0) throw new Error("cannot clear context while tool calls are outstanding");
2216
+ this.#messages = [];
2217
+ this.#emit({ type: "conversation_reset" });
2218
+ });
2219
+ this.#turnChain = run.then(() => void 0, () => void 0);
2220
+ await run;
2221
+ }
2161
2222
  async interrupt() {
2162
2223
  if (this.#abort) this.#abort.abort();
2163
2224
  else if (this.#pendingToolCalls.size > 0) {
@@ -2238,7 +2299,7 @@ var AiSdkRunner = class {
2238
2299
  return this.#events.find((event) => event.seq === seq);
2239
2300
  }
2240
2301
  subscribe(listener, afterSeq = 0, options) {
2241
- return this.#subscribers.subscribe(this.#events, listener, afterSeq, options);
2302
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
2242
2303
  }
2243
2304
  #scheduleTurn() {
2244
2305
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
@@ -2628,7 +2689,10 @@ var AiSdkRunner = class {
2628
2689
  this.#lastActivityAt = event.ts;
2629
2690
  this.#activityCount += transcriptActivity(body);
2630
2691
  this.#contextUsage = contextReading(body) ?? this.#contextUsage;
2631
- if (body.type === "conversation_reset") this.#contextUsage = void 0;
2692
+ if (body.type === "conversation_reset") {
2693
+ this.#resetSeq = event.seq;
2694
+ this.#contextUsage = void 0;
2695
+ }
2632
2696
  this.#events.push(event);
2633
2697
  this.#subscribers.emit(event);
2634
2698
  }
@@ -4100,6 +4164,25 @@ var CodexAgentTracker = class {
4100
4164
  sweep() {
4101
4165
  for (const record of this.#byThread.values()) if (record.status === "running") this.#settle(record, "failed");
4102
4166
  }
4167
+ /**
4168
+ * The conversation these agents belonged to is gone (a `conversation_reset`).
4169
+ *
4170
+ * Deliberately NOT {@link CodexAgentTracker.sweep}: that settles the running
4171
+ * ones as failed and keeps the rows, which is right when the *process* dies —
4172
+ * the transcript still holds the anchor `tool_use` each row points at, and a
4173
+ * row that vanished would leave that card unexplained. A clear is the other
4174
+ * way round. The anchors go with the transcript, so a surviving row would
4175
+ * publish a `toolUseId` that resolves to nothing — and clients key a
4176
+ * pressable, enterable agent line off exactly that id.
4177
+ */
4178
+ forget() {
4179
+ this.#byThread.clear();
4180
+ }
4181
+ /** The thread ids currently tracked — what a clear remembers so a still-running
4182
+ * agent's later traffic can be dropped rather than re-anchored. */
4183
+ threadIds() {
4184
+ return Array.from(this.#byThread.keys());
4185
+ }
4103
4186
  /** The rollup as `SessionInfo.subagents` serves it — spawn order, fresh
4104
4187
  * objects, and `undefined` when there is nothing to say (absent and empty
4105
4188
  * mean the same thing to a client, and bytes on a polled list are paid for). */
@@ -4117,6 +4200,330 @@ var CodexAgentTracker = class {
4117
4200
  }
4118
4201
  };
4119
4202
  //#endregion
4203
+ //#region src/engines/codex/trust.ts
4204
+ /**
4205
+ * Codex project trust: will this session's cwd get its `.codex/config.toml`?
4206
+ *
4207
+ * Codex only layers a project's `.codex/config.toml` onto the operator's base
4208
+ * config when the project is *trusted* (a `[projects."<path>"]
4209
+ * trust_level = "trusted"` entry in `$CODEX_HOME/config.toml`), and the
4210
+ * app-server surface has no trust prompt — that lives in the TUI. So under
4211
+ * WorkerDeck an untrusted project's config, MCP servers included, is silently
4212
+ * ignored: no error, no notice, servers just missing. The runner asks this
4213
+ * module at session start whether that is about to happen, so the transcript
4214
+ * can say so.
4215
+ *
4216
+ * Semantics, all measured against both the bundled 0.146.0 and 0.149.0
4217
+ * (2026-08-22, via `codex mcp list` from probe cwds and via `thread/start` +
4218
+ * `mcpServerStatus/list` on the app-server surface — identical answers):
4219
+ *
4220
+ * - **Discovery**: config layers come from the cwd and its ancestors up to and
4221
+ * including the nearest directory containing `.git` (dir or file). With no
4222
+ * git anywhere above, the cwd alone is consulted. Directories above the
4223
+ * nearest git root never contribute, trusted or not.
4224
+ * - **Trust per layer**: an exact entry for the layer's own canonical path
4225
+ * decides (an explicit `"untrusted"` beats inherited trust); without one the
4226
+ * layer inherits from the chain's git root — trusted iff the git root has a
4227
+ * trusted entry, where a linked worktree's root also counts its main
4228
+ * repository's entry (the `.git` file's gitdir names it). A trusted
4229
+ * mid-chain directory does NOT trust its children, and plain path
4230
+ * containment without git confers nothing.
4231
+ * - **Canonical paths**: codex matches entries against the canonicalized cwd —
4232
+ * a macOS `/tmp/...` entry never matches the `/private/tmp/...` it points
4233
+ * at, while the reverse spelling works (and the app-server canonicalizes its
4234
+ * `cwd` param too). Both sides here are realpath'd, which can only err
4235
+ * toward silence.
4236
+ * - **The gate is sandbox-scoped**: `thread/start` under `workspace-write` or
4237
+ * `danger-full-access` (permission modes `acceptEdits`/`bypassPermissions`)
4238
+ * WRITES the trust entry itself and loads the config — only `read-only`
4239
+ * (mode `default`) leaves the project untrusted and the config ignored. A
4240
+ * later `turn/start` with a wider sandboxPolicy does not heal the thread
4241
+ * (measured): the caller probes `default`-mode sessions only, and the notice
4242
+ * stays true for the session it opens.
4243
+ * - `trust_level`'s vocabulary is exactly `trusted`/`untrusted`; any other
4244
+ * value fails codex's bootstrap outright ("unknown variant"), so a config
4245
+ * carrying one probes silent — that session announces its own failure.
4246
+ *
4247
+ * The correctness bar for every degrade path: a FALSE notice — warning about a
4248
+ * project codex actually trusts — is worse than a missed one. The narrow TOML
4249
+ * reader below refuses (→ silence) anything it cannot interpret with
4250
+ * certainty, rather than guessing.
4251
+ */
4252
+ const BARE_KEY = /[A-Za-z0-9_-]/;
4253
+ function skipWs(text, pos) {
4254
+ let i = pos;
4255
+ while (i < text.length && (text[i] === " " || text[i] === " ")) i++;
4256
+ return i;
4257
+ }
4258
+ /** One-line TOML basic string starting at `pos` (which must be `"`). Undefined
4259
+ * on an escape TOML doesn't define or a close quote that never comes — the
4260
+ * caller refuses the file rather than guessing what codex would read. */
4261
+ function parseBasicString(text, pos) {
4262
+ let out = "";
4263
+ let i = pos + 1;
4264
+ while (i < text.length) {
4265
+ const ch = text[i];
4266
+ if (ch === "\"") return {
4267
+ value: out,
4268
+ end: i + 1
4269
+ };
4270
+ if (ch === "\\") {
4271
+ const esc = text[i + 1];
4272
+ if (esc === "b") out += "\b";
4273
+ else if (esc === "t") out += " ";
4274
+ else if (esc === "n") out += "\n";
4275
+ else if (esc === "f") out += "\f";
4276
+ else if (esc === "r") out += "\r";
4277
+ else if (esc === "\"") out += "\"";
4278
+ else if (esc === "\\") out += "\\";
4279
+ else if (esc === "u" || esc === "U") {
4280
+ const width = esc === "u" ? 4 : 8;
4281
+ const hex = text.slice(i + 2, i + 2 + width);
4282
+ if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return void 0;
4283
+ const code = Number.parseInt(hex, 16);
4284
+ if (code > 1114111) return void 0;
4285
+ out += String.fromCodePoint(code);
4286
+ i += width;
4287
+ } else return void 0;
4288
+ i += 2;
4289
+ continue;
4290
+ }
4291
+ out += ch;
4292
+ i++;
4293
+ }
4294
+ }
4295
+ /** One-line TOML literal string starting at `pos` (which must be `'`). */
4296
+ function parseLiteralString(text, pos) {
4297
+ const close = text.indexOf("'", pos + 1);
4298
+ if (close === -1) return void 0;
4299
+ return {
4300
+ value: text.slice(pos + 1, close),
4301
+ end: close + 1
4302
+ };
4303
+ }
4304
+ /** A dotted key path — bare, `"basic"` and `'literal'` keys, whitespace around
4305
+ * the dots — as found in table headers and on the left of assignments. */
4306
+ function parseKeyPath(text, pos) {
4307
+ const keys = [];
4308
+ let i = pos;
4309
+ for (;;) {
4310
+ i = skipWs(text, i);
4311
+ const ch = text[i];
4312
+ if (ch === "\"" || ch === "'") {
4313
+ const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4314
+ if (!str) return void 0;
4315
+ keys.push(str.value);
4316
+ i = str.end;
4317
+ } else if (ch !== void 0 && BARE_KEY.test(ch)) {
4318
+ let end = i;
4319
+ while (end < text.length && BARE_KEY.test(text[end])) end++;
4320
+ keys.push(text.slice(i, end));
4321
+ i = end;
4322
+ } else return;
4323
+ i = skipWs(text, i);
4324
+ if (text[i] !== ".") return {
4325
+ value: keys,
4326
+ end: i
4327
+ };
4328
+ i++;
4329
+ }
4330
+ }
4331
+ /**
4332
+ * Scan an assignment's value (or the continuation line of a multi-line array),
4333
+ * confirming where it ends. Returns the bracket depth carried onto the next
4334
+ * line (0 = the value is complete) plus the string itself when the whole value
4335
+ * was one plain one-line string. Undefined refuses the file: multi-line
4336
+ * strings are where a line reader starts misreading string *content* as
4337
+ * sections and entries — the exact mistake that could flip a real trust entry
4338
+ * — so they are not parsed around, they end the attempt.
4339
+ */
4340
+ function scanValueLine(text, pos, depth) {
4341
+ let i = skipWs(text, pos);
4342
+ if (depth === 0 && (text[i] === "\"" || text[i] === "'")) {
4343
+ if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return void 0;
4344
+ const str = text[i] === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4345
+ if (!str) return void 0;
4346
+ const rest = skipWs(text, str.end);
4347
+ if (rest < text.length && text[rest] !== "#") return void 0;
4348
+ return {
4349
+ depth: 0,
4350
+ value: str.value
4351
+ };
4352
+ }
4353
+ while (i < text.length) {
4354
+ const ch = text[i];
4355
+ if (ch === "#") break;
4356
+ if (ch === "\"" || ch === "'") {
4357
+ if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return void 0;
4358
+ const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4359
+ if (!str) return void 0;
4360
+ i = str.end;
4361
+ continue;
4362
+ }
4363
+ if (ch === "[" || ch === "{") depth++;
4364
+ else if (ch === "]" || ch === "}") {
4365
+ depth--;
4366
+ if (depth < 0) return void 0;
4367
+ }
4368
+ i++;
4369
+ }
4370
+ return { depth };
4371
+ }
4372
+ /**
4373
+ * The `[projects."<path>"] trust_level = "..."` entries of a codex
4374
+ * `config.toml`, by a deliberately narrow reader (core takes no TOML
4375
+ * dependency for this). Handles what codex itself writes plus the reasonable
4376
+ * hand-edits — comments, CRLF, whitespace, quoted keys with escapes, literal
4377
+ * and bare keys, `[projects]`-with-dotted-keys and top-level dotted forms,
4378
+ * single-line inline tables, multi-line arrays — and returns **undefined for
4379
+ * anything else it meets anywhere in the file** (multi-line strings,
4380
+ * `projects` as an inline table, array-of-tables, junk): the caller treats
4381
+ * undefined as "cannot know" and stays silent. Conflicting duplicate entries
4382
+ * also refuse — invalid for TOML, and guessing wrong is a false notice.
4383
+ */
4384
+ function parseProjectTrustEntries(source) {
4385
+ const entries = /* @__PURE__ */ new Map();
4386
+ let section = [];
4387
+ let carryDepth = 0;
4388
+ for (const rawLine of source.split("\n")) {
4389
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
4390
+ if (carryDepth > 0) {
4391
+ const scanned = scanValueLine(line, 0, carryDepth);
4392
+ if (!scanned) return void 0;
4393
+ carryDepth = scanned.depth;
4394
+ continue;
4395
+ }
4396
+ const start = skipWs(line, 0);
4397
+ if (start >= line.length || line[start] === "#") continue;
4398
+ if (line[start] === "[") {
4399
+ const array = line.startsWith("[[", start);
4400
+ const path = parseKeyPath(line, start + (array ? 2 : 1));
4401
+ if (!path) return void 0;
4402
+ const close = array ? "]]" : "]";
4403
+ if (!line.startsWith(close, path.end)) return void 0;
4404
+ const rest = skipWs(line, path.end + close.length);
4405
+ if (rest < line.length && line[rest] !== "#") return void 0;
4406
+ if (array && path.value[0] === "projects") return void 0;
4407
+ section = path.value;
4408
+ continue;
4409
+ }
4410
+ const key = parseKeyPath(line, start);
4411
+ if (!key) return void 0;
4412
+ if (line[key.end] !== "=") return void 0;
4413
+ const scanned = scanValueLine(line, key.end + 1, 0);
4414
+ if (!scanned) return void 0;
4415
+ carryDepth = scanned.depth;
4416
+ const full = [...section, ...key.value];
4417
+ if (full[0] !== "projects") continue;
4418
+ if (full.length < 3) return void 0;
4419
+ if (full.length === 3 && full[2] === "trust_level") {
4420
+ if (carryDepth !== 0 || scanned.value === void 0) return void 0;
4421
+ const project = full[1];
4422
+ const existing = entries.get(project);
4423
+ if (existing !== void 0 && existing !== scanned.value) return void 0;
4424
+ entries.set(project, scanned.value);
4425
+ }
4426
+ }
4427
+ if (carryDepth > 0) return void 0;
4428
+ return entries;
4429
+ }
4430
+ /**
4431
+ * A linked worktree inherits trust from its main repository's entry (measured:
4432
+ * trusting the main repo path loads the worktree's project config). The
4433
+ * worktree's `.git` is a FILE whose `gitdir:` line names
4434
+ * `<main>/.git/worktrees/<name>`; the directory owning that `.git` is the
4435
+ * anchor to look up. Anything unreadable or shaped differently resolves false
4436
+ * — this route can only ADD trust, i.e. silence, never a false notice.
4437
+ */
4438
+ function mainRepositoryTrusted(gitRootDir, canonical) {
4439
+ const gitPath = join(gitRootDir, ".git");
4440
+ try {
4441
+ if (!statSync(gitPath).isFile()) return false;
4442
+ const match = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(readFileSync(gitPath, "utf8"));
4443
+ if (!match) return false;
4444
+ const gitdir = resolve(gitRootDir, match[1]);
4445
+ const at = gitdir.lastIndexOf(`${sep}.git${sep}`);
4446
+ if (at <= 0) return false;
4447
+ let main = gitdir.slice(0, at);
4448
+ try {
4449
+ main = realpathSync(main);
4450
+ } catch {}
4451
+ return canonical.get(main) === "trusted";
4452
+ } catch {
4453
+ return false;
4454
+ }
4455
+ }
4456
+ /**
4457
+ * The notice for a codex session about to run on a cwd whose
4458
+ * `.codex/config.toml` codex will ignore, or undefined when there is nothing
4459
+ * to say — no project config anywhere codex would look, the project is
4460
+ * trusted, or the situation cannot be established with certainty. Read-only
4461
+ * throughout: WorkerDeck never writes trust entries (adjacent to the auth red
4462
+ * lines — trusting a directory is the operator's decision, made in codex's
4463
+ * own prompt or by their own hand).
4464
+ */
4465
+ function untrustedProjectNotice(options) {
4466
+ let cwd;
4467
+ try {
4468
+ cwd = realpathSync(options.cwd);
4469
+ } catch {
4470
+ return;
4471
+ }
4472
+ let home = resolve(options.codexHome);
4473
+ try {
4474
+ home = realpathSync(options.codexHome);
4475
+ } catch {}
4476
+ const chain = [];
4477
+ let dir = cwd;
4478
+ for (;;) {
4479
+ chain.push(dir);
4480
+ if (existsSync(join(dir, ".git"))) break;
4481
+ const parent = dirname(dir);
4482
+ if (parent === dir) break;
4483
+ dir = parent;
4484
+ }
4485
+ const anchor = chain[chain.length - 1];
4486
+ const gitRoot = existsSync(join(anchor, ".git")) ? anchor : void 0;
4487
+ const layers = (gitRoot ? chain : [cwd]).filter((layer) => {
4488
+ if (!existsSync(join(layer, ".codex", "config.toml"))) return false;
4489
+ try {
4490
+ return realpathSync(join(layer, ".codex")) !== home;
4491
+ } catch {
4492
+ return false;
4493
+ }
4494
+ });
4495
+ if (layers.length === 0) return void 0;
4496
+ const homeConfigPath = join(options.codexHome, "config.toml");
4497
+ let source = "";
4498
+ try {
4499
+ source = readFileSync(homeConfigPath, "utf8");
4500
+ } catch (error) {
4501
+ if (error.code !== "ENOENT") return void 0;
4502
+ }
4503
+ const entries = parseProjectTrustEntries(source);
4504
+ if (!entries) return void 0;
4505
+ for (const value of entries.values()) if (value !== "trusted" && value !== "untrusted") return void 0;
4506
+ const canonical = /* @__PURE__ */ new Map();
4507
+ for (const [key, value] of entries) {
4508
+ let path = key;
4509
+ try {
4510
+ path = realpathSync(key);
4511
+ } catch {}
4512
+ if (canonical.get(path) === "trusted") continue;
4513
+ canonical.set(path, value);
4514
+ }
4515
+ const rootTrusted = gitRoot !== void 0 && (canonical.get(gitRoot) === "trusted" || mainRepositoryTrusted(gitRoot, canonical));
4516
+ const ignored = layers.filter((layer) => {
4517
+ const entry = canonical.get(layer);
4518
+ if (entry !== void 0) return entry !== "trusted";
4519
+ return !rootTrusted;
4520
+ });
4521
+ if (ignored.length === 0) return void 0;
4522
+ const trustDir = gitRoot ?? cwd;
4523
+ const configs = ignored.map((layer) => join(layer, ".codex", "config.toml"));
4524
+ return `codex does not trust this directory, so ${configs.length === 1 ? `its project config (${configs[0]}) is` : `its project configs (${configs.join(", ")}) are`} being ignored — MCP servers and settings declared there will be missing from this session. To trust it, run codex once in ${trustDir} and accept the trust prompt, or add [projects."${trustDir}"] with trust_level = "trusted" to ${homeConfigPath}.`;
4525
+ }
4526
+ //#endregion
4120
4527
  //#region src/engines/codex/runner.ts
4121
4528
  /**
4122
4529
  * thread/start's sandbox axis (string form) — our permission modes as codex
@@ -4124,16 +4531,30 @@ var CodexAgentTracker = class {
4124
4531
  * the OS sandbox and — with the ask policy below — escalates to a real
4125
4532
  * question), `acceptEdits` → workspace-write (in-workspace writes sail
4126
4533
  * through, the acceptEdits grant), `bypassPermissions` → danger-full-access.
4534
+ * `auto` rides the SAME sandbox as acceptEdits — it is not a wider grant, it
4535
+ * only moves *who answers* the approvals (see {@link APPROVALS_REVIEWER_BY_MODE}).
4127
4536
  */
4128
4537
  const THREAD_SANDBOX_BY_MODE = {
4129
4538
  default: "read-only",
4130
4539
  acceptEdits: "workspace-write",
4540
+ auto: "workspace-write",
4131
4541
  bypassPermissions: "danger-full-access"
4132
4542
  };
4133
- /** turn/start's sandboxPolicy axis (object form — same policy, second shape). */
4543
+ /**
4544
+ * turn/start's sandboxPolicy axis (object form — same policy, second shape).
4545
+ *
4546
+ * The `workspaceWrite` entries here are a SHAPE, not the whole policy: every
4547
+ * unstated field of that variant is serde-defaulted by the app-server, so
4548
+ * sending it bare silently overrides the operator's `[sandbox_workspace_write]`
4549
+ * — `network_access` back to false, `writable_roots` back to empty — on every
4550
+ * turn. {@link CodexRunner.#turnSandboxPolicy} restates those fields from
4551
+ * `config/read`; nothing else may send this map's `workspaceWrite` entries
4552
+ * directly.
4553
+ */
4134
4554
  const TURN_SANDBOX_BY_MODE = {
4135
4555
  default: { type: "readOnly" },
4136
4556
  acceptEdits: { type: "workspaceWrite" },
4557
+ auto: { type: "workspaceWrite" },
4137
4558
  bypassPermissions: { type: "dangerFullAccess" }
4138
4559
  };
4139
4560
  /**
@@ -4177,8 +4598,33 @@ const THREAD_SCOPED_NOTIFICATIONS = new Set([
4177
4598
  const APPROVAL_POLICY_BY_MODE = {
4178
4599
  default: GRANULAR_ASK,
4179
4600
  acceptEdits: GRANULAR_ASK,
4601
+ auto: GRANULAR_ASK,
4180
4602
  bypassPermissions: GRANULAR_NEVER
4181
4603
  };
4604
+ /**
4605
+ * The THIRD approval axis — *who reviews*, independent of the sandbox axis and
4606
+ * the ask axis above. Codex's `approvalsReviewer` (thread/start and turn/start,
4607
+ * present since 0.146.0) routes every approval request either to the user
4608
+ * (`'user'`, codex's own default) or to `'auto_review'`: a prompted subagent
4609
+ * that gathers context and applies a risk framework before allowing or denying.
4610
+ * That is codex's "Approve for me" preset, and our `auto` mode is exactly it.
4611
+ *
4612
+ * Sent explicitly for EVERY mode rather than omitted for the default — a thread
4613
+ * inherits `approvalsReviewer` across turns ("this turn and subsequent turns"),
4614
+ * so leaving it unset would let a stale reviewer from an earlier turn survive a
4615
+ * mode switch back to a user-reviewed mode. Stating it every time makes the
4616
+ * mode the single source of truth.
4617
+ *
4618
+ * NOTE the asymmetry with the Claude engine's `auto`: that classifier is
4619
+ * operator-configurable (`autoMode.environment`, allow/soft_deny/hard_deny);
4620
+ * this reviewer has no configuration surface at all.
4621
+ */
4622
+ const APPROVALS_REVIEWER_BY_MODE = {
4623
+ default: "user",
4624
+ acceptEdits: "user",
4625
+ auto: "auto_review",
4626
+ bypassPermissions: "user"
4627
+ };
4182
4628
  /** Fallback timeout for a pending approval nobody answers — the SessionRunner
4183
4629
  * default, so unattended codex sessions land the same way Claude ones do. */
4184
4630
  const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
@@ -4580,6 +5026,15 @@ var CodexRunner = class {
4580
5026
  */
4581
5027
  #contextUsage;
4582
5028
  #activityCount = 0;
5029
+ /**
5030
+ * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
5031
+ * never truncated — it still carries the state-bearing events (`capabilities`,
5032
+ * `system_init`, …) a fresh attacher depends on and which are not re-emitted —
5033
+ * but `subscribe()` skips transcript *content* strictly below this mark, so a
5034
+ * replay does not resurrect a cleared conversation. A later reset supersedes
5035
+ * an earlier one by overwriting it.
5036
+ */
5037
+ #resetSeq = 0;
4583
5038
  #status = "starting";
4584
5039
  #sdkSessionId;
4585
5040
  #model;
@@ -4596,6 +5051,8 @@ var CodexRunner = class {
4596
5051
  #turnChain = Promise.resolve();
4597
5052
  #activeTurn;
4598
5053
  #connection;
5054
+ /** Per-child, from `config/read`; undefined = read failed, send the bare shape. */
5055
+ #workspaceWrite;
4599
5056
  #threadLoaded = false;
4600
5057
  #numTurns = 0;
4601
5058
  #totalCostUsd;
@@ -4640,6 +5097,15 @@ var CodexRunner = class {
4640
5097
  * it, and only the child process dying (or the session closing) ends them
4641
5098
  * all — see the module doc in `subagents.ts`. */
4642
5099
  #agents = new CodexAgentTracker();
5100
+ /** Threads that belonged to a conversation this session has cleared — the
5101
+ * agents that were still running when it happened. Their notifications keep
5102
+ * arriving on the same connection (a clear does not interrupt them and does
5103
+ * not drop the child), and without this {@link CodexRunner.#agentFor} would
5104
+ * mint them a fresh anchor and stream the cleared conversation's agent work
5105
+ * into the new one. Never pruned: it is a handful of uuids for the session's
5106
+ * life, and a late report from a long-dead agent is exactly what it exists to
5107
+ * catch. */
5108
+ #clearedThreads = /* @__PURE__ */ new Set();
4643
5109
  constructor(config, id = randomUUID()) {
4644
5110
  const mode = config.permissionMode ?? "default";
4645
5111
  if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
@@ -4723,6 +5189,7 @@ var CodexRunner = class {
4723
5189
  start() {
4724
5190
  if (this.#started) return this.#turnChain;
4725
5191
  this.#started = true;
5192
+ this.#warnUntrustedProject();
4726
5193
  if (this.#config.resume && this.#config.backfillHistory !== false) {
4727
5194
  this.#backfillPending = true;
4728
5195
  this.#turnChain = this.#turnChain.then(() => this.#backfillHistory());
@@ -4732,6 +5199,37 @@ var CodexRunner = class {
4732
5199
  return this.#turnChain;
4733
5200
  }
4734
5201
  /**
5202
+ * One-time transcript notice for the codex trust gap: a `default`-mode
5203
+ * session (read-only sandbox) on an untrusted cwd has its
5204
+ * `.codex/config.toml` — MCP servers included — silently ignored, and the
5205
+ * app-server surface has no trust prompt to say so (the TUI's prompt is
5206
+ * where the entry normally gets written). `acceptEdits`/`bypassPermissions`
5207
+ * sessions are exempt because their `thread/start` (workspace-write /
5208
+ * danger-full-access sandbox) writes the trust entry itself and loads the
5209
+ * config — measured against 0.146.0 and 0.149.0; a notice there would be
5210
+ * false. Emitted as `session_error`, which both clients render as an inline
5211
+ * notice while the session keeps running (the backfill-history precedent),
5212
+ * so nothing new rides the wire. Every degrade path is silence: a false
5213
+ * warning on a trusted project is worse than a missed one.
5214
+ */
5215
+ #warnUntrustedProject() {
5216
+ if (this.#permissionMode !== "default") return;
5217
+ try {
5218
+ const env = this.#childEnv();
5219
+ const pin = env.CODEX_HOME;
5220
+ if (pin !== void 0 && pin.length === 0) return;
5221
+ const codexHome = pin ?? join(env.HOME ?? homedir(), ".codex");
5222
+ const message = untrustedProjectNotice({
5223
+ cwd: this.#cwd,
5224
+ codexHome
5225
+ });
5226
+ if (message) this.#emit({
5227
+ type: "session_error",
5228
+ message
5229
+ });
5230
+ } catch {}
5231
+ }
5232
+ /**
4735
5233
  * List skills over a **throwaway** connection, for a session with nothing else
4736
5234
  * to do yet.
4737
5235
  *
@@ -4786,6 +5284,15 @@ var CodexRunner = class {
4786
5284
  }
4787
5285
  sendMessage(text, attachments) {
4788
5286
  if (this.#closed) throw new Error("session is closed");
5287
+ if (text.trim() === "/clear" && !attachments?.length) {
5288
+ this.clearContext().catch((error) => {
5289
+ this.#emit({
5290
+ type: "session_error",
5291
+ message: `could not clear the conversation: ${error instanceof Error ? error.message : String(error)}`
5292
+ });
5293
+ });
5294
+ return;
5295
+ }
4789
5296
  const input = this.#buildInput(text, attachments ?? []);
4790
5297
  const echo = () => this.#emit({
4791
5298
  type: "user_message",
@@ -4857,6 +5364,66 @@ var CodexRunner = class {
4857
5364
  await this.#interruptTurn();
4858
5365
  await this.#turnChain;
4859
5366
  }
5367
+ /**
5368
+ * Reset the conversation: a **fresh thread on the same session**.
5369
+ *
5370
+ * Codex has no clear/reset RPC — `thread/compact/start` summarises and
5371
+ * continues, `thread/fork` makes a second thread, and neither is "same
5372
+ * session, empty context". So the analog is to stop resuming the old thread
5373
+ * and start a new one, which is the path a dead child already takes minus the
5374
+ * resume. The old thread is NOT deleted: it stays in CODEX_HOME and stays
5375
+ * resumable from `GET /sdk-sessions`.
5376
+ *
5377
+ * Two things it does on the way through, both mirroring the Claude engine's
5378
+ * SDK-driven reset (`engines/claude/runner.ts`):
5379
+ *
5380
+ * 1. **The new thread id is adopted before `conversation_reset` is emitted**,
5381
+ * whenever a child is already up — the eager `thread/start` costs no
5382
+ * tokens and no model call, and it is what keeps the dormant record from
5383
+ * ever naming the conversation that was just cleared. With no child there
5384
+ * is nothing to start against and the id is simply dropped; the parking
5385
+ * service treats a resumable session with no engine session id as one with
5386
+ * nothing to come back to, and forgets the stale record.
5387
+ * 2. **The context reading is retired**, in `#emit`'s `conversation_reset`
5388
+ * arm. Codex cannot re-poll it the way Claude does — the only source is
5389
+ * `thread/tokenUsage/updated`, which arrives *during* a turn — so there is
5390
+ * no reading at all until the next turn runs, and the protocol's rule
5391
+ * applies: render nothing rather than a stale ring or a 0%.
5392
+ *
5393
+ * The turn counter stays monotonic across this, on purpose (it is an unread
5394
+ * cursor, not an item count), and so does `activityCount` — `#emit` owns both.
5395
+ */
5396
+ async clearContext() {
5397
+ if (this.#closed) throw new Error("session is closed");
5398
+ const run = this.#turnChain.then(() => this.#clearNow());
5399
+ this.#turnChain = run.then(() => void 0, () => void 0);
5400
+ await run;
5401
+ }
5402
+ /** The clear itself, only ever called as a turn-chain link. */
5403
+ async #clearNow() {
5404
+ if (this.#closed) throw new Error("session is closed");
5405
+ const previousThread = this.#sdkSessionId;
5406
+ this.#sdkSessionId = void 0;
5407
+ this.#threadLoaded = false;
5408
+ if (this.#connection) try {
5409
+ await this.#ensureThread();
5410
+ } catch (error) {
5411
+ this.#sdkSessionId = previousThread;
5412
+ this.#threadLoaded = false;
5413
+ throw error;
5414
+ }
5415
+ for (const agent of this.#agents.threadIds()) this.#clearedThreads.add(agent);
5416
+ this.#agents.forget();
5417
+ for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
5418
+ behavior: "deny",
5419
+ message: "the conversation was cleared"
5420
+ }, "policy");
5421
+ this.#resumedHistory = void 0;
5422
+ this.#emit({
5423
+ type: "conversation_reset",
5424
+ sdkSessionId: this.#sdkSessionId
5425
+ });
5426
+ }
4860
5427
  /** Address the in-flight turn only (no approval sweep) — also the follow-up
4861
5428
  * for a deny+interrupt whose wire decision couldn't carry the interrupt. */
4862
5429
  async #interruptTurn() {
@@ -4934,12 +5501,54 @@ var CodexRunner = class {
4934
5501
  return this.#events.find((event) => event.seq === seq);
4935
5502
  }
4936
5503
  subscribe(listener, afterSeq = 0, options) {
4937
- return this.#subscribers.subscribe(this.#events, listener, afterSeq, options);
5504
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
4938
5505
  }
4939
5506
  #scheduleTurn() {
4940
5507
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
4941
5508
  }
4942
5509
  /**
5510
+ * Read `[sandbox_workspace_write]` as codex resolves it for this session's
5511
+ * cwd, once per child, so {@link CodexRunner.#turnSandboxPolicy} can restate
5512
+ * it verbatim.
5513
+ *
5514
+ * Why this exists at all: `turn/start`'s object-form sandbox policy is
5515
+ * serde-defaulted field by field, so `{type: 'workspaceWrite'}` bare means
5516
+ * `networkAccess: false, writableRoots: []` NO MATTER what the operator
5517
+ * configured — and we must keep sending the object every turn, because
5518
+ * restating it is what makes a between-turns permission-mode switch take
5519
+ * effect. Measured against 0.149.0 with `network_access = true` set: the
5520
+ * bare object produced `curl: (6) Could not resolve host`, the fully-stated
5521
+ * object and an omitted policy both produced `200`. `read-only` is not
5522
+ * affected — the setting is scoped to workspace-write, as its name says, and
5523
+ * a read-only sandbox has no network either way.
5524
+ *
5525
+ * A failure here is not fatal: `#workspaceWrite` stays undefined and we send
5526
+ * the bare shape, which is exactly the behaviour that shipped before.
5527
+ */
5528
+ async #readWorkspaceWrite(connection) {
5529
+ this.#workspaceWrite = void 0;
5530
+ try {
5531
+ const block = (await connection.request("config/read", { cwd: this.#cwd }))?.config?.sandbox_workspace_write;
5532
+ if (!block) return;
5533
+ const roots = block.writable_roots;
5534
+ this.#workspaceWrite = {
5535
+ writableRoots: Array.isArray(roots) ? roots.filter((r) => typeof r === "string") : [],
5536
+ networkAccess: block.network_access === true,
5537
+ excludeTmpdirEnvVar: block.exclude_tmpdir_env_var === true,
5538
+ excludeSlashTmp: block.exclude_slash_tmp === true
5539
+ };
5540
+ } catch {}
5541
+ }
5542
+ /** The mode's turn-level sandbox policy, with the operator's workspace-write settings intact. */
5543
+ #turnSandboxPolicy() {
5544
+ const policy = TURN_SANDBOX_BY_MODE[this.#permissionMode];
5545
+ if (policy?.type !== "workspaceWrite" || !this.#workspaceWrite) return policy;
5546
+ return {
5547
+ type: "workspaceWrite",
5548
+ ...this.#workspaceWrite
5549
+ };
5550
+ }
5551
+ /**
4943
5552
  * The session's live connection with its thread loaded, (re)building both as
4944
5553
  * needed: spawn + `initialize`/`initialized` on a fresh child, then
4945
5554
  * `thread/start` (new) or `thread/resume` (a create-request `resume`, or a
@@ -4983,12 +5592,14 @@ var CodexRunner = class {
4983
5592
  throw error;
4984
5593
  }
4985
5594
  connection.notify("initialized");
5595
+ await this.#readWorkspaceWrite(connection);
4986
5596
  }
4987
5597
  if (!this.#threadLoaded) {
4988
5598
  const options = {
4989
5599
  cwd: this.#cwd,
4990
5600
  approvalPolicy: APPROVAL_POLICY_BY_MODE[this.#permissionMode],
4991
- sandbox: THREAD_SANDBOX_BY_MODE[this.#permissionMode]
5601
+ sandbox: THREAD_SANDBOX_BY_MODE[this.#permissionMode],
5602
+ approvalsReviewer: APPROVALS_REVIEWER_BY_MODE[this.#permissionMode]
4992
5603
  };
4993
5604
  if (this.#model) options.model = this.#model;
4994
5605
  const resuming = this.#sdkSessionId !== void 0;
@@ -5231,7 +5842,8 @@ var CodexRunner = class {
5231
5842
  input: turn.input,
5232
5843
  cwd: this.#cwd,
5233
5844
  approvalPolicy: APPROVAL_POLICY_BY_MODE[this.#permissionMode],
5234
- sandboxPolicy: TURN_SANDBOX_BY_MODE[this.#permissionMode]
5845
+ sandboxPolicy: this.#turnSandboxPolicy(),
5846
+ approvalsReviewer: APPROVALS_REVIEWER_BY_MODE[this.#permissionMode]
5235
5847
  };
5236
5848
  const model = this.#model ?? this.#resolvedModel;
5237
5849
  if (model) params.model = model;
@@ -5305,6 +5917,7 @@ var CodexRunner = class {
5305
5917
  if (threadId === void 0 || threadId === this.#sdkSessionId) return void 0;
5306
5918
  const known = this.#agents.get(threadId);
5307
5919
  if (known) return known;
5920
+ if (this.#clearedThreads.has(threadId)) return void 0;
5308
5921
  const nonce = this.#activeTurn?.nonce ?? "codex";
5309
5922
  const record = this.#agents.open(threadId, `${nonce}:agent:${threadId}`, void 0, Date.now());
5310
5923
  record.anchored = true;
@@ -5943,7 +6556,10 @@ var CodexRunner = class {
5943
6556
  this.#lastActivityAt = event.ts;
5944
6557
  this.#activityCount += transcriptActivity(body);
5945
6558
  this.#contextUsage = contextReading(body) ?? this.#contextUsage;
5946
- if (body.type === "conversation_reset") this.#contextUsage = void 0;
6559
+ if (body.type === "conversation_reset") {
6560
+ this.#resetSeq = event.seq;
6561
+ this.#contextUsage = void 0;
6562
+ }
5947
6563
  this.#events.push(event);
5948
6564
  this.#subscribers.emit(event);
5949
6565
  }
@@ -5965,7 +6581,15 @@ var CodexRunner = class {
5965
6581
  * const c=JSON.parse(d.slice(s,i));
5966
6582
  * for(const m of c.models) console.log(m.slug, m.display_name,
5967
6583
  * m.visibility, m.supported_reasoning_levels.map(l=>l.effort).join(","))'\
5968
- * "$(node -p 'require.resolve("@openai/codex-darwin-arm64/package.json").replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
6584
+ * "$(node -p 'const{createRequire}=require("module");
6585
+ * const w=require.resolve("@openai/codex/package.json");
6586
+ * createRequire(w).resolve("@openai/codex-darwin-arm64/package.json")
6587
+ * .replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
6588
+ *
6589
+ * The two-hop resolve is NOT optional: under pnpm's strict layout the platform
6590
+ * package is a dependency of `@openai/codex`, so it resolves only from that
6591
+ * wrapper's location, never from the repo root. Resolving it directly throws
6592
+ * MODULE_NOT_FOUND — the same two hops `resolveBundledCodexExecutable` makes.
5969
6593
  *
5970
6594
  * Mapping decisions:
5971
6595
  * - the internal `codex-auto-review` row is dropped (the codex analogue of
@@ -5977,7 +6601,7 @@ var CodexRunner = class {
5977
6601
  * `max`/`ultra` beyond the SDK union; trust the binary, keep strings open.
5978
6602
  */
5979
6603
  const CODEX_CATALOG = {
5980
- provenance: "embedded model presets of @openai/codex@0.146.0 (darwin-arm64 binary), extracted 2026-08-05",
6604
+ provenance: "embedded model presets of @openai/codex@0.149.0 (darwin-arm64 binary), extracted 2026-08-22",
5981
6605
  models: [
5982
6606
  {
5983
6607
  value: "gpt-5.6-sol",