openmausbot 0.1.78 → 0.1.80

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 (56) hide show
  1. package/dist/assets/index-Cu8BkIfo.js +305 -0
  2. package/dist/assets/{index-BqDJqf2C.js → index-D6RZKWks.js} +1 -1
  3. package/dist/assets/index-tfpeJAIG.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +11 -5
  6. package/dist-server/drivers/agents-proxy.js +21 -6
  7. package/dist-server/enterprise/server/index.js +0 -1
  8. package/dist-server/index.js +15942 -6859
  9. package/dist-server/local-computer.js +0 -1
  10. package/dist-server/mcp-server.js +8 -1
  11. package/dist-server/openmausbot.js +9303 -786
  12. package/dist-server/pair-cli.js +9303 -786
  13. package/dist-server/proxy-paths.js +0 -1
  14. package/dist-server/server/box.js +98 -41
  15. package/dist-server/server/browser-engine.js +1 -1
  16. package/dist-server/server/browser-runtime.js +11 -3
  17. package/dist-server/server/browser-tool-shape.js +72 -0
  18. package/dist-server/server/claude-accounts.js +1 -0
  19. package/dist-server/server/cli-setup.js +1 -1
  20. package/dist-server/server/composio.js +18 -0
  21. package/dist-server/server/config.js +16 -4
  22. package/dist-server/server/container-computer.js +0 -12
  23. package/dist-server/server/drivers/acp/core.js +4 -19
  24. package/dist-server/server/drivers/agents-proxy.js +27 -2
  25. package/dist-server/server/drivers/boxagent.js +48 -4
  26. package/dist-server/server/drivers/chat-mcp-tools.js +368 -0
  27. package/dist-server/server/drivers/chat-tool-approval.js +42 -0
  28. package/dist-server/server/drivers/claude.js +86 -38
  29. package/dist-server/server/drivers/codex.js +225 -75
  30. package/dist-server/server/drivers/grok.js +4 -0
  31. package/dist-server/server/drivers/minimax.js +11 -3
  32. package/dist-server/server/drivers/openai-chat-protocol.js +92 -0
  33. package/dist-server/server/drivers/openai-chat.js +332 -88
  34. package/dist-server/server/drivers/openai-compat.js +4 -0
  35. package/dist-server/server/drivers/pi.js +1 -9
  36. package/dist-server/server/harness/registry.js +2 -2
  37. package/dist-server/server/index.js +267 -50
  38. package/dist-server/server/managed-desktop.js +205 -0
  39. package/dist-server/server/model-context-window.js +20 -0
  40. package/dist-server/server/proxy-paths.js +0 -1
  41. package/dist-server/server/redact.js +1 -0
  42. package/dist-server/server/room-handoffs.js +57 -9
  43. package/dist-server/server/store.js +13 -1
  44. package/dist-server/server/tts/chatterbox.js +83 -0
  45. package/dist-server/server/tts/index.js +45 -12
  46. package/dist-server/server/workspace.js +12 -2
  47. package/dist-server/shared/ask-question.js +28 -0
  48. package/dist-server/shared/computer-contention.js +17 -0
  49. package/dist-server/vps-container-mcp.js +11 -5
  50. package/enterprise/server/index.js +0 -1
  51. package/package.json +1 -1
  52. package/dist/assets/index-DjwAroIZ.js +0 -305
  53. package/dist/assets/index-Pbb6Ao0s.css +0 -1
  54. package/dist-server/computer-proxy.js +0 -1196
  55. package/dist-server/server/computer-proxy.js +0 -1070
  56. package/dist-server/server/remote-computer.js +0 -170
@@ -12,24 +12,37 @@
12
12
  import { existsSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { stripWorkspaceCredentialEnv } from "../config.js";
15
- import { computerProxyEnv } from "../container-computer.js";
16
15
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
17
- import { SPAWNED_PROXIES } from "../proxy-paths.js";
18
16
  import { isHarnessOwnedMcpEnvName } from "../mcp-registry.js";
19
17
  import { newEventId, newId } from "../contracts.js";
20
18
  import { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.js";
21
19
  import { codexLocalProviderArgs } from "./local-inject.js";
22
20
  import { augmentedPath, splitCliString } from "../env-path.js";
23
- import { classifyError, computeBackoff, RETRY_MAX_ATTEMPTS } from "./retry.js";
21
+ import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS } from "./retry.js";
24
22
  import { appendNative } from "./native.js";
25
23
  import { commandSummary, toolDetailPreview } from "../tool-summary.js";
26
24
  import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instructions.js";
27
25
  import { CodexDeviceAuthController } from "./codex-device-auth.js";
28
26
  import { codexAccountEmail } from "./codex-identity.js";
27
+ import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
29
28
  export { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.js";
30
29
  const DRIVER_KIND = "codex";
31
30
  const ASTRA_MODEL_ID = "gpt-6-astra";
32
31
  const ASTRA_MIN_CODEX_VERSION = [0, 153, 1];
32
+ class CodexRpcError extends Error {
33
+ code;
34
+ constructor(error) {
35
+ super(error.message ?? JSON.stringify(error));
36
+ this.code = error.code;
37
+ }
38
+ }
39
+ function missingNativeCodexThread(error, cursor) {
40
+ // Codex's local thread/resume rejection, verified with an empty native home.
41
+ // A generic 404, auth error, timeout or prose mentioning a missing thread is
42
+ // not evidence that the native history was lost. Unknown versions fail closed.
43
+ return error instanceof CodexRpcError && error.code === -32600 &&
44
+ error.message === `no rollout found for thread id ${cursor}`;
45
+ }
33
46
  /** Whether an installed Codex predates the release that exposes GPT-6 Astra
34
47
  * through app-server. Unknown version formats stay quiet: a bad guess should
35
48
  * never nag someone whose custom build may already support the model. */
@@ -81,8 +94,33 @@ function decodeConfig(raw) {
81
94
  return {
82
95
  cli: typeof o.cli === "string" ? o.cli : "codex",
83
96
  fullAuto: o.fullAuto === true,
97
+ ...(o.managed && typeof o.managed === "object" ? { managed: decodeManagedCodex(o.managed) } : {}),
84
98
  };
85
99
  }
100
+ function decodeManagedCodex(raw) {
101
+ const value = raw;
102
+ if (typeof value.url !== "string" || !Array.isArray(value.models) || !value.models.length || value.models.some(model => typeof model !== "string" || !/^[\w][\w./+-]*$/.test(model))) {
103
+ throw new Error("Invalid Company Codex configuration.");
104
+ }
105
+ const url = new URL(value.url);
106
+ if (url.username || url.password || url.search || url.hash || !["https:", "http:"].includes(url.protocol))
107
+ throw new Error("Invalid Company Codex endpoint.");
108
+ return { url: url.href.replace(/\/$/, ""), models: value.models };
109
+ }
110
+ export function managedCodexArgs(config) {
111
+ // Credential stays in the instance environment, never argv or config.toml.
112
+ // https://learn.chatgpt.com/docs/config-file/config-reference
113
+ return [
114
+ "-c", 'model_provider="openmaus_company"',
115
+ "-c", 'model_providers.openmaus_company.name="Company"',
116
+ "-c", `model_providers.openmaus_company.base_url=${JSON.stringify(config.url)}`,
117
+ "-c", 'model_providers.openmaus_company.env_key="OPENMAUSBOT_COMPANY_API_KEY"',
118
+ "-c", 'model_providers.openmaus_company.wire_api="responses"',
119
+ "-c", "model_providers.openmaus_company.requires_openai_auth=false",
120
+ "-c", 'cli_auth_credentials_store="ephemeral"',
121
+ "-c", "shell_environment_policy.ignore_default_excludes=false",
122
+ ];
123
+ }
86
124
  const QUESTION_TIMEOUT_NOTE = "No answer was given — use your best judgment.";
87
125
  const DENY_TIMEOUT_NOTE = "OpenMausBot: nobody answered this permission request in time. Skip this action and finish what you can without it.";
88
126
  /** RequestPermissionProfile uses null for permission families that were not
@@ -418,8 +456,10 @@ export const CodexDriver = {
418
456
  return env;
419
457
  };
420
458
  const catalogEnv = childEnv();
421
- let models = STATIC_CODEX_MODELS;
459
+ let models = config.managed ? { default: config.managed.models[0], options: config.managed.models.map(id => ({ id, label: id })) } : STATIC_CODEX_MODELS;
422
460
  const refreshModels = async () => {
461
+ if (config.managed)
462
+ return;
423
463
  try {
424
464
  const resolved = await readCodexModelCatalog(catalogEnv, fetch, config.cli);
425
465
  if (resolved.options.length)
@@ -449,9 +489,17 @@ export const CodexDriver = {
449
489
  createdAt: new Date().toISOString(),
450
490
  });
451
491
  const sendTurn = async (turn) => {
492
+ if (config.managed && (!turn.model || !config.managed.models.includes(turn.model) || !input.environment.OPENMAUSBOT_COMPANY_API_KEY || !input.environment.CODEX_HOME)) {
493
+ throw new Error("Company model access is unavailable. Reconnect your organization; personal billing will not be used.");
494
+ }
452
495
  // One driver instance serves many threads. Interrupt state belongs to
453
496
  // this turn so activity elsewhere cannot cancel or revive its retry.
454
497
  let stopRequested = false;
498
+ let promptSubmitted = false;
499
+ let recoveredMissingSession = false;
500
+ // Wakes a retry backoff the moment Stop arrives, so the turn settles
501
+ // now rather than after the full wait.
502
+ const stopSignal = new AbortController();
455
503
  const { threadId } = turn;
456
504
  // Direct adapter callers predating the per-bot selector retain the
457
505
  // instance's legacy fullAuto setting. Harness turns always send an
@@ -472,30 +520,14 @@ export const CodexDriver = {
472
520
  const retryScale = Number(process.env.FAKE_CODEX_RETRY_SCALE ?? "1");
473
521
  const launchAttempt = async (attempt) => {
474
522
  const env = childEnv();
475
- const appServerArgs = ["app-server", ...codexLocalProviderArgs(env, turn.model)];
523
+ const appServerArgs = ["app-server", ...(config.managed ? managedCodexArgs(config.managed) : codexLocalProviderArgs(env, turn.model))];
476
524
  if (turn.integrations?.composio) {
477
525
  mountMcpServer(appServerArgs, env, "openmausbot_connectors", turn.integrations.composio);
478
526
  }
479
527
  if (turn.integrations?.agents) {
480
528
  mountMcpServer(appServerArgs, env, "agents", turn.integrations.agents);
481
529
  }
482
- if (turn.integrations?.computer) {
483
- const proxyEnv = computerProxyEnv(turn.integrations.computer);
484
- mountMcpServer(appServerArgs, env, "computer", {
485
- command: process.execPath,
486
- args: [SPAWNED_PROXIES.computer],
487
- env: {
488
- ELECTRON_RUN_AS_NODE: "1",
489
- OGB_BOX_ID: proxyEnv.OGB_BOX_ID ?? "",
490
- OGB_BOX_TOKEN: proxyEnv.OGB_BOX_TOKEN ?? "",
491
- // who-is-driving endpoint, so a person taking the wheel in the
492
- // panel pauses this bot's hands mid-turn
493
- OMB_CONTROL_URL: proxyEnv.OMB_CONTROL_URL ?? "",
494
- OMB_CONTROL_TOKEN: proxyEnv.OMB_CONTROL_TOKEN ?? "",
495
- },
496
- });
497
- }
498
- else if (turn.integrations?.localComputer) {
530
+ if (turn.integrations?.localComputer) {
499
531
  // The host daemon and isolated Local VM both arrive as a direct Cua
500
532
  // Driver stdio MCP server. Codex sees the same computer tool surface.
501
533
  mountMcpServer(appServerArgs, env, "computer", turn.integrations.localComputer);
@@ -527,9 +559,12 @@ export const CodexDriver = {
527
559
  lastError: "",
528
560
  lastText: "",
529
561
  sawStreamDelta: false,
530
- // codex reports token usage as a running THREAD total; the harness
531
- // wants this turn's figure, so the last report is banked on settle
562
+ // codex reports token usage as a running total for this app-server
563
+ // process. The harness wants this turn's figure: the total minus
564
+ // whatever the process already carried before turn/start (a resumed
565
+ // thread may restore earlier usage), banked on settle.
532
566
  usage: undefined,
567
+ usageBaseline: undefined,
533
568
  };
534
569
  const asks = new Map();
535
570
  let nextId = 1;
@@ -561,6 +596,18 @@ export const CodexDriver = {
561
596
  rpcPending.set(id, {
562
597
  resolve: (v) => {
563
598
  clearTimeout(timer);
599
+ if (method === "thread/start" || method === "thread/resume") {
600
+ // Notifications can follow the thread response in the same
601
+ // stdout chunk, before the handshake await resumes.
602
+ const returnedId = v?.thread?.id;
603
+ const requestedId = method === "thread/resume"
604
+ && params && typeof params === "object" && "threadId" in params
605
+ ? params.threadId : null;
606
+ if (typeof returnedId === "string" && returnedId)
607
+ codexThreadId = returnedId;
608
+ else if (typeof requestedId === "string" && requestedId)
609
+ codexThreadId = requestedId;
610
+ }
564
611
  if (method === "turn/start") {
565
612
  if (typeof v?.turn?.id !== "string" || !v.turn.id) {
566
613
  reject(new Error("Codex did not return a native turn id"));
@@ -594,6 +641,7 @@ export const CodexDriver = {
594
641
  let completeStoppedTurn;
595
642
  const stop = async () => {
596
643
  stopRequested = true;
644
+ stopSignal.abort();
597
645
  const stopped = await terminate();
598
646
  if (stopped)
599
647
  completeStoppedTurn?.();
@@ -744,6 +792,17 @@ export const CodexDriver = {
744
792
  if (!connectionError) {
745
793
  if (!codexThreadId || p.threadId !== codexThreadId)
746
794
  return;
795
+ if (!codexTurnId && msg.method === "thread/tokenUsage/updated" && p.tokenUsage?.total) {
796
+ // A total reported before this turn exists is what the process
797
+ // carried in — a resumed thread restoring earlier usage. It is the
798
+ // baseline this turn's figure is measured from, never a reading to
799
+ // buffer and replay as if this turn produced it. (Codex names the
800
+ // turn before any model call, so a genuine first reading cannot
801
+ // land here.)
802
+ const t = p.tokenUsage.total;
803
+ state.usageBaseline = { input: t.inputTokens ?? 0, output: t.outputTokens ?? 0, cachedInput: t.cachedInputTokens ?? 0 };
804
+ return;
805
+ }
747
806
  if (!codexTurnId) {
748
807
  // Some servers stream before acknowledging turn/start. Retain a
749
808
  // bounded prefix, then filter against the authoritative response.
@@ -849,32 +908,44 @@ export const CodexDriver = {
849
908
  break;
850
909
  }
851
910
  case "thread/tokenUsage/updated": {
852
- // `last` is the most recent turn when the server sends it;
853
- // `total` is the thread so far — a fresh app-server per turn
854
- // makes that this turn's figure too
855
- const turnUsage = p.tokenUsage?.last ?? p.tokenUsage?.total;
856
- // codex's inputTokens already includes cachedInputTokens; the
857
- // cached share is carried alongside so the UI can say how much
858
- // of a turn was context re-read rather than new text
859
- if (turnUsage) {
911
+ // `total` is everything this app-server process has used; `last`
912
+ // is the most recent model call. This turn's figure is the total
913
+ // minus what the process carried before turn/start went out (a
914
+ // resumed thread can restore earlier usage), so it never grows by
915
+ // the whole thread per message and never counts only the final
916
+ // call of a multi-step turn. codex's inputTokens already includes
917
+ // cachedInputTokens; the cached share rides alongside so the UI
918
+ // can say how much was context re-read rather than new text.
919
+ const t = p.tokenUsage?.total;
920
+ const last = p.tokenUsage?.last;
921
+ const shape = (u) => ({
922
+ input: u.inputTokens ?? 0, output: u.outputTokens ?? 0, cachedInput: u.cachedInputTokens ?? 0,
923
+ });
924
+ // (A total that arrived before this turn was named became the
925
+ // baseline upstream and never reaches this switch.)
926
+ if (t) {
927
+ const b = state.usageBaseline ?? { input: 0, output: 0, cachedInput: 0 };
928
+ const now = shape(t);
860
929
  state.usage = {
861
- input: turnUsage.inputTokens ?? 0,
862
- output: turnUsage.outputTokens ?? 0,
863
- ...(typeof turnUsage.cachedInputTokens === "number"
864
- ? { cachedInput: turnUsage.cachedInputTokens }
865
- : {}),
930
+ input: Math.max(0, now.input - b.input),
931
+ output: Math.max(0, now.output - b.output),
932
+ ...(typeof t.cachedInputTokens === "number" ? { cachedInput: Math.max(0, now.cachedInput - b.cachedInput) } : {}),
866
933
  };
867
934
  }
868
- const t = p.tokenUsage?.total;
935
+ else if (last) {
936
+ state.usage = { input: last.inputTokens ?? 0, output: last.outputTokens ?? 0, ...(typeof last.cachedInputTokens === "number" ? { cachedInput: last.cachedInputTokens } : {}) };
937
+ }
869
938
  if (t) {
939
+ const window = p.tokenUsage?.modelContextWindow;
870
940
  emit({
871
941
  ...base(threadId, turnId),
872
942
  type: "thread.token-usage.updated",
873
943
  input: t.inputTokens ?? 0,
874
944
  output: t.outputTokens ?? 0,
875
- ...(typeof t.cachedInputTokens === "number"
876
- ? { cachedInput: t.cachedInputTokens }
877
- : {}),
945
+ ...(typeof t.cachedInputTokens === "number" ? { cachedInput: t.cachedInputTokens } : {}),
946
+ // the last call's prompt is what fills the window
947
+ ...(last && typeof last.inputTokens === "number" ? { contextTokens: last.inputTokens } : {}),
948
+ ...(typeof window === "number" && window > 0 ? { contextWindow: window } : {}),
878
949
  });
879
950
  }
880
951
  break;
@@ -926,6 +997,7 @@ export const CodexDriver = {
926
997
  catch {
927
998
  continue;
928
999
  }
1000
+ stderrSinceOutput = "";
929
1001
  const loggedMessage = codexNativeIncomingLogMessage(msg, sensitiveResponseIds);
930
1002
  appendNative(threadId, { dir: "in", source: "codex.app-server", msg: loggedMessage });
931
1003
  if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
@@ -933,7 +1005,7 @@ export const CodexDriver = {
933
1005
  if (pend) {
934
1006
  rpcPending.delete(msg.id);
935
1007
  if (msg.error)
936
- pend.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
1008
+ pend.reject(new CodexRpcError(msg.error));
937
1009
  else
938
1010
  pend.resolve(msg.result);
939
1011
  }
@@ -947,10 +1019,20 @@ export const CodexDriver = {
947
1019
  }
948
1020
  });
949
1021
  let stderr = "";
1022
+ // Stderr that arrived after the last parsed protocol message. The
1023
+ // full buffer accumulates for the whole process lifetime, so its
1024
+ // tail can name a long-past event (a websocket 426 logged at turn
1025
+ // start, echoed half an hour later when something else kills the
1026
+ // process). Only this slice can explain an exit; older bytes are
1027
+ // context, not cause.
1028
+ let stderrSinceOutput = "";
950
1029
  child.stderr.on("data", (c) => {
951
1030
  stderr += c;
1031
+ stderrSinceOutput += c;
952
1032
  if (stderr.length > 8192)
953
1033
  stderr = stderr.slice(-8192);
1034
+ if (stderrSinceOutput.length > 2048)
1035
+ stderrSinceOutput = stderrSinceOutput.slice(-2048);
954
1036
  });
955
1037
  child.on("error", (e) => {
956
1038
  if (abandoned)
@@ -958,7 +1040,7 @@ export const CodexDriver = {
958
1040
  emit({ ...base(threadId, turnId), type: "runtime.error", ...describeSpawnFailure(e, config.cli) });
959
1041
  void settle(false, "spawn_error");
960
1042
  });
961
- child.on("close", (code) => {
1043
+ child.on("close", (code, signal) => {
962
1044
  if (abandoned)
963
1045
  return;
964
1046
  if (state.settled) {
@@ -967,14 +1049,65 @@ export const CodexDriver = {
967
1049
  void stop();
968
1050
  return;
969
1051
  }
970
- if (!state.settled) {
1052
+ // The child died before the turn completed. Attribute the exit
1053
+ // honestly: name the signal when it was killed, and only quote
1054
+ // stderr that arrived after the last protocol message. A stale
1055
+ // tail here once misattributed a whole day of killed turns to a
1056
+ // websocket 426 logged at turn start.
1057
+ const recentStderr = stderrSinceOutput.trim();
1058
+ const hadStreamedOutput = codexTurnId !== null || state.sawStreamDelta;
1059
+ // A signal exit is terminal no matter what the stderr says:
1060
+ // something killed the process (OOM, kill -9), and classifyError
1061
+ // cannot see the signal — with code null, transient-looking recent
1062
+ // stderr could still mark a killed attempt retryable.
1063
+ // Classification also reads only stderr received after the last
1064
+ // protocol output; the lifetime buffer's tail can name a
1065
+ // long-past event (the websocket-426 misattribution).
1066
+ const verdict = signal !== null
1067
+ ? { transient: false, reason: "interrupted" }
1068
+ : classifyError({ exitCode: code, stderr: recentStderr });
1069
+ // Safe re-dispatch: relaunch only when the app-server never
1070
+ // acknowledged turn/start — no native turn began, nothing was
1071
+ // streamed, so replaying the input cannot duplicate work. After
1072
+ // any acknowledgement (or any buffered pre-ack event) the turn
1073
+ // settles instead: a replay could re-run tools the user saw.
1074
+ if (!stopRequested && codexTurnId === null && earlyNotifications.length === 0 &&
1075
+ verdict.transient && attempt < RETRY_MAX_ATTEMPTS - 1) {
1076
+ const delayMs = computeBackoff(attempt);
1077
+ attempt++;
971
1078
  emit({
972
1079
  ...base(threadId, turnId),
973
- type: "runtime.error",
974
- message: `codex exited ${code} before turn/completed${stderr ? `: ${stderr.trim().slice(-300)}` : ""}`,
1080
+ type: "turn.retrying",
1081
+ attempt,
1082
+ delayMs,
1083
+ reason: verdict.reason,
975
1084
  });
976
- void settle(false, "exit_before_result");
1085
+ // Retire this attempt before anything async runs, so a late
1086
+ // rpc timer rejection in the handshake catch cannot relaunch
1087
+ // a second time on top of this one.
1088
+ abandoned = true;
1089
+ void (async () => {
1090
+ const alreadyDead = child.exitCode !== null || child.signalCode !== null;
1091
+ if (!alreadyDead && !(await terminate())) {
1092
+ void settle(false, "shutdown_timeout");
1093
+ return;
1094
+ }
1095
+ await interruptibleDelay(Math.max(1, Math.round(delayMs * retryScale)), stopSignal.signal).promise;
1096
+ if (!stopRequested) {
1097
+ void launchAttempt(attempt).catch(() => { });
1098
+ }
1099
+ else {
1100
+ await settle(false, "interrupted");
1101
+ }
1102
+ })().catch(() => { });
1103
+ return;
977
1104
  }
1105
+ emit({
1106
+ ...base(threadId, turnId),
1107
+ type: "runtime.error",
1108
+ message: `codex exited ${code}${signal ? ` (signal ${signal})` : ""} before turn/completed${recentStderr ? `: ${recentStderr.slice(-300)}` : hadStreamedOutput && stderr.trim() ? "; no stderr after the last app-server output" : ""}`,
1109
+ });
1110
+ void settle(false, "exit_before_result");
978
1111
  });
979
1112
  active.set(threadId, { stop, turnId, asks });
980
1113
  // Relaunching the app-server is still the same logical turn. Keep the
@@ -1030,35 +1163,48 @@ export const CodexDriver = {
1030
1163
  // Removed bot rules are cleared without dropping native configured rules.
1031
1164
  const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
1032
1165
  let startedModel = null;
1166
+ let resumedNativeThread = false;
1167
+ let promptText = turn.text;
1033
1168
  if (cursor) {
1169
+ const resumeThread = () => request("thread/resume", {
1170
+ threadId: cursor,
1171
+ developerInstructions,
1172
+ ...approvalParams.thread,
1173
+ });
1034
1174
  try {
1035
- const resumed = await request("thread/resume", {
1036
- threadId: cursor,
1037
- developerInstructions,
1038
- ...approvalParams.thread,
1039
- });
1175
+ let resumed;
1176
+ try {
1177
+ resumed = await resumeThread();
1178
+ }
1179
+ catch (error) {
1180
+ if (!approvalParams.fallback || !permissionProfileUnsupported(error))
1181
+ throw error;
1182
+ // Older servers may require the legacy permission selector, but
1183
+ // still resume the same native thread before any user submission.
1184
+ approvalParams = approvalParams.fallback;
1185
+ resumed = await resumeThread();
1186
+ }
1040
1187
  codexThreadId = resumed?.thread?.id ?? cursor;
1188
+ resumedNativeThread = true;
1041
1189
  }
1042
1190
  catch (error) {
1043
- if (approvalParams.fallback && permissionProfileUnsupported(error)) {
1044
- // A server can understand config/read before it understands the
1045
- // profile selector. Retry the same resume safely rather than
1046
- // losing the native thread or inheriting its previous mode.
1047
- approvalParams = approvalParams.fallback;
1048
- const resumed = await request("thread/resume", {
1049
- threadId: cursor,
1050
- developerInstructions,
1051
- ...approvalParams.thread,
1052
- });
1053
- codexThreadId = resumed?.thread?.id ?? cursor;
1054
- }
1055
- else {
1191
+ const failure = classifyResumeFailure({
1192
+ attempted: true,
1193
+ rejected: error instanceof CodexRpcError,
1194
+ promptSubmitted,
1195
+ producedOutput: state.sawStreamDelta,
1196
+ });
1197
+ if (!config.managed || recoveredMissingSession || stopRequested || state.settled ||
1198
+ !turn.recoveryText?.trim() || !missingNativeCodexThread(error, cursor) || !mayReplay(failure))
1056
1199
  throw error;
1057
- }
1200
+ // The prompt has never been submitted. Rebuild only missing Company
1201
+ // histories, once, through the same approved model/provider below.
1202
+ recoveredMissingSession = true;
1203
+ promptText = recoveryPromptFor({ recoveryText: turn.recoveryText, currentText: turn.text, failure }).text;
1058
1204
  }
1059
1205
  }
1060
1206
  if (!codexThreadId) {
1061
- const selection = decodeCodexSelection(turn.model);
1207
+ const selection = config.managed ? { model: turn.model, modelProvider: "openmaus_company" } : decodeCodexSelection(turn.model);
1062
1208
  const startThread = () => request("thread/start", {
1063
1209
  developerInstructions,
1064
1210
  cwd: turn.cwd ?? homedir(),
@@ -1082,14 +1228,14 @@ export const CodexDriver = {
1082
1228
  }
1083
1229
  if (!codexThreadId)
1084
1230
  throw new Error("Codex did not return a native thread id");
1085
- await syncCodexInstructions(threadId, codexThreadId, developerInstructions, Boolean(cursor), request);
1231
+ await syncCodexInstructions(threadId, codexThreadId, developerInstructions, resumedNativeThread, request);
1086
1232
  emit({ ...base(threadId, turnId), type: "session.started", sessionId: codexThreadId, model: startedModel ?? turn.model ?? null });
1087
- const promptText = turn.text;
1088
1233
  const turnInput = [
1089
1234
  ...(promptText ? [{ type: "text", text: promptText }] : []),
1090
1235
  ...(turn.images ?? []).map((image) => ({ type: "localImage", path: image.path })),
1091
1236
  ];
1092
1237
  const startTurn = () => {
1238
+ promptSubmitted = true;
1093
1239
  startingNativeTurn = true;
1094
1240
  return request("turn/start", {
1095
1241
  threadId: codexThreadId,
@@ -1122,7 +1268,10 @@ export const CodexDriver = {
1122
1268
  const message = e instanceof Error ? e.message : String(e);
1123
1269
  const needsAuth = /(?:\b401\b|unauthorized|missing bearer|authentication required)/i.test(message);
1124
1270
  const verdict = classifyError(failure);
1125
- if (!state.settled && !needsAuth && verdict.transient && attempt < RETRY_MAX_ATTEMPTS - 1 && state.sawStreamDelta === false) {
1271
+ // Three guards hold here: main's abandoned attempt never retries,
1272
+ // neither does a Company session already recovered once from canonical
1273
+ // history, and a Stop already asked for must not be undone by a relaunch.
1274
+ if (!state.settled && !abandoned && !recoveredMissingSession && !stopRequested && !needsAuth && verdict.transient && attempt < RETRY_MAX_ATTEMPTS - 1 && state.sawStreamDelta === false) {
1126
1275
  const delayMs = computeBackoff(attempt);
1127
1276
  attempt++;
1128
1277
  emit({
@@ -1139,10 +1288,7 @@ export const CodexDriver = {
1139
1288
  void settle(false, "shutdown_timeout");
1140
1289
  return;
1141
1290
  }
1142
- await new Promise((resolve) => {
1143
- const timer = setTimeout(resolve, Math.max(1, Math.round(delayMs * retryScale)));
1144
- timer.unref?.();
1145
- });
1291
+ await interruptibleDelay(Math.max(1, Math.round(delayMs * retryScale)), stopSignal.signal).promise;
1146
1292
  if (!stopRequested) {
1147
1293
  void launchAttempt(attempt).catch(() => { });
1148
1294
  }
@@ -1151,7 +1297,9 @@ export const CodexDriver = {
1151
1297
  }
1152
1298
  return;
1153
1299
  }
1154
- if (!state.settled) {
1300
+ // abandoned marks an attempt retired by a retry; its late rpc
1301
+ // timeouts must neither report a spurious error nor relaunch again
1302
+ if (!state.settled && !abandoned) {
1155
1303
  emit({
1156
1304
  ...base(threadId, turnId),
1157
1305
  type: "runtime.error",
@@ -1172,6 +1320,8 @@ export const CodexDriver = {
1172
1320
  });
1173
1321
  if (!version)
1174
1322
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
1323
+ if (config.managed)
1324
+ return { state: "available", version, authenticated: Boolean(input.environment.OPENMAUSBOT_COMPANY_API_KEY && input.environment.CODEX_HOME), billing: "metered" };
1175
1325
  const authenticated = await new Promise((resolve) => {
1176
1326
  execCli(config.cli, ["login", "status"], { timeout: 8000, env }, (err, stdout, stderr) => resolve(!err && /^logged in\b/im.test(`${stdout}\n${stderr ?? ""}`)));
1177
1327
  });
@@ -11,7 +11,10 @@ const MODELS = {
11
11
  };
12
12
  function decodeConfig(raw) {
13
13
  const config = (raw ?? {});
14
+ if (config.tools !== undefined && typeof config.tools !== "boolean")
15
+ throw new Error("tools must be a boolean");
14
16
  return {
17
+ ...(config.tools !== undefined ? { tools: config.tools } : {}),
15
18
  url: typeof config.url === "string" ? config.url : DEFAULT_URL,
16
19
  apiKeyEnv: typeof config.apiKeyEnv === "string" ? config.apiKeyEnv : "XAI_API_KEY",
17
20
  };
@@ -30,6 +33,7 @@ export const GrokDriver = {
30
33
  driverKind: DRIVER_KIND,
31
34
  apiKey,
32
35
  apiUrl: config.url,
36
+ tools: config.tools,
33
37
  models: () => MODELS,
34
38
  requestBody: (model, messages, stream) => ({ model, messages, stream }),
35
39
  httpErrorLabel: "xAI",
@@ -11,10 +11,12 @@ const DEFAULT_URL = "https://api.minimax.io/v1";
11
11
  const CN_URL = "https://api.minimaxi.com/v1";
12
12
  const MODELS = {
13
13
  default: "MiniMax-M3",
14
+ // `custom: true` because the driver is access "custom": the picker opens
15
+ // custom-access engines in the pane that lists only custom-flagged models.
14
16
  options: [
15
- { id: "MiniMax-M3", label: "MiniMax M3", contextWindow: 1_000_000 },
16
- { id: "MiniMax-M2.7", label: "MiniMax M2.7", contextWindow: 204_800 },
17
- { id: "MiniMax-M2.7-highspeed", label: "MiniMax M2.7 Highspeed", contextWindow: 204_800 },
17
+ { id: "MiniMax-M3", label: "MiniMax M3", contextWindow: 1_000_000, custom: true },
18
+ { id: "MiniMax-M2.7", label: "MiniMax M2.7", contextWindow: 204_800, custom: true },
19
+ { id: "MiniMax-M2.7-highspeed", label: "MiniMax M2.7 Highspeed", contextWindow: 204_800, custom: true },
18
20
  ],
19
21
  };
20
22
  const localConfigSchema = z.object({
@@ -45,9 +47,13 @@ export function loadLocalMiniMaxConfig(home = homedir()) {
45
47
  }
46
48
  }
47
49
  export function decodeMinimaxConfig(raw) {
50
+ const tools = (raw ?? {}).tools;
51
+ if (tools !== undefined && typeof tools !== "boolean")
52
+ throw new Error("tools must be a boolean");
48
53
  const parsed = driverConfigSchema.safeParse(raw ?? {});
49
54
  const config = parsed.success ? parsed.data : {};
50
55
  return {
56
+ ...(tools !== undefined ? { tools } : {}),
51
57
  url: normalizedApiUrl(config.url?.trim() || process.env.MINIMAX_BASE_URL?.trim() || DEFAULT_URL),
52
58
  };
53
59
  }
@@ -83,6 +89,7 @@ export const MinimaxDriver = {
83
89
  driverKind: DRIVER_KIND,
84
90
  apiKey,
85
91
  apiUrl,
92
+ tools: input.config.tools,
86
93
  models: () => models,
87
94
  requestBody: (model, messages, stream) => ({
88
95
  model,
@@ -95,6 +102,7 @@ export const MinimaxDriver = {
95
102
  missingKeyError: `no MiniMax key — set ${API_KEY_ENV} or run mmx auth login --api-key …`,
96
103
  unavailableReason: `no MiniMax API key — run mmx auth login --api-key … or set ${API_KEY_ENV}`,
97
104
  timeoutMs: 180_000,
105
+ reasoning: true,
98
106
  billing: "metered",
99
107
  includeUsageInCompleted: true,
100
108
  noBodyError: "MiniMax returned no response body",