codeden 0.6.2 → 0.6.3

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.
Files changed (36) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/drizzle/20261112140714_turn_fork_point/migration.sql +1 -0
  3. package/dist/drizzle/20261112140714_turn_fork_point/snapshot.json +3524 -0
  4. package/dist/{main-hdQfX9V6.js → main-Bx58CdV4.js} +1375 -559
  5. package/dist/main-Bx58CdV4.js.map +1 -0
  6. package/dist/{main-DZBmzxSH.js → main-CMt4z1Z9.js} +2 -2
  7. package/dist/{main-DZBmzxSH.js.map → main-CMt4z1Z9.js.map} +1 -1
  8. package/dist/{mediaType-nJs-4z5U.js → mediaType-BCz-hbZl.js} +2 -2
  9. package/dist/{mediaType-nJs-4z5U.js.map → mediaType-BCz-hbZl.js.map} +1 -1
  10. package/dist/{runner-DYf8dV43.js → runner-CiaiUWOE.js} +636 -71
  11. package/dist/runner-CiaiUWOE.js.map +1 -0
  12. package/dist/runner.js +1 -1
  13. package/dist/{server-test-COAiKxTV.js → server-test-b0hHPqXb.js} +3 -3
  14. package/dist/{server-test-COAiKxTV.js.map → server-test-b0hHPqXb.js.map} +1 -1
  15. package/dist/{tree-CF7Q8tXK.js → tree-31WAOm5k.js} +184 -23
  16. package/dist/tree-31WAOm5k.js.map +1 -0
  17. package/dist/web/assets/{core-Rhf-hNq6.js → core-FqX7NQzW.js} +1 -1
  18. package/dist/web/assets/engine-oniguruma-C5UP18m1.js +1 -0
  19. package/dist/web/assets/{highlighted-body-JLMTFACH-DcKwRTm5.js → highlighted-body-JLMTFACH-BMKFJRL-.js} +1 -1
  20. package/dist/web/assets/{highlights-DCtMC0YR.js → highlights-9yuGk1pg.js} +1 -1
  21. package/dist/web/assets/{index-BLggvVMT.js → index-C3QkKvUn.js} +233 -233
  22. package/dist/web/assets/index-CmllrYFx.css +1 -0
  23. package/dist/web/assets/langs-DfCgj44a.js +1 -0
  24. package/dist/web/assets/mermaid-WFAXDIZX-zTA5f8lD.js +1 -0
  25. package/dist/web/assets/{shiki-B3-ws3OK.js → shiki-Qa-y3xHo.js} +2 -2
  26. package/dist/web/assets/{stream-tokenizer-hKFrMxml.js → stream-tokenizer-VL43-ily.js} +1 -1
  27. package/dist/web/assets/{themes-Cvhh4Ovr.js → themes-Di4DO5zr.js} +2 -2
  28. package/dist/web/index.html +2 -2
  29. package/package.json +1 -1
  30. package/dist/main-hdQfX9V6.js.map +0 -1
  31. package/dist/runner-DYf8dV43.js.map +0 -1
  32. package/dist/tree-CF7Q8tXK.js.map +0 -1
  33. package/dist/web/assets/engine-oniguruma-BbpT993F.js +0 -1
  34. package/dist/web/assets/index-JjF9o00P.css +0 -1
  35. package/dist/web/assets/langs-B2ZGl3f_.js +0 -1
  36. package/dist/web/assets/mermaid-WFAXDIZX-C2_MUy_a.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { n as __require, r as __toESM, t as __commonJSMin } from "./rolldown-runtime-DegdyBIC.js";
2
- import { Bn as BROWSER_RELAY_SECRET_HEADER, Er as ReasoningEffort, Hn as RuntimeResponse, Vn as ProviderCapabilities, Wn as ProjectConfigFile, a as childEnv, d as RunnerLock, er as AgentEvent, i as listDescendants, ir as UserInput, m as ServerToRunnerMessage, n as killTree$1, nr as oneLineDescription, o as CODEDEN_VERSION, rr as winningCommands, t as isAlive$1, u as RunnerEventMessage, zn as SETUP_TAIL_LIMIT } from "./tree-CF7Q8tXK.js";
2
+ import { Ar as ReasoningEffort, Bn as BROWSER_RELAY_SECRET_HEADER, Hn as RuntimeResponse, Vn as ProviderCapabilities, Wn as ProjectConfigFile, a as childEnv, ar as winningCommands, d as RunnerLock, er as AgentEvent, i as listDescendants, ir as oneLineDescription, m as ServerToRunnerMessage, n as killTree$1, nr as mergeItemDeltas, o as CODEDEN_VERSION, or as itemDeltaText, sr as UserInput, t as isAlive$1, u as RunnerEventMessage, zn as SETUP_TAIL_LIMIT } from "./tree-31WAOm5k.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { accessSync, appendFileSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
5
5
  import { pathToFileURL } from "node:url";
@@ -3612,6 +3612,8 @@ var import_websocket = /* @__PURE__ */ __toESM(require_websocket(), 1);
3612
3612
  var import_websocket_server = /* @__PURE__ */ __toESM(require_websocket_server(), 1);
3613
3613
  var wrapper_default = import_websocket.default;
3614
3614
  // ../../packages/providers/src/adapter.ts
3615
+ /** `ProviderSessionError` code: the conversation doesn't hold the fork point it was asked for. */
3616
+ const FORK_POINT_MISSING = "fork_point_missing";
3615
3617
  /**
3616
3618
  * Session options ready to open (`SessionOptions.forkFrom`): with `forkFrom` and no
3617
3619
  * `resumeToken`, the conversation is copied first (`forkConversation`) and the session resumes
@@ -4513,6 +4515,33 @@ function parseClaudeAuthStatus(stdout) {
4513
4515
  return unknown;
4514
4516
  }
4515
4517
  }
4518
+ // ../../packages/providers/src/claude/fork.ts
4519
+ const reasonOf = (error) => error instanceof Error ? error.message : String(error);
4520
+ /**
4521
+ * Copy a Claude conversation with the SDK's `forkSession()`: the transcript is written to a new
4522
+ * session file at once (fresh message ids, the original untouched) and the new session id is
4523
+ * returned for resuming. With `upTo` (a turn's fork point: its last transcript message), the
4524
+ * transcript is read first (`getSessionMessages`) to confirm it still holds that message, and
4525
+ * the copy ends with it. A conversation that no longer holds it (replaced or deleted) throws
4526
+ * `FORK_POINT_MISSING` before anything is copied, so it never becomes a whole copy instead.
4527
+ */
4528
+ async function forkClaudeConversation(sdk, resumeToken, upTo) {
4529
+ if (upTo) await confirmForkPoint(sdk, resumeToken, upTo);
4530
+ try {
4531
+ return (await sdk.forkSession(resumeToken, upTo ? { upToMessageId: upTo } : void 0)).sessionId;
4532
+ } catch (error) {
4533
+ throw new Error(`Could not fork the Claude conversation ${resumeToken}: ${reasonOf(error)}`);
4534
+ }
4535
+ }
4536
+ async function confirmForkPoint(sdk, resumeToken, upTo) {
4537
+ let messages;
4538
+ try {
4539
+ messages = await sdk.getSessionMessages(resumeToken, { includeSystemMessages: true });
4540
+ } catch (error) {
4541
+ throw new Error(`Could not read the Claude conversation ${resumeToken}: ${reasonOf(error)}`);
4542
+ }
4543
+ if (!messages.some((m) => m.uuid === upTo)) throw new ProviderSessionError(FORK_POINT_MISSING, "The conversation no longer holds the end of that turn (it was replaced or deleted), so it can't be forked there");
4544
+ }
4516
4545
  // ../../packages/providers/src/util/model.ts
4517
4546
  /** The id the model pickers use for "the provider's own default model". */
4518
4547
  const DEFAULT_MODEL_ID = "default";
@@ -8434,6 +8463,12 @@ var ClaudeEventMapper = class {
8434
8463
  retryNoticeId;
8435
8464
  /** What the current turn said about a failure, for classifying it (`errors.ts`). */
8436
8465
  signals = {};
8466
+ /**
8467
+ * The `uuid` of the current root turn's last main-chain transcript message (an `assistant`
8468
+ * or `user` message without `parent_tool_use_id`): the turn's fork point, where
8469
+ * `forkSession({ upToMessageId })` ends a copy that keeps this turn and no later one.
8470
+ */
8471
+ lastChainUuid;
8437
8472
  /** The `/` command list last reported, so an unchanged list isn't sent again. */
8438
8473
  commandsKey;
8439
8474
  constructor(opts) {
@@ -8491,6 +8526,7 @@ var ClaudeEventMapper = class {
8491
8526
  this.rootTurnId = turnId;
8492
8527
  this.turnBaseline = this.queryTotal;
8493
8528
  this.signals = {};
8529
+ this.lastChainUuid = void 0;
8494
8530
  this.emit({
8495
8531
  type: "turn.started",
8496
8532
  threadId: this.rootThreadId,
@@ -8535,9 +8571,11 @@ var ClaudeEventMapper = class {
8535
8571
  return;
8536
8572
  case "assistant":
8537
8573
  this.onAssistant(msg);
8574
+ this.noteChainMessage(msg);
8538
8575
  return;
8539
8576
  case "user":
8540
8577
  this.onUser(msg);
8578
+ this.noteChainMessage(msg);
8541
8579
  return;
8542
8580
  case "system":
8543
8581
  this.onSystem(msg);
@@ -8560,6 +8598,16 @@ var ClaudeEventMapper = class {
8560
8598
  default: return;
8561
8599
  }
8562
8600
  }
8601
+ /**
8602
+ * Keep a root turn's latest main-chain message as its fork point. Subagents' messages don't
8603
+ * count, nor do meta or synthetic ones, which the transcript read that confirms a fork point
8604
+ * (`getSessionMessages`) may leave out.
8605
+ */
8606
+ noteChainMessage(msg) {
8607
+ if (msg.parent_tool_use_id || !this.rootTurnId || msg.isMeta || msg.isSynthetic) return;
8608
+ const uuid = str(msg, "uuid");
8609
+ if (uuid) this.lastChainUuid = uuid;
8610
+ }
8563
8611
  onAssistant(msg) {
8564
8612
  const message = msg.message ?? {};
8565
8613
  const ctx = this.ctxFor(msg.parent_tool_use_id);
@@ -8703,19 +8751,24 @@ var ClaudeEventMapper = class {
8703
8751
  this.tools.finishTurn(ctx, outcome.type === "completed");
8704
8752
  this.rootTurnId = void 0;
8705
8753
  this.interruptRequested = false;
8754
+ const end = {
8755
+ ...ctx,
8756
+ ...this.lastChainUuid ? { forkPoint: this.lastChainUuid } : {}
8757
+ };
8758
+ this.lastChainUuid = void 0;
8706
8759
  if (outcome.type === "completed") this.emit({
8707
8760
  type: "turn.completed",
8708
- ...ctx,
8761
+ ...end,
8709
8762
  stopReason: outcome.stopReason
8710
8763
  });
8711
8764
  else if (outcome.type === "failed") this.emit({
8712
8765
  type: "turn.failed",
8713
- ...ctx,
8766
+ ...end,
8714
8767
  error: outcome.error
8715
8768
  });
8716
8769
  else this.emit({
8717
8770
  type: "turn.interrupted",
8718
- ...ctx,
8771
+ ...end,
8719
8772
  reason: outcome.reason
8720
8773
  });
8721
8774
  }
@@ -9405,18 +9458,12 @@ var ClaudeAdapter = class {
9405
9458
  });
9406
9459
  }
9407
9460
  /**
9408
- * Copy a conversation with the SDK's `forkSession()`: the transcript is written to a new
9409
- * session file at once (fresh message ids, the original untouched) and the new session id is
9410
- * returned for resuming. The copy is found from any worktree of the project.
9461
+ * Copy a conversation (see `forkClaudeConversation`): at once, with fresh message ids, the
9462
+ * original untouched; with `upTo`, only through the turn that ended with that message. The
9463
+ * copy is found from any worktree of the project.
9411
9464
  */
9412
- async forkConversation(resumeToken) {
9413
- const { forkSession } = await (this.options.loadSdk ?? loadClaudeSdk)();
9414
- try {
9415
- return (await forkSession(resumeToken)).sessionId;
9416
- } catch (error) {
9417
- const reason = error instanceof Error ? error.message : String(error);
9418
- throw new Error(`Could not fork the Claude conversation ${resumeToken}: ${reason}`);
9419
- }
9465
+ async forkConversation(resumeToken, opts) {
9466
+ return forkClaudeConversation(await (this.options.loadSdk ?? loadClaudeSdk)(), resumeToken, opts?.upTo);
9420
9467
  }
9421
9468
  utilityModel() {
9422
9469
  return new ClaudeUtilityModel(() => this.executable());
@@ -9733,11 +9780,27 @@ async function startAppServer(exe, handlers, opts = {}) {
9733
9780
  }
9734
9781
  }
9735
9782
  // ../../packages/providers/src/codex/fork.ts
9783
+ /** Turns listed per `thread/turns/list` page, and the most pages read to find a turn. */
9784
+ const TURNS_PAGE = 100;
9785
+ const MAX_PAGES$1 = 50;
9786
+ /**
9787
+ * How the app-server answers for a thread or turn it doesn't have. Codex 0.160 says
9788
+ * `no rollout found for thread id <id>` for a deleted thread and `turn not found: <id>` for a
9789
+ * missing `lastTurnId`. Other refusals (a turn still in progress, a malformed id) don't match.
9790
+ */
9791
+ const MISSING = /\bno rollout found\b|\b(thread|turn|rollout) not found\b/i;
9736
9792
  /**
9737
9793
  * Copy a Codex thread with `thread/fork` through a short-lived `codex app-server` and return
9738
9794
  * the new thread's id. The original thread is untouched; the session that resumes the copy
9739
9795
  * sets its own cwd, policy and instructions (`thread/resume` overrides). The process is closed
9740
9796
  * before returning, so it no longer holds the new thread when that session resumes it.
9797
+ *
9798
+ * With `upTo` (a turn id), the copy ends with that turn (`lastTurnId`). A thread that no
9799
+ * longer holds the turn, or no longer exists, throws `FORK_POINT_MISSING`: when the turn list
9800
+ * (`thread/turns/list`) shows it before anything is copied, or else when `thread/fork` answers
9801
+ * so. A short-lived app-server usually can't list a thread it hasn't loaded (Codex 0.160 says
9802
+ * `thread not loaded`), so `thread/fork`'s answer is what decides then. Any other failure, and
9803
+ * every failure of a whole copy, stays a plain error.
9741
9804
  */
9742
9805
  async function forkCodexThread(exe, threadId, opts) {
9743
9806
  const { rpc } = await startAppServer(exe, {
@@ -9746,17 +9809,47 @@ async function forkCodexThread(exe, threadId, opts) {
9746
9809
  onExit: () => {}
9747
9810
  }, { cwd: opts.cwd });
9748
9811
  try {
9749
- return (await rpc.request("thread/fork", {
9812
+ if (opts.upTo && await holdsTurn(rpc, threadId, opts.upTo) === false) throw new ProviderSessionError(FORK_POINT_MISSING, "The thread no longer holds that turn (it was replaced or deleted), so it can't be forked there");
9813
+ const params = {
9750
9814
  threadId,
9751
9815
  cwd: opts.cwd,
9752
- excludeTurns: true
9753
- })).thread.id;
9816
+ excludeTurns: true,
9817
+ ...opts.upTo ? { lastTurnId: opts.upTo } : {}
9818
+ };
9819
+ return (await rpc.request("thread/fork", params)).thread.id;
9754
9820
  } catch (error) {
9755
- throw new Error(`Could not fork Codex thread ${threadId}: ${error.message}`);
9821
+ if (error instanceof ProviderSessionError) throw error;
9822
+ const message = error.message;
9823
+ if (opts.upTo && error instanceof CodexRpcError && MISSING.test(message)) throw new ProviderSessionError(FORK_POINT_MISSING, `The thread no longer holds that turn (${message}), so it can't be forked there`);
9824
+ throw new Error(`Could not fork Codex thread ${threadId}: ${message}`);
9756
9825
  } finally {
9757
9826
  await rpc.close();
9758
9827
  }
9759
9828
  }
9829
+ /**
9830
+ * Whether the thread still holds `turnId`, from its turn list. Undefined when the list can't be
9831
+ * read (a thread this app-server hasn't loaded, or an app-server without the method):
9832
+ * `thread/fork` then decides.
9833
+ */
9834
+ async function holdsTurn(rpc, threadId, turnId) {
9835
+ let cursor = null;
9836
+ try {
9837
+ for (let page = 0; page < MAX_PAGES$1; page++) {
9838
+ const res = await rpc.request("thread/turns/list", {
9839
+ threadId,
9840
+ limit: TURNS_PAGE,
9841
+ itemsView: "notLoaded",
9842
+ ...cursor ? { cursor } : {}
9843
+ });
9844
+ if (res.data.some((t) => t.id === turnId)) return true;
9845
+ cursor = res.nextCursor;
9846
+ if (!cursor) return false;
9847
+ }
9848
+ return;
9849
+ } catch {
9850
+ return;
9851
+ }
9852
+ }
9760
9853
  /** `model/list` pages are followed up to this many (a guard against a looping cursor). */
9761
9854
  const MAX_PAGES = 20;
9762
9855
  /**
@@ -9946,7 +10039,7 @@ function permissionsRisk(permissions, ctx) {
9946
10039
  })
9947
10040
  ]);
9948
10041
  }
9949
- // ../../packages/providers/src/codex/approvals.ts
10042
+ // ../../packages/providers/src/codex/approval-context.ts
9950
10043
  const decisionOf = (r) => r.kind === "approval" ? r.decision : "cancel";
9951
10044
  /**
9952
10045
  * Approve `item` by policy when the permission level allows `risk`: record it as decided by
@@ -9965,6 +10058,296 @@ function approvedByPolicy(ac, p, item, risk) {
9965
10058
  });
9966
10059
  return true;
9967
10060
  }
10061
+ // ../../packages/providers/src/codex/elicitation-form.ts
10062
+ /** The option that leaves an optional field empty. */
10063
+ const LEAVE_EMPTY = "Leave empty";
10064
+ /** A form without fields is a confirmation: one question, accept or decline. */
10065
+ const CONFIRM_ID = "confirm";
10066
+ const ACCEPT = "Accept";
10067
+ const DECLINE = "Decline";
10068
+ function formFields(schema) {
10069
+ const required = new Set(schema.required ?? []);
10070
+ return Object.entries(schema.properties).flatMap(([name, prop], i) => prop ? [field(name, i, prop, required.has(name))] : []);
10071
+ }
10072
+ function field(name, index, s, required) {
10073
+ const description = s.description?.trim();
10074
+ const base = {
10075
+ name,
10076
+ id: name.length > 0 && name.length <= 200 ? name : `field-${index}`,
10077
+ label: s.title?.trim() || name,
10078
+ ...description ? { description } : {},
10079
+ required,
10080
+ choices: []
10081
+ };
10082
+ if (s.type === "boolean") return {
10083
+ ...base,
10084
+ type: "boolean"
10085
+ };
10086
+ if (s.type === "number" || s.type === "integer") return {
10087
+ ...base,
10088
+ type: s.type
10089
+ };
10090
+ if (s.type === "array") {
10091
+ const items = s.items;
10092
+ const choices = "anyOf" in items ? items.anyOf.map((o) => ({
10093
+ value: o.const,
10094
+ label: o.title
10095
+ })) : items.enum.map((value) => ({
10096
+ value,
10097
+ label: value
10098
+ }));
10099
+ return {
10100
+ ...base,
10101
+ type: "choices",
10102
+ choices: unique(choices)
10103
+ };
10104
+ }
10105
+ if ("oneOf" in s) return {
10106
+ ...base,
10107
+ type: "choice",
10108
+ choices: unique(s.oneOf.map((o) => ({
10109
+ value: o.const,
10110
+ label: o.title
10111
+ })))
10112
+ };
10113
+ if ("enum" in s) {
10114
+ const names = "enumNames" in s ? s.enumNames : void 0;
10115
+ const choices = s.enum.map((value, i) => ({
10116
+ value,
10117
+ label: names?.[i] ?? value
10118
+ }));
10119
+ return {
10120
+ ...base,
10121
+ type: "choice",
10122
+ choices: unique(choices)
10123
+ };
10124
+ }
10125
+ return {
10126
+ ...base,
10127
+ type: "text"
10128
+ };
10129
+ }
10130
+ /**
10131
+ * Labels are how answers come back, so each must name exactly one value: a repeated label (or
10132
+ * one that reads as {@link LEAVE_EMPTY}) gets its value added, then a number, until it is free.
10133
+ */
10134
+ function unique(choices) {
10135
+ const seen = /* @__PURE__ */ new Set([LEAVE_EMPTY]);
10136
+ return choices.map((c) => {
10137
+ const label = c.label.trim() || c.value;
10138
+ let free = seen.has(label) ? `${label} (${c.value})` : label;
10139
+ for (let n = 2; seen.has(free); n++) free = `${label} (${c.value}) ${n}`;
10140
+ seen.add(free);
10141
+ return {
10142
+ value: c.value,
10143
+ label: free
10144
+ };
10145
+ });
10146
+ }
10147
+ const HINTS = {
10148
+ integer: " (a whole number)",
10149
+ number: " (a number)"
10150
+ };
10151
+ /** The form as questions; the first one carries the server's message. */
10152
+ function formQuestions(server, message, fields) {
10153
+ const lead = `${server} asks: ${message.trim()}`;
10154
+ if (fields.length === 0) return [{
10155
+ id: CONFIRM_ID,
10156
+ header: server,
10157
+ question: lead,
10158
+ options: [{ label: ACCEPT }, { label: DECLINE }],
10159
+ multiSelect: false,
10160
+ allowFreeText: false
10161
+ }];
10162
+ return fields.map((f, i) => {
10163
+ const ask = `${f.description ?? f.label}${HINTS[f.type] ?? ""}`;
10164
+ const plain = fields.length === 1 && ask === f.label;
10165
+ const question = i > 0 ? ask : plain ? lead : `${lead} — ${ask}`;
10166
+ const options = f.type === "boolean" ? [{ label: "Yes" }, { label: "No" }] : f.choices.map((c) => ({ label: c.label }));
10167
+ return {
10168
+ id: f.id,
10169
+ header: f.label.length > 40 ? `${f.label.slice(0, 39)}…` : f.label,
10170
+ question,
10171
+ options: f.required ? options : [...options, { label: LEAVE_EMPTY }],
10172
+ multiSelect: f.type === "choices",
10173
+ allowFreeText: f.type === "text" || f.type === "number" || f.type === "integer"
10174
+ };
10175
+ });
10176
+ }
10177
+ /**
10178
+ * The form's content from the answers (keyed by question id), or undefined when a required
10179
+ * field has no answer or an answer doesn't fit its field.
10180
+ */
10181
+ function formContent(fields, answers) {
10182
+ const content = {};
10183
+ for (const f of fields) {
10184
+ const given = (answers[f.id] ?? []).map((a) => a.trim()).filter(Boolean);
10185
+ const values = f.required ? given : given.filter((a) => a !== LEAVE_EMPTY);
10186
+ if (values.length === 0) {
10187
+ if (f.required) return void 0;
10188
+ continue;
10189
+ }
10190
+ const value = parse$1(f, values);
10191
+ if (value === void 0) return void 0;
10192
+ content[f.name] = value;
10193
+ }
10194
+ return content;
10195
+ }
10196
+ function parse$1(f, values) {
10197
+ const [one] = values;
10198
+ switch (f.type) {
10199
+ case "text": return values.join("\n");
10200
+ case "number":
10201
+ case "integer": {
10202
+ const n = values.length === 1 ? Number(one) : NaN;
10203
+ return Number.isFinite(n) && (f.type === "number" || Number.isInteger(n)) ? n : void 0;
10204
+ }
10205
+ case "boolean": {
10206
+ const v = values.length === 1 ? one?.toLowerCase() : void 0;
10207
+ return v === "yes" || v === "true" ? true : v === "no" || v === "false" ? false : void 0;
10208
+ }
10209
+ case "choice": return values.length === 1 && one !== void 0 ? choice(f, one) : void 0;
10210
+ case "choices": {
10211
+ const picked = values.map((v) => choice(f, v));
10212
+ return picked.every((v) => v !== void 0) ? picked : void 0;
10213
+ }
10214
+ }
10215
+ }
10216
+ /**
10217
+ * The value of the option the user picked. The card sends labels, so labels are matched first,
10218
+ * across every option; a raw value (a PM may answer with one) only when no label matches.
10219
+ */
10220
+ const choice = (f, answer) => (f.choices.find((c) => c.label === answer) ?? f.choices.find((c) => c.value === answer))?.value;
10221
+ // ../../packages/providers/src/codex/elicitation.ts
10222
+ const accept = (content, meta = null) => ({
10223
+ action: "accept",
10224
+ content,
10225
+ _meta: meta
10226
+ });
10227
+ const DECLINED = {
10228
+ action: "decline",
10229
+ content: null,
10230
+ _meta: null
10231
+ };
10232
+ const CANCELLED = {
10233
+ action: "cancel",
10234
+ content: null,
10235
+ _meta: null
10236
+ };
10237
+ const READ_ONLY = "This session has read-only permissions: it can't run MCP tools that may change things.";
10238
+ async function handleElicitation(p, ac) {
10239
+ if (!p.turnId) return DECLINED;
10240
+ const where = {
10241
+ threadId: p.threadId,
10242
+ turnId: p.turnId
10243
+ };
10244
+ if (p.mode === "url") return openLink(p, where, ac);
10245
+ const meta = asMeta(p._meta);
10246
+ if (meta.codex_approval_kind === "mcp_tool_call") return toolApproval(p, meta, where, ac);
10247
+ if (p.mode === "form") return fillForm(p, where, ac);
10248
+ return DECLINED;
10249
+ }
10250
+ /**
10251
+ * Codex asks before it runs one of the user's MCP tools that isn't marked read-only (its
10252
+ * `tool_call_mcp_elicitation` feature, on by default).
10253
+ */
10254
+ async function toolApproval(p, meta, where, ac) {
10255
+ const server = p.serverName;
10256
+ const tool = text$1(meta.tool_name) ?? text$1(meta.tool_title) ?? /run tool "([^"]+)"/.exec(p.message)?.[1];
10257
+ const persist = meta.persist;
10258
+ const offersSession = persist === "session" || Array.isArray(persist) && persist.includes("session");
10259
+ const requestId = newRequestId();
10260
+ const item = {
10261
+ id: `approval-${requestId}`,
10262
+ kind: "approval_request",
10263
+ status: "in_progress",
10264
+ requestId,
10265
+ action: "tool",
10266
+ title: tool ? `Use ${server}: ${shorten(tool, 80)}` : `Use a tool of ${server}`,
10267
+ ...p.message.trim() ? { reason: p.message.trim() } : {},
10268
+ ...tool ? { toolName: `mcp__${server}__${tool}` } : {},
10269
+ ...meta.tool_params !== void 0 ? { toolInput: toJson(meta.tool_params) } : {},
10270
+ options: offersSession ? [
10271
+ "allow_once",
10272
+ "allow_session",
10273
+ "deny"
10274
+ ] : ["allow_once", "deny"]
10275
+ };
10276
+ const ctx = ac.ctx(where.threadId, where.turnId);
10277
+ if ((ac.autonomy?.() ?? "supervised") === "read_only") {
10278
+ ac.broker.record(ctx, {
10279
+ ...item,
10280
+ status: "declined",
10281
+ reason: READ_ONLY,
10282
+ decision: "deny",
10283
+ decidedBy: "policy"
10284
+ });
10285
+ return DECLINED;
10286
+ }
10287
+ if (!(meta.codex_sensitive_action === true || meta.codex_requires_user_input === true) && approvedByPolicy(ac, where, item, { risky: false })) return accept({});
10288
+ const decision = decisionOf(await ac.broker.request(ctx, item));
10289
+ if (decision === "allow_session" && offersSession) return accept({}, { persist: "session" });
10290
+ if (decision === "allow_once" || decision === "allow_session") return accept({});
10291
+ return decision === "deny" ? DECLINED : CANCELLED;
10292
+ }
10293
+ /** A server's form: a question card built from its schema, the answers sent as its content. */
10294
+ async function fillForm(p, where, ac) {
10295
+ const fields = formFields(p.requestedSchema);
10296
+ const requestId = newRequestId();
10297
+ const response = await ac.broker.request(ac.ctx(where.threadId, where.turnId), {
10298
+ id: `question-${requestId}`,
10299
+ kind: "user_question",
10300
+ status: "in_progress",
10301
+ requestId,
10302
+ mcpServer: p.serverName,
10303
+ questions: formQuestions(p.serverName, p.message, fields)
10304
+ });
10305
+ if (response.kind !== "answer") return CANCELLED;
10306
+ if (fields.length === 0) return response.answers["confirm"]?.includes("Accept") ? accept({}) : DECLINED;
10307
+ const content = formContent(fields, response.answers);
10308
+ return content ? accept(content) : DECLINED;
10309
+ }
10310
+ /**
10311
+ * A server asks the user to open a link (to sign in, say). The card shows the link and the user
10312
+ * opens it: never decided by policy, and nothing opens it on the user's behalf.
10313
+ */
10314
+ async function openLink(p, where, ac) {
10315
+ const url = webUrl(p.url);
10316
+ if (!url) return DECLINED;
10317
+ const requestId = newRequestId();
10318
+ const response = await ac.broker.request(ac.ctx(where.threadId, where.turnId), {
10319
+ id: `approval-${requestId}`,
10320
+ kind: "approval_request",
10321
+ status: "in_progress",
10322
+ requestId,
10323
+ action: "network",
10324
+ title: `${p.serverName} asks you to open ${url.host}`,
10325
+ ...p.message.trim() ? { reason: p.message.trim() } : {},
10326
+ url: url.href,
10327
+ options: ["allow_once", "deny"]
10328
+ });
10329
+ const decision = decisionOf(response);
10330
+ if (decision === "allow_once" || decision === "allow_session") return accept(null);
10331
+ return decision === "deny" ? DECLINED : CANCELLED;
10332
+ }
10333
+ /** Elicitations carry no item id, and app-server request ids restart with each process. */
10334
+ const newRequestId = () => `mcp-${randomUUID()}`;
10335
+ function asMeta(value) {
10336
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
10337
+ }
10338
+ function text$1(value) {
10339
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
10340
+ }
10341
+ /** Web links only: a card never links to a script or a local file. */
10342
+ function webUrl(raw) {
10343
+ try {
10344
+ const url = new URL(raw);
10345
+ return url.protocol === "https:" || url.protocol === "http:" ? url : void 0;
10346
+ } catch {
10347
+ return;
10348
+ }
10349
+ }
10350
+ // ../../packages/providers/src/codex/approvals.ts
9968
10351
  /** The item with the classifier's verdict, for the user to decide. */
9969
10352
  function withRisk(item, risk) {
9970
10353
  if (!risk.risky) return item;
@@ -10099,11 +10482,7 @@ async function handleServerRequest(req, ac) {
10099
10482
  if (response.kind === "answer") for (const [id, values] of Object.entries(response.answers)) answers[id] = { answers: values };
10100
10483
  return { answers };
10101
10484
  }
10102
- case "mcpServer/elicitation/request": return {
10103
- action: "decline",
10104
- content: null,
10105
- _meta: null
10106
- };
10485
+ case "mcpServer/elicitation/request": return handleElicitation(req.params, ac);
10107
10486
  case "execCommandApproval":
10108
10487
  case "applyPatchApproval": return { decision: { denied: { rejection: "Unsupported approval request" } } };
10109
10488
  default: throw new CodexRpcError(-32601, `CodeDen does not handle ${req.method}`);
@@ -11115,10 +11494,16 @@ var CodexThreadMapper = class {
11115
11494
  });
11116
11495
  }
11117
11496
  }
11497
+ /**
11498
+ * Codex ended a turn. On the session's own thread the turn's id is its fork point:
11499
+ * `thread/fork { lastTurnId }` keeps it and drops later turns. Turns CodeDen closes itself
11500
+ * (the process died, the session closed) report none, as Codex may not have finished them.
11501
+ */
11118
11502
  completeTurn(turn) {
11119
11503
  this.beginTurn(turn.id);
11120
- if (turn.status === "completed") this.endTurn({ type: "completed" }, turn.id);
11121
- else if (turn.status === "interrupted") this.endTurn({ type: "interrupted" }, turn.id);
11504
+ const forkPoint = this.o.root ? turn.id : void 0;
11505
+ if (turn.status === "completed") this.endTurn({ type: "completed" }, turn.id, forkPoint);
11506
+ else if (turn.status === "interrupted") this.endTurn({ type: "interrupted" }, turn.id, forkPoint);
11122
11507
  else {
11123
11508
  const info = turn.error?.codexErrorInfo;
11124
11509
  const kind = classifyCodexError(info);
@@ -11130,13 +11515,17 @@ var CodexThreadMapper = class {
11130
11515
  code: codexErrorCode(info),
11131
11516
  kind,
11132
11517
  retryAt
11133
- }, turn.id);
11518
+ }, turn.id, forkPoint);
11134
11519
  }
11135
11520
  }
11136
11521
  /** Close the running turn (also used when the process dies or the session closes). */
11137
- endTurn(outcome, turnId = this.current) {
11522
+ endTurn(outcome, turnId = this.current, forkPoint) {
11138
11523
  if (!turnId || turnId !== this.current) return;
11139
11524
  const ctx = this.ctx(turnId);
11525
+ const end = {
11526
+ ...ctx,
11527
+ ...forkPoint ? { forkPoint } : {}
11528
+ };
11140
11529
  const ok = outcome.type === "completed";
11141
11530
  for (const item of this.open.values()) this.o.emit({
11142
11531
  type: "item.completed",
@@ -11163,17 +11552,17 @@ var CodexThreadMapper = class {
11163
11552
  this.current = void 0;
11164
11553
  if (outcome.type === "completed") this.o.emit({
11165
11554
  type: "turn.completed",
11166
- ...ctx,
11555
+ ...end,
11167
11556
  stopReason: "end_turn"
11168
11557
  });
11169
11558
  else if (outcome.type === "interrupted") this.o.emit({
11170
11559
  type: "turn.interrupted",
11171
- ...ctx,
11560
+ ...end,
11172
11561
  reason: "user"
11173
11562
  });
11174
11563
  else this.o.emit({
11175
11564
  type: "turn.failed",
11176
- ...ctx,
11565
+ ...end,
11177
11566
  error: {
11178
11567
  code: outcome.code ?? "turn_failed",
11179
11568
  message: outcome.message,
@@ -12054,7 +12443,10 @@ var CodexAdapter = class {
12054
12443
  }
12055
12444
  return session;
12056
12445
  }
12057
- /** Copy a thread with `thread/fork` (see `forkCodexThread`); returns the new thread's id. */
12446
+ /**
12447
+ * Copy a thread with `thread/fork` (see `forkCodexThread`); returns the new thread's id. With
12448
+ * `upTo` (a turn id), the copy ends with that turn.
12449
+ */
12058
12450
  async forkConversation(resumeToken, opts) {
12059
12451
  const executable = this.executable();
12060
12452
  if (!executable) throw new Error("Codex is not installed (no `codex` on PATH)");
@@ -12316,6 +12708,7 @@ var FakeSession = class {
12316
12708
  }
12317
12709
  async runTurn(turn, input) {
12318
12710
  const { threadId, turnId } = turn;
12711
+ const forkPoint = turnId;
12319
12712
  const prompt = input.text;
12320
12713
  try {
12321
12714
  this.emit({
@@ -12342,6 +12735,7 @@ var FakeSession = class {
12342
12735
  type: "turn.failed",
12343
12736
  threadId,
12344
12737
  turnId,
12738
+ forkPoint,
12345
12739
  error: {
12346
12740
  code: "fake_failure",
12347
12741
  message: "Scripted failure",
@@ -12357,6 +12751,7 @@ var FakeSession = class {
12357
12751
  type: "turn.failed",
12358
12752
  threadId,
12359
12753
  turnId,
12754
+ forkPoint,
12360
12755
  error: usage ? {
12361
12756
  code: "usage_limit",
12362
12757
  message: "Scripted usage limit",
@@ -12378,6 +12773,7 @@ var FakeSession = class {
12378
12773
  type: "turn.completed",
12379
12774
  threadId,
12380
12775
  turnId,
12776
+ forkPoint,
12381
12777
  stopReason: "end_turn"
12382
12778
  });
12383
12779
  return;
@@ -12426,6 +12822,7 @@ var FakeSession = class {
12426
12822
  type: "turn.completed",
12427
12823
  threadId,
12428
12824
  turnId,
12825
+ forkPoint,
12429
12826
  stopReason: "end_turn"
12430
12827
  });
12431
12828
  } catch (error) {
@@ -12434,6 +12831,7 @@ var FakeSession = class {
12434
12831
  type: "turn.interrupted",
12435
12832
  threadId,
12436
12833
  turnId,
12834
+ forkPoint,
12437
12835
  reason: "user"
12438
12836
  });
12439
12837
  return;
@@ -12443,6 +12841,7 @@ var FakeSession = class {
12443
12841
  type: "turn.failed",
12444
12842
  threadId,
12445
12843
  turnId,
12844
+ forkPoint,
12446
12845
  error: { message }
12447
12846
  });
12448
12847
  }
@@ -12843,10 +13242,13 @@ var FakeProvider = class {
12843
13242
  async openSession(opts) {
12844
13243
  return new FakeSession(await withFork(this, opts), this.options);
12845
13244
  }
12846
- /** A fork gets a conversation of its own, named after the one it copies. */
12847
- async forkConversation(resumeToken) {
13245
+ /**
13246
+ * A fork gets a conversation of its own, named after the one it copies and, with `upTo`,
13247
+ * the turn it ends with (`<token>:fork-<n>@<turn>`).
13248
+ */
13249
+ async forkConversation(resumeToken, opts) {
12848
13250
  this.forks += 1;
12849
- return `${resumeToken}:fork-${this.forks}`;
13251
+ return `${resumeToken}:fork-${this.forks}${opts?.upTo ? `@${opts.upTo}` : ""}`;
12850
13252
  }
12851
13253
  forks = 0;
12852
13254
  };
@@ -21858,9 +22260,12 @@ async function executeCommand(core, command) {
21858
22260
  const adapter = registry.get(command.providerId);
21859
22261
  if (!adapter?.capabilities().fork || !adapter.forkConversation) throw new CommandError("unsupported", `Provider ${command.providerId} cannot fork`);
21860
22262
  try {
21861
- return { resumeToken: await adapter.forkConversation(command.resumeToken, { cwd: command.cwd }) };
22263
+ return { resumeToken: await adapter.forkConversation(command.resumeToken, {
22264
+ cwd: command.cwd,
22265
+ ...command.upTo ? { upTo: command.upTo } : {}
22266
+ }) };
21862
22267
  } catch (error) {
21863
- throw new CommandError("fork_failed", error.message);
22268
+ throw new CommandError(error.code === FORK_POINT_MISSING ? FORK_POINT_MISSING : "fork_failed", error.message);
21864
22269
  }
21865
22270
  }
21866
22271
  case "workspace.prepare":
@@ -21943,7 +22348,7 @@ async function executeCommand(core, command) {
21943
22348
  case "checkpoint.restoreFiles": return restoreFiles(command.cwd, command.sessionId, command.index, command.paths);
21944
22349
  case "ports.list": return { ports: await listPorts(command.probe) };
21945
22350
  case "mcp.test": {
21946
- const { testMcpServer } = await import("./server-test-COAiKxTV.js");
22351
+ const { testMcpServer } = await import("./server-test-b0hHPqXb.js");
21947
22352
  return testMcpServer({
21948
22353
  spec: command.spec,
21949
22354
  cwd: command.cwd,
@@ -22004,21 +22409,44 @@ function removeOwnLock(lockFile) {
22004
22409
  * monotonically increasing seq and kept until the server acks them, so nothing is lost
22005
22410
  * while the server is away. `state.json` holds the stable runner id and seq counters.
22006
22411
  *
22007
- * TODO(M1): batch fsyncs and compact lazily instead of rewriting on every ack.
22412
+ * An ack only moves the acked position in memory. While acks flow, the journal persists at
22413
+ * most every `persistMs`: it writes `state.json`, then, when enough acked events have piled
22414
+ * up and they outweigh what is left, rewrites the file without them (compaction), so each
22415
+ * ack costs no more than its append did. State always goes first, so `lastSeq` outlives the
22416
+ * events a compaction drops. `close` persists and compacts at once. After a crash the journal
22417
+ * may still hold events the server already acked; the server's `resume` acks them again and
22418
+ * they are not replayed.
22008
22419
  */
22009
22420
  var Journal = class {
22010
22421
  file;
22011
22422
  stateFile;
22423
+ compactEntries;
22424
+ compactBytes;
22425
+ persistMs;
22012
22426
  state;
22427
+ /** Every event in `journal.jsonl`, oldest first; those before `head` are acked. */
22013
22428
  entries = [];
22014
- constructor(dir) {
22429
+ head = 0;
22430
+ fileBytes = 0;
22431
+ ackedBytes = 0;
22432
+ persistTimer;
22433
+ constructor(dir, opts = {}) {
22015
22434
  mkdirSync(dir, { recursive: true });
22016
22435
  this.file = join(dir, "journal.jsonl");
22017
22436
  this.stateFile = join(dir, "state.json");
22437
+ this.compactEntries = opts.compactEntries ?? 1e3;
22438
+ this.compactBytes = opts.compactBytes ?? 1048576;
22439
+ this.persistMs = opts.persistMs ?? 250;
22018
22440
  this.state = this.loadState();
22019
- this.entries = this.loadEntries().filter((e) => e.seq > this.state.ackedSeq);
22020
- const maxSeq = this.entries.at(-1)?.seq ?? 0;
22021
- this.state.lastSeq = Math.max(this.state.lastSeq, maxSeq);
22441
+ const { entries, torn } = this.loadEntries();
22442
+ this.entries = entries;
22443
+ for (const e of entries) this.fileBytes += e.bytes;
22444
+ this.state.lastSeq = Math.max(this.state.lastSeq, entries.at(-1)?.msg.seq ?? 0);
22445
+ this.advanceHead(this.state.ackedSeq);
22446
+ if (torn) {
22447
+ this.writeState(this.state);
22448
+ this.compact();
22449
+ }
22022
22450
  }
22023
22451
  get runnerId() {
22024
22452
  return this.state.runnerId;
@@ -22027,7 +22455,7 @@ var Journal = class {
22027
22455
  return this.state.lastSeq;
22028
22456
  }
22029
22457
  get unacked() {
22030
- return this.entries.length;
22458
+ return this.entries.length - this.head;
22031
22459
  }
22032
22460
  /** Assign the next seq, persist the event, and return the wire message. */
22033
22461
  append(event) {
@@ -22038,20 +22466,84 @@ var Journal = class {
22038
22466
  ts: (/* @__PURE__ */ new Date()).toISOString(),
22039
22467
  event
22040
22468
  };
22041
- appendFileSync(this.file, `${JSON.stringify(msg)}\n`);
22042
- this.entries.push(msg);
22469
+ const line = `${JSON.stringify(msg)}\n`;
22470
+ appendFileSync(this.file, line);
22471
+ const bytes = Buffer.byteLength(line);
22472
+ this.entries.push({
22473
+ msg,
22474
+ bytes
22475
+ });
22476
+ this.fileBytes += bytes;
22043
22477
  return msg;
22044
22478
  }
22045
- /** Drop everything with `seq <= seq` (cumulative ack). */
22479
+ /** Acknowledge everything with `seq <= seq` (cumulative ack). */
22046
22480
  ack(seq) {
22047
- if (seq <= this.state.ackedSeq) return;
22048
- this.state.ackedSeq = Math.min(seq, this.state.lastSeq);
22049
- this.entries = this.entries.filter((e) => e.seq > seq);
22050
- this.rewrite();
22481
+ const upTo = Math.min(seq, this.state.lastSeq);
22482
+ if (upTo <= this.state.ackedSeq) return;
22483
+ this.state.ackedSeq = upTo;
22484
+ this.advanceHead(upTo);
22485
+ this.schedulePersist();
22051
22486
  }
22052
22487
  /** Unacked events after `seq`, oldest first (for replay on resume). */
22053
22488
  after(seq) {
22054
- return this.entries.filter((e) => e.seq > seq);
22489
+ const out = [];
22490
+ for (let i = this.head; i < this.entries.length; i++) {
22491
+ const msg = this.entries[i]?.msg;
22492
+ if (msg && msg.seq > seq) out.push(msg);
22493
+ }
22494
+ return out;
22495
+ }
22496
+ /** Persist the acked position and drop every acked event from the file (runner shutdown). */
22497
+ close() {
22498
+ this.persist(true);
22499
+ }
22500
+ advanceHead(ackedSeq) {
22501
+ for (;;) {
22502
+ const entry = this.entries[this.head];
22503
+ if (!entry || entry.msg.seq > ackedSeq) return;
22504
+ this.ackedBytes += entry.bytes;
22505
+ this.head += 1;
22506
+ }
22507
+ }
22508
+ /** Enough acked events piled up, and rewriting what is left costs no more than they do. */
22509
+ compactDue() {
22510
+ return (this.head >= this.compactEntries || this.ackedBytes >= this.compactBytes) && this.ackedBytes >= this.fileBytes - this.ackedBytes;
22511
+ }
22512
+ schedulePersist() {
22513
+ if (this.persistTimer) return;
22514
+ this.persistTimer = setTimeout(() => {
22515
+ this.persistTimer = void 0;
22516
+ try {
22517
+ this.persist(false);
22518
+ } catch {}
22519
+ }, this.persistMs);
22520
+ this.persistTimer.unref();
22521
+ }
22522
+ /**
22523
+ * Write the acked position, then compact when it is due (`all`: whenever anything is acked).
22524
+ * State first, so `lastSeq` outlives the events the compaction drops.
22525
+ */
22526
+ persist(all) {
22527
+ if (this.persistTimer) clearTimeout(this.persistTimer);
22528
+ this.persistTimer = void 0;
22529
+ this.writeState(this.state);
22530
+ if (all ? this.head > 0 : this.compactDue()) this.compact();
22531
+ }
22532
+ /** Rewrite the file without its acked events. Callers write the state first. */
22533
+ compact() {
22534
+ const live = this.entries.slice(this.head);
22535
+ const tmp = `${this.file}.tmp`;
22536
+ writeFileSync(tmp, live.map((e) => `${JSON.stringify(e.msg)}\n`).join(""));
22537
+ renameSync(tmp, this.file);
22538
+ this.entries = live;
22539
+ this.head = 0;
22540
+ this.fileBytes -= this.ackedBytes;
22541
+ this.ackedBytes = 0;
22542
+ }
22543
+ writeState(state) {
22544
+ const tmp = `${this.stateFile}.tmp`;
22545
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
22546
+ renameSync(tmp, this.stateFile);
22055
22547
  }
22056
22548
  loadState() {
22057
22549
  try {
@@ -22071,25 +22563,26 @@ var Journal = class {
22071
22563
  return fresh;
22072
22564
  }
22073
22565
  loadEntries() {
22074
- if (!existsSync(this.file)) return [];
22075
- const out = [];
22076
- for (const line of readFileSync(this.file, "utf8").split("\n")) {
22566
+ if (!existsSync(this.file)) return {
22567
+ entries: [],
22568
+ torn: false
22569
+ };
22570
+ const raw = readFileSync(this.file, "utf8");
22571
+ const entries = [];
22572
+ let torn = raw.length > 0 && !raw.endsWith("\n");
22573
+ for (const line of raw.split("\n")) {
22077
22574
  if (!line.trim()) continue;
22078
22575
  const parsed = RunnerEventMessage.safeParse(safeJson(line));
22079
- if (parsed.success) out.push(parsed.data);
22576
+ if (parsed.success) entries.push({
22577
+ msg: parsed.data,
22578
+ bytes: Buffer.byteLength(line) + 1
22579
+ });
22580
+ else torn = true;
22080
22581
  }
22081
- return out.sort((a, b) => a.seq - b.seq);
22082
- }
22083
- rewrite() {
22084
- const tmp = `${this.file}.tmp`;
22085
- writeFileSync(tmp, this.entries.map((e) => `${JSON.stringify(e)}\n`).join(""));
22086
- renameSync(tmp, this.file);
22087
- this.writeState(this.state);
22088
- }
22089
- writeState(state) {
22090
- const tmp = `${this.stateFile}.tmp`;
22091
- writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
22092
- renameSync(tmp, this.stateFile);
22582
+ return {
22583
+ entries: entries.sort((a, b) => a.msg.seq - b.msg.seq),
22584
+ torn
22585
+ };
22093
22586
  }
22094
22587
  };
22095
22588
  function safeJson(line) {
@@ -24047,6 +24540,68 @@ function connectionHandlers(core, register, unregister, onAttach) {
24047
24540
  }
24048
24541
  };
24049
24542
  }
24543
+ /**
24544
+ * Batches streamed `item.delta` events on their way to the journal. Every event the runner
24545
+ * journals is also a send, a server transaction and an ack, and providers stream an answer a
24546
+ * few characters at a time. Per session, consecutive deltas that extend the same item the
24547
+ * same way (`mergeItemDeltas`) are merged and passed on as one: after `windowMs`, once the
24548
+ * batch holds `maxChars`, or right before any other event of that session, whoever emits it
24549
+ * (the session's provider, a checkpoint command, workspace setup). An event that belongs to
24550
+ * no session (a terminal exiting, a drain) passes every batch on first. So no event overtakes
24551
+ * a delta that came before it, and only deltas ever wait.
24552
+ */
24553
+ var DeltaBatcher = class {
24554
+ out;
24555
+ opts;
24556
+ batches = /* @__PURE__ */ new Map();
24557
+ constructor(out, opts = {}) {
24558
+ this.out = out;
24559
+ this.opts = opts;
24560
+ }
24561
+ push(event) {
24562
+ if (event.kind === "agent" && event.event.type === "item.delta") {
24563
+ this.add(event.sessionId, event.event);
24564
+ return;
24565
+ }
24566
+ this.flush("sessionId" in event ? event.sessionId : void 0);
24567
+ this.out(event);
24568
+ }
24569
+ /** Pass on the pending batch of one session, or of every session. */
24570
+ flush(sessionId) {
24571
+ if (sessionId === void 0) {
24572
+ for (const id of [...this.batches.keys()]) this.flush(id);
24573
+ return;
24574
+ }
24575
+ const batch = this.batches.get(sessionId);
24576
+ if (!batch) return;
24577
+ clearTimeout(batch.timer);
24578
+ this.batches.delete(sessionId);
24579
+ this.out({
24580
+ kind: "agent",
24581
+ sessionId,
24582
+ event: batch.delta
24583
+ });
24584
+ }
24585
+ add(sessionId, delta) {
24586
+ const chars = itemDeltaText(delta.delta).length;
24587
+ let batch = this.batches.get(sessionId);
24588
+ const merged = batch && mergeItemDeltas(batch.delta, delta);
24589
+ if (batch && merged) {
24590
+ batch.delta = merged;
24591
+ batch.chars += chars;
24592
+ } else {
24593
+ this.flush(sessionId);
24594
+ const windowMs = this.opts.windowMs ?? 50;
24595
+ batch = {
24596
+ delta,
24597
+ chars,
24598
+ timer: setTimeout(() => this.flush(sessionId), windowMs)
24599
+ };
24600
+ this.batches.set(sessionId, batch);
24601
+ }
24602
+ if (batch.chars >= (this.opts.maxChars ?? 16384)) this.flush(sessionId);
24603
+ }
24604
+ };
24050
24605
  // ../runner/src/session-host.ts
24051
24606
  /**
24052
24607
  * Owns live provider sessions on this runner and pumps their normalized events into the
@@ -24346,11 +24901,15 @@ async function startRunner(opts) {
24346
24901
  let mcpUpstream = opts.mcpUpstreamUrl ? new HttpMcpUpstream(opts.mcpUpstreamUrl) : void 0;
24347
24902
  let port = 0;
24348
24903
  let stopped = false;
24349
- const emit = (event) => {
24904
+ const journalEvent = (event) => {
24350
24905
  if (stopped) return;
24351
24906
  const msg = journal.append(event);
24352
24907
  attached?.deliver(msg);
24353
24908
  };
24909
+ const deltas = new DeltaBatcher(journalEvent, opts.deltaBatching);
24910
+ const emit = (event) => {
24911
+ if (!stopped) deltas.push(event);
24912
+ };
24354
24913
  const stream = (data) => {
24355
24914
  attached?.deliver({
24356
24915
  type: "stream",
@@ -24481,7 +25040,13 @@ async function startRunner(opts) {
24481
25040
  if ("closeAllConnections" in server) server.closeAllConnections();
24482
25041
  });
24483
25042
  process.off("exit", removeLock);
25043
+ deltas.flush();
24484
25044
  stopped = true;
25045
+ try {
25046
+ journal.close();
25047
+ } catch (error) {
25048
+ log(`journal: ${String(error)}`);
25049
+ }
24485
25050
  log("runner stopped");
24486
25051
  })();
24487
25052
  return closing;
@@ -24496,4 +25061,4 @@ async function startRunner(opts) {
24496
25061
  }
24497
25062
  export { isElectronRuntime as A, installClaudeSdk as C, acquireInstallLock as D, resolveNpm as E, import_websocket_server as M, wrapper_default as N, brokenInstallMessage as O, importClaudeSdkFrom as S, runNpm as T, claudeMemoryDir as _, bufferToFormData as a, claudeSdkSource as b, refreshProcessPath as c, ImageEngineRegistry as d, createImageEngineRegistry as f, ImageGenerationError as g, resolveImageOutput as h, tryDecodeURIComponent as i, ownNodeEnv as j, findExecutable as k, defaultShell as l, imageInfo as m, readLock as n, createAdaptorServer as o, IMAGE_ASPECT_RATIOS as p, Hono as r, upgradeWebSocket as s, startRunner as t, shellCommandLine as u, findGitRoot as v, isClaudeSdkInstalled as w, CLAUDE_SDK_VERSION as x, mainRepoRoot as y };
24498
25063
 
24499
- //# sourceMappingURL=runner-DYf8dV43.js.map
25064
+ //# sourceMappingURL=runner-CiaiUWOE.js.map