openmausbot 0.1.72 → 0.1.73

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.
@@ -34774,6 +34774,10 @@ var CodexDriver = {
34774
34774
  stdio: ["pipe", "pipe", "pipe"]
34775
34775
  });
34776
34776
  let abandoned = false;
34777
+ let codexThreadId = null;
34778
+ let codexTurnId = null;
34779
+ let startingNativeTurn = false;
34780
+ const earlyNotifications = [];
34777
34781
  const state = {
34778
34782
  settled: false,
34779
34783
  lastText: "",
@@ -34807,6 +34811,18 @@ var CodexDriver = {
34807
34811
  rpcPending.set(id, {
34808
34812
  resolve: (v2) => {
34809
34813
  clearTimeout(timer);
34814
+ if (method === "turn/start") {
34815
+ if (typeof v2?.turn?.id !== "string" || !v2.turn.id) {
34816
+ reject(new Error("Codex did not return a native turn id"));
34817
+ return;
34818
+ }
34819
+ codexTurnId = v2.turn.id;
34820
+ startingNativeTurn = false;
34821
+ for (const notification of earlyNotifications.splice(0)) {
34822
+ if (state.settled) break;
34823
+ handleNotification(notification);
34824
+ }
34825
+ }
34810
34826
  resolve16(v2);
34811
34827
  },
34812
34828
  reject: (e) => {
@@ -34916,6 +34932,22 @@ var CodexDriver = {
34916
34932
  };
34917
34933
  const handleNotification = (msg) => {
34918
34934
  const p2 = msg.params ?? {};
34935
+ const connectionError = msg.method === "error" && !("threadId" in p2) && !("turnId" in p2);
34936
+ if (!connectionError) {
34937
+ if (!codexThreadId || p2.threadId !== codexThreadId) return;
34938
+ if (!codexTurnId) {
34939
+ if (startingNativeTurn) {
34940
+ if (earlyNotifications.length >= 1024) {
34941
+ void settle(false, "too_many_events_before_turn_start");
34942
+ } else {
34943
+ earlyNotifications.push(msg);
34944
+ }
34945
+ }
34946
+ return;
34947
+ }
34948
+ const eventTurnId = msg.method === "turn/started" || msg.method === "turn/completed" ? p2.turn?.id : p2.turnId;
34949
+ if (eventTurnId !== codexTurnId) return;
34950
+ }
34919
34951
  switch (msg.method) {
34920
34952
  // token-level chat text; the item/completed frame follows with the
34921
34953
  // whole message, so its delta is only a fallback when none streamed
@@ -35100,7 +35132,6 @@ var CodexDriver = {
35100
35132
  }
35101
35133
  autoAcceptPermissions = approvalMode === "full";
35102
35134
  const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
35103
- let codexThreadId = null;
35104
35135
  let startedModel = null;
35105
35136
  if (cursor) {
35106
35137
  try {
@@ -35153,21 +35184,24 @@ var CodexDriver = {
35153
35184
  ...promptText ? [{ type: "text", text: promptText }] : [],
35154
35185
  ...(turn.images ?? []).map((image) => ({ type: "localImage", path: image.path }))
35155
35186
  ];
35156
- const startTurn2 = () => request2("turn/start", {
35157
- threadId: codexThreadId,
35158
- input: turnInput,
35159
- ...approvalParams.turn,
35160
- // Spread, not `effort: turn.effort ?? null`. Probed against
35161
- // codex-cli 0.146.0: null is indistinguishable from an absent key
35162
- // — both leave the thread's current effort alone, emitting no
35163
- // thread/settings/updated, and thread/resume reads the old value
35164
- // back. The app-server offers no way to clear a level either:
35165
- // "" is rejected outright and thread/start takes no effort at
35166
- // all. So a thread keeps the last level it was sent until it is
35167
- // sent another, and choosing Default lands on the bot's next new
35168
- // thread rather than the current one.
35169
- ...turn.effort ? { effort: turn.effort } : {}
35170
- });
35187
+ const startTurn2 = () => {
35188
+ startingNativeTurn = true;
35189
+ return request2("turn/start", {
35190
+ threadId: codexThreadId,
35191
+ input: turnInput,
35192
+ ...approvalParams.turn,
35193
+ // Spread, not `effort: turn.effort ?? null`. Probed against
35194
+ // codex-cli 0.146.0: null is indistinguishable from an absent key
35195
+ // — both leave the thread's current effort alone, emitting no
35196
+ // thread/settings/updated, and thread/resume reads the old value
35197
+ // back. The app-server offers no way to clear a level either:
35198
+ // "" is rejected outright and thread/start takes no effort at
35199
+ // all. So a thread keeps the last level it was sent until it is
35200
+ // sent another, and choosing Default lands on the bot's next new
35201
+ // thread rather than the current one.
35202
+ ...turn.effort ? { effort: turn.effort } : {}
35203
+ });
35204
+ };
35171
35205
  try {
35172
35206
  await startTurn2();
35173
35207
  } catch (error51) {
@@ -51101,6 +51135,13 @@ var MANAGED_VPS_CONTAINER_NAME = /^openmausbot-vps-[a-z0-9]{1,12}-[a-f0-9]{12}$/
51101
51135
  var IMAGE_ID = /^sha256:[a-f0-9]{64}$/i;
51102
51136
  var PIDS_LIMIT2 = 512;
51103
51137
  var SCREENSHOT_PATH = "/tmp/openmausbot-vps-preview.png";
51138
+ var SCREENSHOT_TRANSFER = `/opt/venv/bin/python -I -c 'import base64, io, sys
51139
+ from PIL import Image
51140
+ with Image.open(sys.argv[1]) as image:
51141
+ image.thumbnail((1280, 1280))
51142
+ output = io.BytesIO()
51143
+ image.convert("RGB").save(output, format="JPEG", quality=70)
51144
+ sys.stdout.write(base64.b64encode(output.getvalue()).decode("ascii"))' "$1" 2>/dev/null || { base64 < "$1" | tr -d "\\n"; }`;
51104
51145
  var INTERNAL_VIEWER_PORT2 = 6901;
51105
51146
  var VIEWER_VERSION = "1";
51106
51147
  var lifecycleLocks = /* @__PURE__ */ new Map();
@@ -51114,6 +51155,9 @@ function vpsLifecycleBusy() {
51114
51155
  var LOCK_ACQUIRE_TIMEOUT_MS = 5e3;
51115
51156
  var STATUS_CACHE_TTL_MS = 1e4;
51116
51157
  var statusCache = /* @__PURE__ */ new Map();
51158
+ var SCREENSHOT_BUDGET_MS = 45e3;
51159
+ var SCREENSHOT_CLEANUP_BUDGET_MS = 1e4;
51160
+ var pendingScreenshots = /* @__PURE__ */ new Map();
51117
51161
  var viewerConnections = /* @__PURE__ */ new Map();
51118
51162
  var desktopTunnels = /* @__PURE__ */ new Map();
51119
51163
  function containerNamePart(botId) {
@@ -51448,6 +51492,8 @@ async function vpsComputerStatus(cfg2, botId, runner = defaultRunner) {
51448
51492
  const key3 = vpsLockKey(cfg2, botId);
51449
51493
  const cacheable = runner === defaultRunner && key3 !== null;
51450
51494
  if (cacheable) {
51495
+ await pendingScreenshots.get(key3)?.catch(() => {
51496
+ });
51451
51497
  const cached3 = statusCache.get(key3);
51452
51498
  if (cached3 && cached3.expiresAt > Date.now()) return cached3.status;
51453
51499
  }
@@ -51892,21 +51938,49 @@ async function vpsComputerScreenshot(cfg2, botId, runner = defaultRunner) {
51892
51938
  const alias = vpsSshAlias(cfg2);
51893
51939
  if (!alias) throw Object.assign(new Error("VPS is not configured"), { status: 409 });
51894
51940
  const key3 = `${alias}:${vpsContainerName(botId)}`;
51941
+ const pending = pendingScreenshots.get(key3);
51942
+ if (pending) return pending;
51895
51943
  const cacheable = runner === defaultRunner;
51896
- return withVpsLifecycleLock(key3, async () => {
51944
+ const deadline = Date.now() + SCREENSHOT_BUDGET_MS;
51945
+ const workDeadline = deadline - SCREENSHOT_CLEANUP_BUDGET_MS;
51946
+ let budgetExpired = false;
51947
+ const timeoutError = () => Object.assign(new Error("The VPS screen preview timed out. Retry the preview when the connection recovers."), { status: 504 });
51948
+ const boundedRunner = async (args, options = {}) => {
51949
+ const remaining = workDeadline - Date.now() - COMMAND_TIMEOUT_KILL_GRACE_MS;
51950
+ if (remaining <= 0) {
51951
+ budgetExpired = true;
51952
+ throw timeoutError();
51953
+ }
51954
+ try {
51955
+ const result = await runner(args, { ...options, timeoutMs: Math.min(options.timeoutMs ?? 12e4, remaining) });
51956
+ if (Date.now() >= workDeadline) {
51957
+ budgetExpired = true;
51958
+ throw timeoutError();
51959
+ }
51960
+ return result;
51961
+ } catch (error51) {
51962
+ if (Date.now() >= workDeadline - COMMAND_TIMEOUT_KILL_GRACE_MS) budgetExpired = true;
51963
+ throw budgetExpired ? timeoutError() : error51;
51964
+ }
51965
+ };
51966
+ const capture = withVpsLifecycleLock(key3, async () => {
51897
51967
  const cached3 = cacheable ? statusCache.get(key3) : void 0;
51898
- const status = cached3 && cached3.expiresAt > Date.now() ? cached3.status : await computeVpsComputerStatus(cfg2, botId, runner);
51968
+ const status = cached3 && cached3.expiresAt > Date.now() ? cached3.status : await computeVpsComputerStatus(cfg2, botId, boundedRunner);
51969
+ if (budgetExpired) {
51970
+ if (cacheable) statusCache.delete(key3);
51971
+ throw timeoutError();
51972
+ }
51899
51973
  if (!status.ready) {
51900
51974
  if (cacheable) statusCache.delete(key3);
51901
51975
  throw Object.assign(new Error(status.problem ?? "The VPS computer is not ready"), { status: 409 });
51902
51976
  }
51903
- if (cacheable) statusCache.set(key3, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
51904
51977
  const containerRef = status.container_id ?? status.container_name;
51905
51978
  if (!CONTAINER_ID.test(containerRef) && !CONTAINER_NAME.test(containerRef)) {
51906
51979
  throw Object.assign(new Error("the VPS container reference is malformed"), { status: 409 });
51907
51980
  }
51981
+ let frame;
51908
51982
  try {
51909
- await runner(
51983
+ await boundedRunner(
51910
51984
  vpsDockerArgs(
51911
51985
  alias,
51912
51986
  cuaExecArgs(
@@ -51916,30 +51990,41 @@ async function vpsComputerScreenshot(cfg2, botId, runner = defaultRunner) {
51916
51990
  ),
51917
51991
  { timeoutMs: 3e4 }
51918
51992
  );
51919
- const encoded = (await runner(vpsDockerArgs(alias, [
51993
+ const encoded = (await boundedRunner(vpsDockerArgs(alias, [
51920
51994
  "exec",
51921
51995
  "-u",
51922
51996
  "cua",
51923
51997
  "-e",
51924
51998
  "HOME=/home/cua",
51925
51999
  containerRef,
51926
- "base64",
51927
- "-w0",
52000
+ "sh",
52001
+ "-c",
52002
+ SCREENSHOT_TRANSFER,
52003
+ "openmausbot-preview",
51928
52004
  SCREENSHOT_PATH
51929
52005
  ]), { timeoutMs: 3e4 })).stdout.trim();
51930
52006
  const checked = wholeScreenshot(Buffer.from(encoded, "base64"));
51931
52007
  if (!checked.ok) throw Object.assign(new Error("Cua Driver returned an incomplete VPS screenshot"), { status: 502 });
51932
- return { png: encoded, format: checked.mime === "image/jpeg" ? "jpeg" : "png" };
52008
+ frame = { png: encoded, format: checked.mime === "image/jpeg" ? "jpeg" : "png" };
51933
52009
  } catch (error51) {
51934
52010
  if (cacheable) statusCache.delete(key3);
51935
52011
  throw error51;
51936
52012
  } finally {
51937
- await runner(vpsDockerArgs(alias, ["exec", "-u", "cua", containerRef, "rm", "-f", SCREENSHOT_PATH]), {
51938
- timeoutMs: 1e4
52013
+ const cleanupMs = Math.min(5e3, deadline - Date.now() - COMMAND_TIMEOUT_KILL_GRACE_MS);
52014
+ if (cleanupMs > 0) await runner(vpsDockerArgs(alias, ["exec", "-u", "cua", containerRef, "rm", "-f", SCREENSHOT_PATH]), {
52015
+ timeoutMs: cleanupMs
51939
52016
  }).catch(() => {
51940
52017
  });
51941
52018
  }
52019
+ if (cacheable) statusCache.set(key3, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
52020
+ return frame;
51942
52021
  });
52022
+ pendingScreenshots.set(key3, capture);
52023
+ try {
52024
+ return await capture;
52025
+ } finally {
52026
+ if (pendingScreenshots.get(key3) === capture) pendingScreenshots.delete(key3);
52027
+ }
51943
52028
  }
51944
52029
 
51945
52030
  // server/routines.ts
@@ -69491,6 +69576,7 @@ var localVmProvisionBusy = false;
69491
69576
  var localVmModeChangeBusy = false;
69492
69577
  var activeVpsThreads = /* @__PURE__ */ new Map();
69493
69578
  var boxLifecycleBusyBots = /* @__PURE__ */ new Set();
69579
+ var vpsPreviewRequests = /* @__PURE__ */ new Map();
69494
69580
  var orphanBoxLifecycleBusyIds = /* @__PURE__ */ new Set();
69495
69581
  var boxInventoryRequestsBusyIds = /* @__PURE__ */ new Set();
69496
69582
  var computerProviderConfigTransitions = /* @__PURE__ */ new Set();
@@ -69522,7 +69608,7 @@ function providerOperationConflict(provider) {
69522
69608
  if (managedBoxOwners().some((owner2) => owner2.inUse)) {
69523
69609
  return `stop active bot work and computer control before changing ${provider === "box" ? "the Box account" : "the VPS connection"}`;
69524
69610
  }
69525
- if (boxLifecycleBusyBots.size > 0) {
69611
+ if (boxLifecycleBusyBots.size > 0 || vpsPreviewRequests.size > 0) {
69526
69612
  return "wait for cloud computer actions to finish before changing provider settings";
69527
69613
  }
69528
69614
  if (provider === "box") {
@@ -69568,10 +69654,13 @@ function claimManagedBoxMutation(instance) {
69568
69654
  }
69569
69655
  return claimBotComputerLifecycle(ownerBotId);
69570
69656
  }
69571
- function claimBotComputerLifecycle(botId) {
69657
+ function claimBotComputerLifecycle(botId, allowPreview = false) {
69572
69658
  if (boxLifecycleBusyBots.has(botId)) {
69573
69659
  throw Object.assign(new Error("this bot's cloud computer is being changed \u2014 wait for it to finish"), { status: 409 });
69574
69660
  }
69661
+ if (!allowPreview && vpsPreviewRequests.has(botId)) {
69662
+ throw Object.assign(new Error("a screen preview is still refreshing \u2014 wait before changing this computer"), { status: 409 });
69663
+ }
69575
69664
  boxLifecycleBusyBots.add(botId);
69576
69665
  return () => boxLifecycleBusyBots.delete(botId);
69577
69666
  }
@@ -73491,7 +73580,7 @@ var workspaceBackupRoutes = createWorkspaceBackupRoutes({
73491
73580
  return Boolean(current2?.scopes.includes("admin") && current2.kind === original.kind && (current2.kind !== "session" || original.kind === "session" && current2.session.id === original.session.id));
73492
73581
  },
73493
73582
  exclusive: (work, keepLocked) => workspaceMaintenance.run(work, {
73494
- idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall && !routines?.isTicking && !calendarCalls?.isTicking && !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy && !localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !orphanBoxLifecycleBusyIds.size && !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size && store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !computerControl.snapshot(bot.id).held) && store.groups.every((group) => !groupIsWorking(group)),
73583
+ idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall && !routines?.isTicking && !calendarCalls?.isTicking && !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy && !localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoxLifecycleBusyIds.size && !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size && store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !computerControl.snapshot(bot.id).held) && store.groups.every((group) => !groupIsWorking(group)),
73495
73584
  pause: () => {
73496
73585
  routines?.stop();
73497
73586
  calendarCalls?.stop();
@@ -78596,7 +78685,17 @@ data: ${JSON.stringify({ kind: "ping" })}
78596
78685
  return json3(res, 409, { error: "this bot's cloud computer is being changed \u2014 wait for it to finish" });
78597
78686
  }
78598
78687
  if (bot.cloudBackend === "vps") {
78599
- const releaseComputerLifecycle2 = claimBotComputerLifecycle(botId);
78688
+ if (m2[2] === "screenshot") {
78689
+ let preview = vpsPreviewRequests.get(botId);
78690
+ if (!preview) {
78691
+ preview = vpsComputerScreenshot(cfg, botId).finally(() => {
78692
+ vpsPreviewRequests.delete(botId);
78693
+ });
78694
+ vpsPreviewRequests.set(botId, preview);
78695
+ }
78696
+ return json3(res, 200, await preview);
78697
+ }
78698
+ const releaseComputerLifecycle2 = claimBotComputerLifecycle(botId, m2[2] === "join");
78600
78699
  try {
78601
78700
  if (m2[2] === "exec") {
78602
78701
  return json3(res, 409, { error: "the VPS console is available to the bot through its scoped computer tools" });
@@ -78610,7 +78709,6 @@ data: ${JSON.stringify({ kind: "ping" })}
78610
78709
  if (m2[2] === "join") {
78611
78710
  return json3(res, 200, await vpsComputerJoin(cfg, botId));
78612
78711
  }
78613
- if (m2[2] === "screenshot") return json3(res, 200, await vpsComputerScreenshot(cfg, botId));
78614
78712
  const action = m2[2] === "provision" ? "provision" : m2[2] === "remove" ? "remove" : "stop";
78615
78713
  return json3(res, 200, await vpsComputerAction(action, cfg, botId));
78616
78714
  } finally {
@@ -24762,6 +24762,10 @@ var init_codex = __esm({
24762
24762
  stdio: ["pipe", "pipe", "pipe"]
24763
24763
  });
24764
24764
  let abandoned = false;
24765
+ let codexThreadId = null;
24766
+ let codexTurnId = null;
24767
+ let startingNativeTurn = false;
24768
+ const earlyNotifications = [];
24765
24769
  const state = {
24766
24770
  settled: false,
24767
24771
  lastText: "",
@@ -24795,6 +24799,18 @@ var init_codex = __esm({
24795
24799
  rpcPending.set(id, {
24796
24800
  resolve: (v) => {
24797
24801
  clearTimeout(timer);
24802
+ if (method === "turn/start") {
24803
+ if (typeof v?.turn?.id !== "string" || !v.turn.id) {
24804
+ reject(new Error("Codex did not return a native turn id"));
24805
+ return;
24806
+ }
24807
+ codexTurnId = v.turn.id;
24808
+ startingNativeTurn = false;
24809
+ for (const notification of earlyNotifications.splice(0)) {
24810
+ if (state.settled) break;
24811
+ handleNotification(notification);
24812
+ }
24813
+ }
24798
24814
  resolve13(v);
24799
24815
  },
24800
24816
  reject: (e) => {
@@ -24904,6 +24920,22 @@ var init_codex = __esm({
24904
24920
  };
24905
24921
  const handleNotification = (msg) => {
24906
24922
  const p = msg.params ?? {};
24923
+ const connectionError = msg.method === "error" && !("threadId" in p) && !("turnId" in p);
24924
+ if (!connectionError) {
24925
+ if (!codexThreadId || p.threadId !== codexThreadId) return;
24926
+ if (!codexTurnId) {
24927
+ if (startingNativeTurn) {
24928
+ if (earlyNotifications.length >= 1024) {
24929
+ void settle(false, "too_many_events_before_turn_start");
24930
+ } else {
24931
+ earlyNotifications.push(msg);
24932
+ }
24933
+ }
24934
+ return;
24935
+ }
24936
+ const eventTurnId = msg.method === "turn/started" || msg.method === "turn/completed" ? p.turn?.id : p.turnId;
24937
+ if (eventTurnId !== codexTurnId) return;
24938
+ }
24907
24939
  switch (msg.method) {
24908
24940
  // token-level chat text; the item/completed frame follows with the
24909
24941
  // whole message, so its delta is only a fallback when none streamed
@@ -25088,7 +25120,6 @@ var init_codex = __esm({
25088
25120
  }
25089
25121
  autoAcceptPermissions = approvalMode === "full";
25090
25122
  const cursor3 = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
25091
- let codexThreadId = null;
25092
25123
  let startedModel = null;
25093
25124
  if (cursor3) {
25094
25125
  try {
@@ -25141,21 +25172,24 @@ var init_codex = __esm({
25141
25172
  ...promptText ? [{ type: "text", text: promptText }] : [],
25142
25173
  ...(turn.images ?? []).map((image) => ({ type: "localImage", path: image.path }))
25143
25174
  ];
25144
- const startTurn = () => request("turn/start", {
25145
- threadId: codexThreadId,
25146
- input: turnInput,
25147
- ...approvalParams.turn,
25148
- // Spread, not `effort: turn.effort ?? null`. Probed against
25149
- // codex-cli 0.146.0: null is indistinguishable from an absent key
25150
- // — both leave the thread's current effort alone, emitting no
25151
- // thread/settings/updated, and thread/resume reads the old value
25152
- // back. The app-server offers no way to clear a level either:
25153
- // "" is rejected outright and thread/start takes no effort at
25154
- // all. So a thread keeps the last level it was sent until it is
25155
- // sent another, and choosing Default lands on the bot's next new
25156
- // thread rather than the current one.
25157
- ...turn.effort ? { effort: turn.effort } : {}
25158
- });
25175
+ const startTurn = () => {
25176
+ startingNativeTurn = true;
25177
+ return request("turn/start", {
25178
+ threadId: codexThreadId,
25179
+ input: turnInput,
25180
+ ...approvalParams.turn,
25181
+ // Spread, not `effort: turn.effort ?? null`. Probed against
25182
+ // codex-cli 0.146.0: null is indistinguishable from an absent key
25183
+ // — both leave the thread's current effort alone, emitting no
25184
+ // thread/settings/updated, and thread/resume reads the old value
25185
+ // back. The app-server offers no way to clear a level either:
25186
+ // "" is rejected outright and thread/start takes no effort at
25187
+ // all. So a thread keeps the last level it was sent until it is
25188
+ // sent another, and choosing Default lands on the bot's next new
25189
+ // thread rather than the current one.
25190
+ ...turn.effort ? { effort: turn.effort } : {}
25191
+ });
25192
+ };
25159
25193
  try {
25160
25194
  await startTurn();
25161
25195
  } catch (error51) {
@@ -24762,6 +24762,10 @@ var init_codex = __esm({
24762
24762
  stdio: ["pipe", "pipe", "pipe"]
24763
24763
  });
24764
24764
  let abandoned = false;
24765
+ let codexThreadId = null;
24766
+ let codexTurnId = null;
24767
+ let startingNativeTurn = false;
24768
+ const earlyNotifications = [];
24765
24769
  const state = {
24766
24770
  settled: false,
24767
24771
  lastText: "",
@@ -24795,6 +24799,18 @@ var init_codex = __esm({
24795
24799
  rpcPending.set(id, {
24796
24800
  resolve: (v) => {
24797
24801
  clearTimeout(timer);
24802
+ if (method === "turn/start") {
24803
+ if (typeof v?.turn?.id !== "string" || !v.turn.id) {
24804
+ reject(new Error("Codex did not return a native turn id"));
24805
+ return;
24806
+ }
24807
+ codexTurnId = v.turn.id;
24808
+ startingNativeTurn = false;
24809
+ for (const notification of earlyNotifications.splice(0)) {
24810
+ if (state.settled) break;
24811
+ handleNotification(notification);
24812
+ }
24813
+ }
24798
24814
  resolve13(v);
24799
24815
  },
24800
24816
  reject: (e) => {
@@ -24904,6 +24920,22 @@ var init_codex = __esm({
24904
24920
  };
24905
24921
  const handleNotification = (msg) => {
24906
24922
  const p = msg.params ?? {};
24923
+ const connectionError = msg.method === "error" && !("threadId" in p) && !("turnId" in p);
24924
+ if (!connectionError) {
24925
+ if (!codexThreadId || p.threadId !== codexThreadId) return;
24926
+ if (!codexTurnId) {
24927
+ if (startingNativeTurn) {
24928
+ if (earlyNotifications.length >= 1024) {
24929
+ void settle(false, "too_many_events_before_turn_start");
24930
+ } else {
24931
+ earlyNotifications.push(msg);
24932
+ }
24933
+ }
24934
+ return;
24935
+ }
24936
+ const eventTurnId = msg.method === "turn/started" || msg.method === "turn/completed" ? p.turn?.id : p.turnId;
24937
+ if (eventTurnId !== codexTurnId) return;
24938
+ }
24907
24939
  switch (msg.method) {
24908
24940
  // token-level chat text; the item/completed frame follows with the
24909
24941
  // whole message, so its delta is only a fallback when none streamed
@@ -25088,7 +25120,6 @@ var init_codex = __esm({
25088
25120
  }
25089
25121
  autoAcceptPermissions = approvalMode === "full";
25090
25122
  const cursor3 = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
25091
- let codexThreadId = null;
25092
25123
  let startedModel = null;
25093
25124
  if (cursor3) {
25094
25125
  try {
@@ -25141,21 +25172,24 @@ var init_codex = __esm({
25141
25172
  ...promptText ? [{ type: "text", text: promptText }] : [],
25142
25173
  ...(turn.images ?? []).map((image) => ({ type: "localImage", path: image.path }))
25143
25174
  ];
25144
- const startTurn = () => request("turn/start", {
25145
- threadId: codexThreadId,
25146
- input: turnInput,
25147
- ...approvalParams.turn,
25148
- // Spread, not `effort: turn.effort ?? null`. Probed against
25149
- // codex-cli 0.146.0: null is indistinguishable from an absent key
25150
- // — both leave the thread's current effort alone, emitting no
25151
- // thread/settings/updated, and thread/resume reads the old value
25152
- // back. The app-server offers no way to clear a level either:
25153
- // "" is rejected outright and thread/start takes no effort at
25154
- // all. So a thread keeps the last level it was sent until it is
25155
- // sent another, and choosing Default lands on the bot's next new
25156
- // thread rather than the current one.
25157
- ...turn.effort ? { effort: turn.effort } : {}
25158
- });
25175
+ const startTurn = () => {
25176
+ startingNativeTurn = true;
25177
+ return request("turn/start", {
25178
+ threadId: codexThreadId,
25179
+ input: turnInput,
25180
+ ...approvalParams.turn,
25181
+ // Spread, not `effort: turn.effort ?? null`. Probed against
25182
+ // codex-cli 0.146.0: null is indistinguishable from an absent key
25183
+ // — both leave the thread's current effort alone, emitting no
25184
+ // thread/settings/updated, and thread/resume reads the old value
25185
+ // back. The app-server offers no way to clear a level either:
25186
+ // "" is rejected outright and thread/start takes no effort at
25187
+ // all. So a thread keeps the last level it was sent until it is
25188
+ // sent another, and choosing Default lands on the bot's next new
25189
+ // thread rather than the current one.
25190
+ ...turn.effort ? { effort: turn.effort } : {}
25191
+ });
25192
+ };
25159
25193
  try {
25160
25194
  await startTurn();
25161
25195
  } catch (error51) {
@@ -531,7 +531,7 @@ const TOOLS = [
531
531
  additionalProperties: false,
532
532
  properties: {
533
533
  action: { type: "string", enum: ["append", "replace", "remove", "supersede"] },
534
- text: { type: "string", minLength: 1, pattern: "\\S", description: "Non-blank new text for append, replace, or supersede: the fact itself, without a date or bullet. Omit for remove; use remove to delete a passage." },
534
+ text: { type: "string", minLength: 1, description: "Non-blank new text for append, replace, or supersede: the fact itself, without a date or bullet. Omit for remove; use remove to delete a passage." },
535
535
  old_text: { type: "string", minLength: 1, description: "Exact unique existing passage for replace, supersede, or remove. Omit for append." },
536
536
  },
537
537
  required: ["action"],
@@ -544,7 +544,7 @@ const TOOLS = [
544
544
  type: "object",
545
545
  additionalProperties: false,
546
546
  properties: {
547
- text: { type: "string", minLength: 1, pattern: "\\S", description: "One line about what happened, in plain words." },
547
+ text: { type: "string", minLength: 1, description: "One line about what happened, in plain words." },
548
548
  },
549
549
  required: ["text"],
550
550
  },
@@ -515,6 +515,10 @@ export const CodexDriver = {
515
515
  stdio: ["pipe", "pipe", "pipe"],
516
516
  });
517
517
  let abandoned = false;
518
+ let codexThreadId = null;
519
+ let codexTurnId = null;
520
+ let startingNativeTurn = false;
521
+ const earlyNotifications = [];
518
522
  const state = {
519
523
  settled: false,
520
524
  lastText: "",
@@ -553,6 +557,21 @@ export const CodexDriver = {
553
557
  rpcPending.set(id, {
554
558
  resolve: (v) => {
555
559
  clearTimeout(timer);
560
+ if (method === "turn/start") {
561
+ if (typeof v?.turn?.id !== "string" || !v.turn.id) {
562
+ reject(new Error("Codex did not return a native turn id"));
563
+ return;
564
+ }
565
+ // Bind synchronously: a single stdout chunk can contain the
566
+ // response, streamed events, completion and a late request.
567
+ codexTurnId = v.turn.id;
568
+ startingNativeTurn = false;
569
+ for (const notification of earlyNotifications.splice(0)) {
570
+ if (state.settled)
571
+ break;
572
+ handleNotification(notification);
573
+ }
574
+ }
556
575
  resolve(v);
557
576
  },
558
577
  reject: (e) => {
@@ -710,6 +729,33 @@ export const CodexDriver = {
710
729
  };
711
730
  const handleNotification = (msg) => {
712
731
  const p = msg.params ?? {};
732
+ // An app-server also emits notifications for native helper threads.
733
+ // Only this request's parent may write its transcript/usage or settle
734
+ // its run. Requests still use the approval broker above, including
735
+ // helper requests; ignoring child *notifications* must not grant tools.
736
+ const connectionError = msg.method === "error" &&
737
+ !("threadId" in p) && !("turnId" in p);
738
+ if (!connectionError) {
739
+ if (!codexThreadId || p.threadId !== codexThreadId)
740
+ return;
741
+ if (!codexTurnId) {
742
+ // Some servers stream before acknowledging turn/start. Retain a
743
+ // bounded prefix, then filter against the authoritative response.
744
+ if (startingNativeTurn) {
745
+ if (earlyNotifications.length >= 1024) {
746
+ void settle(false, "too_many_events_before_turn_start");
747
+ }
748
+ else {
749
+ earlyNotifications.push(msg);
750
+ }
751
+ }
752
+ return;
753
+ }
754
+ const eventTurnId = msg.method === "turn/started" || msg.method === "turn/completed"
755
+ ? p.turn?.id : p.turnId;
756
+ if (eventTurnId !== codexTurnId)
757
+ return;
758
+ }
713
759
  switch (msg.method) {
714
760
  // token-level chat text; the item/completed frame follows with the
715
761
  // whole message, so its delta is only a fallback when none streamed
@@ -959,7 +1005,6 @@ export const CodexDriver = {
959
1005
  // on start AND resume so Codex owns their lifetime through compaction.
960
1006
  // Removed bot rules are cleared without dropping native configured rules.
961
1007
  const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
962
- let codexThreadId = null;
963
1008
  let startedModel = null;
964
1009
  if (cursor) {
965
1010
  try {
@@ -1020,21 +1065,24 @@ export const CodexDriver = {
1020
1065
  ...(promptText ? [{ type: "text", text: promptText }] : []),
1021
1066
  ...(turn.images ?? []).map((image) => ({ type: "localImage", path: image.path })),
1022
1067
  ];
1023
- const startTurn = () => request("turn/start", {
1024
- threadId: codexThreadId,
1025
- input: turnInput,
1026
- ...approvalParams.turn,
1027
- // Spread, not `effort: turn.effort ?? null`. Probed against
1028
- // codex-cli 0.146.0: null is indistinguishable from an absent key
1029
- // — both leave the thread's current effort alone, emitting no
1030
- // thread/settings/updated, and thread/resume reads the old value
1031
- // back. The app-server offers no way to clear a level either:
1032
- // "" is rejected outright and thread/start takes no effort at
1033
- // all. So a thread keeps the last level it was sent until it is
1034
- // sent another, and choosing Default lands on the bot's next new
1035
- // thread rather than the current one.
1036
- ...(turn.effort ? { effort: turn.effort } : {}),
1037
- });
1068
+ const startTurn = () => {
1069
+ startingNativeTurn = true;
1070
+ return request("turn/start", {
1071
+ threadId: codexThreadId,
1072
+ input: turnInput,
1073
+ ...approvalParams.turn,
1074
+ // Spread, not `effort: turn.effort ?? null`. Probed against
1075
+ // codex-cli 0.146.0: null is indistinguishable from an absent key
1076
+ // — both leave the thread's current effort alone, emitting no
1077
+ // thread/settings/updated, and thread/resume reads the old value
1078
+ // back. The app-server offers no way to clear a level either:
1079
+ // "" is rejected outright and thread/start takes no effort at
1080
+ // all. So a thread keeps the last level it was sent until it is
1081
+ // sent another, and choosing Default lands on the bot's next new
1082
+ // thread rather than the current one.
1083
+ ...(turn.effort ? { effort: turn.effort } : {}),
1084
+ });
1085
+ };
1038
1086
  try {
1039
1087
  await startTurn();
1040
1088
  }