openmausbot 0.1.79 → 0.1.81

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 (60) hide show
  1. package/README.md +6 -5
  2. package/dist/assets/index-CKysBq-V.css +1 -0
  3. package/dist/assets/{index-C2Je5jdF.js → index-oLMljotU.js} +1 -1
  4. package/dist/assets/index-peLFiSH8.js +307 -0
  5. package/dist/index.html +2 -2
  6. package/dist-server/container-mcp.js +41 -10
  7. package/dist-server/drivers/agents-proxy.js +71 -17
  8. package/dist-server/index.js +4104 -1505
  9. package/dist-server/openmausbot.js +554 -163
  10. package/dist-server/pair-cli.js +554 -163
  11. package/dist-server/server/box-delete-journal.js +471 -0
  12. package/dist-server/server/box.js +345 -75
  13. package/dist-server/server/cli-phone-setup.js +2 -2
  14. package/dist-server/server/cli.js +84 -12
  15. package/dist-server/server/config.js +48 -18
  16. package/dist-server/server/container-computer.js +7 -0
  17. package/dist-server/server/contracts.js +5 -0
  18. package/dist-server/server/default-model-selection.js +1 -0
  19. package/dist-server/server/drivers/acp/core.js +112 -18
  20. package/dist-server/server/drivers/acp/opencode-go.js +6 -0
  21. package/dist-server/server/drivers/agents-proxy.js +76 -13
  22. package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
  23. package/dist-server/server/drivers/claude.js +87 -63
  24. package/dist-server/server/drivers/codex.js +248 -51
  25. package/dist-server/server/drivers/native.js +6 -1
  26. package/dist-server/server/harness/bus.js +4 -0
  27. package/dist-server/server/harness/registry.js +3 -2
  28. package/dist-server/server/index.js +1007 -222
  29. package/dist-server/server/local-vm-lease.js +4 -0
  30. package/dist-server/server/managed-desktop.js +205 -0
  31. package/dist-server/server/mcp-http.js +337 -0
  32. package/dist-server/server/mcp-probe.js +67 -18
  33. package/dist-server/server/mcp-registry.js +153 -58
  34. package/dist-server/server/member-turn.js +13 -0
  35. package/dist-server/server/message-db.js +74 -8
  36. package/dist-server/server/model-context-window.js +20 -0
  37. package/dist-server/server/recall-disclosure.js +4 -0
  38. package/dist-server/server/recent-work.js +142 -0
  39. package/dist-server/server/redact.js +1 -0
  40. package/dist-server/server/request-auth.js +3 -1
  41. package/dist-server/server/room-handoffs.js +16 -6
  42. package/dist-server/server/routines.js +17 -0
  43. package/dist-server/server/screen-frame-gate.js +4 -94
  44. package/dist-server/server/sessions.js +22 -3
  45. package/dist-server/server/store.js +29 -2
  46. package/dist-server/server/surface.js +41 -19
  47. package/dist-server/server/team-setup-requests.js +1 -1
  48. package/dist-server/server/thread-log-rotation.js +104 -0
  49. package/dist-server/server/tts/fish.js +155 -0
  50. package/dist-server/server/tts/index.js +25 -3
  51. package/dist-server/server/vps-computer.js +26 -4
  52. package/dist-server/server/vps-ssh.js +88 -0
  53. package/dist-server/server/workspace-backup.js +11 -0
  54. package/dist-server/server/workspace.js +2 -1
  55. package/dist-server/shared/credential-request.js +10 -0
  56. package/dist-server/shared/tool-surface.js +62 -0
  57. package/dist-server/vps-container-mcp.js +41 -10
  58. package/package.json +1 -1
  59. package/dist/assets/index-D_TFZLlo.js +0 -305
  60. package/dist/assets/index-Pbb6Ao0s.css +0 -1
@@ -24,10 +24,25 @@ import { commandSummary, toolDetailPreview } from "../tool-summary.js";
24
24
  import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instructions.js";
25
25
  import { CodexDeviceAuthController } from "./codex-device-auth.js";
26
26
  import { codexAccountEmail } from "./codex-identity.js";
27
+ import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
27
28
  export { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.js";
28
29
  const DRIVER_KIND = "codex";
29
30
  const ASTRA_MODEL_ID = "gpt-6-astra";
30
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
+ }
31
46
  /** Whether an installed Codex predates the release that exposes GPT-6 Astra
32
47
  * through app-server. Unknown version formats stay quiet: a bad guess should
33
48
  * never nag someone whose custom build may already support the model. */
@@ -79,10 +94,50 @@ function decodeConfig(raw) {
79
94
  return {
80
95
  cli: typeof o.cli === "string" ? o.cli : "codex",
81
96
  fullAuto: o.fullAuto === true,
97
+ ...(o.managed && typeof o.managed === "object" ? { managed: decodeManagedCodex(o.managed) } : {}),
82
98
  };
83
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
+ // Plain HTTP leaks the Company API key; allow it only on loopback hosts,
107
+ // where a local proxy terminates TLS on the trusted machine instead.
108
+ const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
109
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)))
110
+ throw new Error("Invalid Company Codex endpoint.");
111
+ return { url: url.href.replace(/\/$/, ""), models: value.models };
112
+ }
113
+ export function managedCodexArgs(config) {
114
+ // Credential stays in the instance environment, never argv or config.toml.
115
+ // https://learn.chatgpt.com/docs/config-file/config-reference
116
+ return [
117
+ "-c", 'model_provider="openmaus_company"',
118
+ "-c", 'model_providers.openmaus_company.name="Company"',
119
+ "-c", `model_providers.openmaus_company.base_url=${JSON.stringify(config.url)}`,
120
+ "-c", 'model_providers.openmaus_company.env_key="OPENMAUSBOT_COMPANY_API_KEY"',
121
+ "-c", 'model_providers.openmaus_company.wire_api="responses"',
122
+ "-c", "model_providers.openmaus_company.requires_openai_auth=false",
123
+ "-c", 'cli_auth_credentials_store="ephemeral"',
124
+ "-c", "shell_environment_policy.ignore_default_excludes=false",
125
+ ];
126
+ }
84
127
  const QUESTION_TIMEOUT_NOTE = "No answer was given — use your best judgment.";
85
128
  const DENY_TIMEOUT_NOTE = "OpenMausBot: nobody answered this permission request in time. Skip this action and finish what you can without it.";
129
+ const skippedSseServers = new Set();
130
+ function noteSkippedSseServer(name) {
131
+ if (skippedSseServers.has(name))
132
+ return;
133
+ skippedSseServers.add(name);
134
+ console.error(`codex: MCP server ${JSON.stringify(name)} uses the SSE transport, which codex does not speak — it is available to Claude bots only`);
135
+ }
136
+ /** A TOML inline table for a `-c key=value` override; JSON string quoting
137
+ * is valid TOML basic-string quoting. */
138
+ function tomlInlineTable(entries) {
139
+ return `{ ${Object.entries(entries).map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`).join(", ")} }`;
140
+ }
86
141
  /** RequestPermissionProfile uses null for permission families that were not
87
142
  * requested; GrantedPermissionProfile requires those keys to be absent. */
88
143
  function grantedPermissions(raw) {
@@ -370,12 +425,39 @@ export function codexNativeIncomingLogMessage(message, sensitiveResponseIds) {
370
425
  return message;
371
426
  }
372
427
  function mountMcpServer(appServerArgs, env, name, server, preApproved = true) {
373
- Object.assign(env, server.env);
374
428
  const prefix = `mcp_servers.${name}`;
375
- appServerArgs.push("-c", `${prefix}.command=${JSON.stringify(server.command)}`, "-c", `${prefix}.args=${JSON.stringify(server.args)}`,
376
- // Values stay in the child environment; argv contains names only so
377
- // credentials never appear in process listings or diagnostics.
378
- "-c", `${prefix}.env_vars=${JSON.stringify(Object.keys(server.env))}`);
429
+ if ("url" in server) {
430
+ // A remote server: codex connects itself. Header values are credentials
431
+ // (Authorization: Bearer …) and travel like env values — the child env
432
+ // holds them under harness-generated names, argv names only the variables.
433
+ // A bearer token goes through codex's own bearer setting, the path its
434
+ // remote servers are documented and exercised with; any other header
435
+ // rides env_http_headers.
436
+ appServerArgs.push("-c", `${prefix}.url=${JSON.stringify(server.url)}`);
437
+ const stem = `OMB_MCP_HEADER_${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
438
+ const variables = {};
439
+ Object.entries(server.headers).forEach(([header, value], index) => {
440
+ const bearer = header.toLowerCase() === "authorization" ? /^Bearer\s+(\S+)$/i.exec(value) : null;
441
+ if (bearer) {
442
+ env[`${stem}_BEARER`] = bearer[1];
443
+ appServerArgs.push("-c", `${prefix}.bearer_token_env_var=${JSON.stringify(`${stem}_BEARER`)}`);
444
+ return;
445
+ }
446
+ const variable = `${stem}_${index}`;
447
+ env[variable] = value;
448
+ variables[header] = variable;
449
+ });
450
+ if (Object.keys(variables).length) {
451
+ appServerArgs.push("-c", `${prefix}.env_http_headers=${tomlInlineTable(variables)}`);
452
+ }
453
+ }
454
+ else {
455
+ Object.assign(env, server.env);
456
+ appServerArgs.push("-c", `${prefix}.command=${JSON.stringify(server.command)}`, "-c", `${prefix}.args=${JSON.stringify(server.args)}`,
457
+ // Values stay in the child environment; argv contains names only so
458
+ // credentials never appear in process listings or diagnostics.
459
+ "-c", `${prefix}.env_vars=${JSON.stringify(Object.keys(server.env))}`);
460
+ }
379
461
  // Harness-owned servers are pre-quieted; a user-configured server keeps
380
462
  // codex's on-request policy so its tool calls become approval cards.
381
463
  if (preApproved) {
@@ -416,8 +498,10 @@ export const CodexDriver = {
416
498
  return env;
417
499
  };
418
500
  const catalogEnv = childEnv();
419
- let models = STATIC_CODEX_MODELS;
501
+ let models = config.managed ? { default: config.managed.models[0], options: config.managed.models.map(id => ({ id, label: id })) } : STATIC_CODEX_MODELS;
420
502
  const refreshModels = async () => {
503
+ if (config.managed)
504
+ return;
421
505
  try {
422
506
  const resolved = await readCodexModelCatalog(catalogEnv, fetch, config.cli);
423
507
  if (resolved.options.length)
@@ -447,9 +531,27 @@ export const CodexDriver = {
447
531
  createdAt: new Date().toISOString(),
448
532
  });
449
533
  const sendTurn = async (turn) => {
534
+ if (config.managed) {
535
+ // One blanket refusal hides which prerequisite broke; name it so the
536
+ // person can fix the actual gap instead of reconnecting blind.
537
+ if (!turn.model) {
538
+ throw new Error("Company model access is unavailable: no model is selected. Reconnect your organization; personal billing will not be used.");
539
+ }
540
+ if (!config.managed.models.includes(turn.model)) {
541
+ throw new Error("Company model access is unavailable: " + turn.model + " is not approved for your organization. Reconnect your organization; personal billing will not be used.");
542
+ }
543
+ if (!input.environment.OPENMAUSBOT_COMPANY_API_KEY) {
544
+ throw new Error("Company model access is unavailable: OPENMAUSBOT_COMPANY_API_KEY is missing. Reconnect your organization; personal billing will not be used.");
545
+ }
546
+ if (!input.environment.CODEX_HOME) {
547
+ throw new Error("Company model access is unavailable: CODEX_HOME is missing. Reconnect your organization; personal billing will not be used.");
548
+ }
549
+ }
450
550
  // One driver instance serves many threads. Interrupt state belongs to
451
551
  // this turn so activity elsewhere cannot cancel or revive its retry.
452
552
  let stopRequested = false;
553
+ let promptSubmitted = false;
554
+ let recoveredMissingSession = false;
453
555
  // Wakes a retry backoff the moment Stop arrives, so the turn settles
454
556
  // now rather than after the full wait.
455
557
  const stopSignal = new AbortController();
@@ -459,6 +561,8 @@ export const CodexDriver = {
459
561
  // explicit mode, which takes precedence.
460
562
  const approvalMode = turn.approvalMode ?? (config.fullAuto ? "full" : "ask");
461
563
  for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
564
+ if ("url" in server)
565
+ continue;
462
566
  const reserved = Object.keys(server.env).find(isHarnessOwnedMcpEnvName);
463
567
  if (reserved) {
464
568
  throw new Error(`Custom MCP server “${name}” cannot set reserved environment variable “${reserved}”`);
@@ -473,7 +577,7 @@ export const CodexDriver = {
473
577
  const retryScale = Number(process.env.FAKE_CODEX_RETRY_SCALE ?? "1");
474
578
  const launchAttempt = async (attempt) => {
475
579
  const env = childEnv();
476
- const appServerArgs = ["app-server", ...codexLocalProviderArgs(env, turn.model)];
580
+ const appServerArgs = ["app-server", ...(config.managed ? managedCodexArgs(config.managed) : codexLocalProviderArgs(env, turn.model))];
477
581
  if (turn.integrations?.composio) {
478
582
  mountMcpServer(appServerArgs, env, "openmausbot_connectors", turn.integrations.composio);
479
583
  }
@@ -489,6 +593,13 @@ export const CodexDriver = {
489
593
  mountMcpServer(appServerArgs, env, "browser", turn.integrations.browser);
490
594
  }
491
595
  for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
596
+ // codex speaks streamable HTTP to a remote server, not the older
597
+ // SSE transport: such an entry still reaches Claude bots, and is
598
+ // left out here rather than mounted as something it is not
599
+ if ("url" in server && server.type === "sse") {
600
+ noteSkippedSseServer(name);
601
+ continue;
602
+ }
492
603
  mountMcpServer(appServerArgs, env, name, server, false);
493
604
  }
494
605
  if (turn.integrations?.phone) {
@@ -512,9 +623,12 @@ export const CodexDriver = {
512
623
  lastError: "",
513
624
  lastText: "",
514
625
  sawStreamDelta: false,
515
- // codex reports token usage as a running THREAD total; the harness
516
- // wants this turn's figure, so the last report is banked on settle
626
+ // codex reports token usage as a running total for this app-server
627
+ // process. The harness wants this turn's figure: the total minus
628
+ // whatever the process already carried before turn/start (a resumed
629
+ // thread may restore earlier usage), banked on settle.
517
630
  usage: undefined,
631
+ usageBaseline: undefined,
518
632
  };
519
633
  const asks = new Map();
520
634
  let nextId = 1;
@@ -546,6 +660,18 @@ export const CodexDriver = {
546
660
  rpcPending.set(id, {
547
661
  resolve: (v) => {
548
662
  clearTimeout(timer);
663
+ if (method === "thread/start" || method === "thread/resume") {
664
+ // Notifications can follow the thread response in the same
665
+ // stdout chunk, before the handshake await resumes.
666
+ const returnedId = v?.thread?.id;
667
+ const requestedId = method === "thread/resume"
668
+ && params && typeof params === "object" && "threadId" in params
669
+ ? params.threadId : null;
670
+ if (typeof returnedId === "string" && returnedId)
671
+ codexThreadId = returnedId;
672
+ else if (typeof requestedId === "string" && requestedId)
673
+ codexThreadId = requestedId;
674
+ }
549
675
  if (method === "turn/start") {
550
676
  if (typeof v?.turn?.id !== "string" || !v.turn.id) {
551
677
  reject(new Error("Codex did not return a native turn id"));
@@ -641,6 +767,27 @@ export const CodexDriver = {
641
767
  }
642
768
  return;
643
769
  }
770
+ // One ask card carries one question honestly: its choices would come
771
+ // from the first question alone and its one reply (including the
772
+ // timeout note) would be copied into every question id (#1237).
773
+ // Refuse the bundled call with a teaching error instead of
774
+ // fabricating per-question answers.
775
+ if (isQuestion && (!Array.isArray(params.questions) || params.questions.length !== 1)) {
776
+ const bundled = Array.isArray(params.questions) && params.questions.length > 1;
777
+ send({
778
+ jsonrpc: "2.0",
779
+ id: msg.id,
780
+ error: {
781
+ code: -32602,
782
+ message: bundled
783
+ ? `ask supports one question per call; this request bundled ${params.questions.length}. Split it into separate asks, one question each.`
784
+ : Array.isArray(params.questions)
785
+ ? "ask supports one question per call; this request sent none."
786
+ : "ask supports one question per call; params.questions must be an array with exactly one question.",
787
+ },
788
+ });
789
+ return;
790
+ }
644
791
  const mcpTool = isLegacyMcpPermission
645
792
  ? String(params.message ?? "").match(/tool "([^"]+)"/)?.[1]
646
793
  : undefined;
@@ -730,6 +877,17 @@ export const CodexDriver = {
730
877
  if (!connectionError) {
731
878
  if (!codexThreadId || p.threadId !== codexThreadId)
732
879
  return;
880
+ if (!codexTurnId && msg.method === "thread/tokenUsage/updated" && p.tokenUsage?.total) {
881
+ // A total reported before this turn exists is what the process
882
+ // carried in — a resumed thread restoring earlier usage. It is the
883
+ // baseline this turn's figure is measured from, never a reading to
884
+ // buffer and replay as if this turn produced it. (Codex names the
885
+ // turn before any model call, so a genuine first reading cannot
886
+ // land here.)
887
+ const t = p.tokenUsage.total;
888
+ state.usageBaseline = { input: t.inputTokens ?? 0, output: t.outputTokens ?? 0, cachedInput: t.cachedInputTokens ?? 0 };
889
+ return;
890
+ }
733
891
  if (!codexTurnId) {
734
892
  // Some servers stream before acknowledging turn/start. Retain a
735
893
  // bounded prefix, then filter against the authoritative response.
@@ -835,32 +993,44 @@ export const CodexDriver = {
835
993
  break;
836
994
  }
837
995
  case "thread/tokenUsage/updated": {
838
- // `last` is the most recent turn when the server sends it;
839
- // `total` is the thread so far — a fresh app-server per turn
840
- // makes that this turn's figure too
841
- const turnUsage = p.tokenUsage?.last ?? p.tokenUsage?.total;
842
- // codex's inputTokens already includes cachedInputTokens; the
843
- // cached share is carried alongside so the UI can say how much
844
- // of a turn was context re-read rather than new text
845
- if (turnUsage) {
996
+ // `total` is everything this app-server process has used; `last`
997
+ // is the most recent model call. This turn's figure is the total
998
+ // minus what the process carried before turn/start went out (a
999
+ // resumed thread can restore earlier usage), so it never grows by
1000
+ // the whole thread per message and never counts only the final
1001
+ // call of a multi-step turn. codex's inputTokens already includes
1002
+ // cachedInputTokens; the cached share rides alongside so the UI
1003
+ // can say how much was context re-read rather than new text.
1004
+ const t = p.tokenUsage?.total;
1005
+ const last = p.tokenUsage?.last;
1006
+ const shape = (u) => ({
1007
+ input: u.inputTokens ?? 0, output: u.outputTokens ?? 0, cachedInput: u.cachedInputTokens ?? 0,
1008
+ });
1009
+ // (A total that arrived before this turn was named became the
1010
+ // baseline upstream and never reaches this switch.)
1011
+ if (t) {
1012
+ const b = state.usageBaseline ?? { input: 0, output: 0, cachedInput: 0 };
1013
+ const now = shape(t);
846
1014
  state.usage = {
847
- input: turnUsage.inputTokens ?? 0,
848
- output: turnUsage.outputTokens ?? 0,
849
- ...(typeof turnUsage.cachedInputTokens === "number"
850
- ? { cachedInput: turnUsage.cachedInputTokens }
851
- : {}),
1015
+ input: Math.max(0, now.input - b.input),
1016
+ output: Math.max(0, now.output - b.output),
1017
+ ...(typeof t.cachedInputTokens === "number" ? { cachedInput: Math.max(0, now.cachedInput - b.cachedInput) } : {}),
852
1018
  };
853
1019
  }
854
- const t = p.tokenUsage?.total;
1020
+ else if (last) {
1021
+ state.usage = { input: last.inputTokens ?? 0, output: last.outputTokens ?? 0, ...(typeof last.cachedInputTokens === "number" ? { cachedInput: last.cachedInputTokens } : {}) };
1022
+ }
855
1023
  if (t) {
1024
+ const window = p.tokenUsage?.modelContextWindow;
856
1025
  emit({
857
1026
  ...base(threadId, turnId),
858
1027
  type: "thread.token-usage.updated",
859
1028
  input: t.inputTokens ?? 0,
860
1029
  output: t.outputTokens ?? 0,
861
- ...(typeof t.cachedInputTokens === "number"
862
- ? { cachedInput: t.cachedInputTokens }
863
- : {}),
1030
+ ...(typeof t.cachedInputTokens === "number" ? { cachedInput: t.cachedInputTokens } : {}),
1031
+ // the last call's prompt is what fills the window
1032
+ ...(last && typeof last.inputTokens === "number" ? { contextTokens: last.inputTokens } : {}),
1033
+ ...(typeof window === "number" && window > 0 ? { contextWindow: window } : {}),
864
1034
  });
865
1035
  }
866
1036
  break;
@@ -920,7 +1090,7 @@ export const CodexDriver = {
920
1090
  if (pend) {
921
1091
  rpcPending.delete(msg.id);
922
1092
  if (msg.error)
923
- pend.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
1093
+ pend.reject(new CodexRpcError(msg.error));
924
1094
  else
925
1095
  pend.resolve(msg.result);
926
1096
  }
@@ -971,7 +1141,16 @@ export const CodexDriver = {
971
1141
  // websocket 426 logged at turn start.
972
1142
  const recentStderr = stderrSinceOutput.trim();
973
1143
  const hadStreamedOutput = codexTurnId !== null || state.sawStreamDelta;
974
- const verdict = classifyError({ exitCode: code, stderr: recentStderr || stderr });
1144
+ // A signal exit is terminal no matter what the stderr says:
1145
+ // something killed the process (OOM, kill -9), and classifyError
1146
+ // cannot see the signal — with code null, transient-looking recent
1147
+ // stderr could still mark a killed attempt retryable.
1148
+ // Classification also reads only stderr received after the last
1149
+ // protocol output; the lifetime buffer's tail can name a
1150
+ // long-past event (the websocket-426 misattribution).
1151
+ const verdict = signal !== null
1152
+ ? { transient: false, reason: "interrupted" }
1153
+ : classifyError({ exitCode: code, stderr: recentStderr });
975
1154
  // Safe re-dispatch: relaunch only when the app-server never
976
1155
  // acknowledged turn/start — no native turn began, nothing was
977
1156
  // streamed, so replaying the input cannot duplicate work. After
@@ -1069,35 +1248,48 @@ export const CodexDriver = {
1069
1248
  // Removed bot rules are cleared without dropping native configured rules.
1070
1249
  const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
1071
1250
  let startedModel = null;
1251
+ let resumedNativeThread = false;
1252
+ let promptText = turn.text;
1072
1253
  if (cursor) {
1254
+ const resumeThread = () => request("thread/resume", {
1255
+ threadId: cursor,
1256
+ developerInstructions,
1257
+ ...approvalParams.thread,
1258
+ });
1073
1259
  try {
1074
- const resumed = await request("thread/resume", {
1075
- threadId: cursor,
1076
- developerInstructions,
1077
- ...approvalParams.thread,
1078
- });
1260
+ let resumed;
1261
+ try {
1262
+ resumed = await resumeThread();
1263
+ }
1264
+ catch (error) {
1265
+ if (!approvalParams.fallback || !permissionProfileUnsupported(error))
1266
+ throw error;
1267
+ // Older servers may require the legacy permission selector, but
1268
+ // still resume the same native thread before any user submission.
1269
+ approvalParams = approvalParams.fallback;
1270
+ resumed = await resumeThread();
1271
+ }
1079
1272
  codexThreadId = resumed?.thread?.id ?? cursor;
1273
+ resumedNativeThread = true;
1080
1274
  }
1081
1275
  catch (error) {
1082
- if (approvalParams.fallback && permissionProfileUnsupported(error)) {
1083
- // A server can understand config/read before it understands the
1084
- // profile selector. Retry the same resume safely rather than
1085
- // losing the native thread or inheriting its previous mode.
1086
- approvalParams = approvalParams.fallback;
1087
- const resumed = await request("thread/resume", {
1088
- threadId: cursor,
1089
- developerInstructions,
1090
- ...approvalParams.thread,
1091
- });
1092
- codexThreadId = resumed?.thread?.id ?? cursor;
1093
- }
1094
- else {
1276
+ const failure = classifyResumeFailure({
1277
+ attempted: true,
1278
+ rejected: error instanceof CodexRpcError,
1279
+ promptSubmitted,
1280
+ producedOutput: state.sawStreamDelta,
1281
+ });
1282
+ if (!config.managed || recoveredMissingSession || stopRequested || state.settled ||
1283
+ !turn.recoveryText?.trim() || !missingNativeCodexThread(error, cursor) || !mayReplay(failure))
1095
1284
  throw error;
1096
- }
1285
+ // The prompt has never been submitted. Rebuild only missing Company
1286
+ // histories, once, through the same approved model/provider below.
1287
+ recoveredMissingSession = true;
1288
+ promptText = recoveryPromptFor({ recoveryText: turn.recoveryText, currentText: turn.text, failure }).text;
1097
1289
  }
1098
1290
  }
1099
1291
  if (!codexThreadId) {
1100
- const selection = decodeCodexSelection(turn.model);
1292
+ const selection = config.managed ? { model: turn.model, modelProvider: "openmaus_company" } : decodeCodexSelection(turn.model);
1101
1293
  const startThread = () => request("thread/start", {
1102
1294
  developerInstructions,
1103
1295
  cwd: turn.cwd ?? homedir(),
@@ -1121,14 +1313,14 @@ export const CodexDriver = {
1121
1313
  }
1122
1314
  if (!codexThreadId)
1123
1315
  throw new Error("Codex did not return a native thread id");
1124
- await syncCodexInstructions(threadId, codexThreadId, developerInstructions, Boolean(cursor), request);
1316
+ await syncCodexInstructions(threadId, codexThreadId, developerInstructions, resumedNativeThread, request);
1125
1317
  emit({ ...base(threadId, turnId), type: "session.started", sessionId: codexThreadId, model: startedModel ?? turn.model ?? null });
1126
- const promptText = turn.text;
1127
1318
  const turnInput = [
1128
1319
  ...(promptText ? [{ type: "text", text: promptText }] : []),
1129
1320
  ...(turn.images ?? []).map((image) => ({ type: "localImage", path: image.path })),
1130
1321
  ];
1131
1322
  const startTurn = () => {
1323
+ promptSubmitted = true;
1132
1324
  startingNativeTurn = true;
1133
1325
  return request("turn/start", {
1134
1326
  threadId: codexThreadId,
@@ -1161,7 +1353,10 @@ export const CodexDriver = {
1161
1353
  const message = e instanceof Error ? e.message : String(e);
1162
1354
  const needsAuth = /(?:\b401\b|unauthorized|missing bearer|authentication required)/i.test(message);
1163
1355
  const verdict = classifyError(failure);
1164
- if (!state.settled && !abandoned && !needsAuth && verdict.transient && attempt < RETRY_MAX_ATTEMPTS - 1 && state.sawStreamDelta === false) {
1356
+ // Three guards hold here: main's abandoned attempt never retries,
1357
+ // neither does a Company session already recovered once from canonical
1358
+ // history, and a Stop already asked for must not be undone by a relaunch.
1359
+ if (!state.settled && !abandoned && !recoveredMissingSession && !stopRequested && !needsAuth && verdict.transient && attempt < RETRY_MAX_ATTEMPTS - 1 && state.sawStreamDelta === false) {
1165
1360
  const delayMs = computeBackoff(attempt);
1166
1361
  attempt++;
1167
1362
  emit({
@@ -1210,6 +1405,8 @@ export const CodexDriver = {
1210
1405
  });
1211
1406
  if (!version)
1212
1407
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
1408
+ if (config.managed)
1409
+ return { state: "available", version, authenticated: Boolean(input.environment.OPENMAUSBOT_COMPANY_API_KEY && input.environment.CODEX_HOME), billing: "metered" };
1213
1410
  const authenticated = await new Promise((resolve) => {
1214
1411
  execCli(config.cli, ["login", "status"], { timeout: 8000, env }, (err, stdout, stderr) => resolve(!err && /^logged in\b/im.test(`${stdout}\n${stderr ?? ""}`)));
1215
1412
  });
@@ -6,14 +6,19 @@ import { appendFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { NATIVE_DIR } from "../config.js";
8
8
  import { redactSecrets } from "../redact.js";
9
+ import { capThreadLog, currentThreadLogCap } from "../thread-log-rotation.js";
9
10
  export function appendNative(threadId, entry) {
11
+ const file = join(NATIVE_DIR, `${threadId}.ndjson`);
10
12
  try {
11
13
  // The session-setup messages carry the credentials the agent is handed —
12
14
  // the box and comms tokens ride inside session/new's mcpServers env, and
13
15
  // an MCP header can carry a Composio key. These files are ordinary
14
16
  // 0644 files people paste into bug reports, so values are masked while
15
17
  // the shape stays intact.
16
- appendFileSync(join(NATIVE_DIR, `${threadId}.ndjson`), JSON.stringify({ at: new Date().toISOString(), ...entry, msg: redactSecrets(entry.msg) }) + "\n", { mode: 0o600 });
18
+ appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry, msg: redactSecrets(entry.msg) }) + "\n", { mode: 0o600 });
19
+ // Best-effort size cap (#1280) — same rule as the write itself: never
20
+ // let logging break the run it is observing.
21
+ capThreadLog(file, currentThreadLogCap());
17
22
  }
18
23
  catch {
19
24
  /* never let logging break a run */
@@ -8,6 +8,7 @@ import { appendFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { EVENTS_DIR } from "../config.js";
10
10
  import { redactSecrets } from "../redact.js";
11
+ import { capThreadLog, currentThreadLogCap } from "../thread-log-rotation.js";
11
12
  import { newId } from "../contracts.js";
12
13
  const INCOMPLETE_LOG_MESSAGE = "Canonical event history is incomplete: OpenMausBot could not write one or more events to disk. Live updates will continue.";
13
14
  export class EventBus {
@@ -43,6 +44,9 @@ export class EventBus {
43
44
  this.appendLog(join(EVENTS_DIR, `${event.threadId}.ndjson`), persistedEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", { mode: 0o600 });
44
45
  if (pendingWarning)
45
46
  this.pendingLogWarnings.delete(event.threadId);
47
+ // Best-effort size cap (#1280): an open thread's canonical log
48
+ // otherwise grows without bound for as long as the thread stays open.
49
+ capThreadLog(join(EVENTS_DIR, `${event.threadId}.ndjson`), currentThreadLogCap());
46
50
  }
47
51
  catch (error) {
48
52
  // Never feed this warning back through publish(): that would retry the
@@ -46,7 +46,7 @@ export class ProviderRegistry {
46
46
  this.npmPresent = options.npmAvailable ?? npmAvailable;
47
47
  this.driversByKind = new Map(drivers.map((d) => [d.driverKind, d]));
48
48
  }
49
- async load(configs) {
49
+ async load(configs, decorate) {
50
50
  for (const [instanceId, entry] of Object.entries(configs)) {
51
51
  // Account edits replace only their own process/session state.
52
52
  await this.dispose(instanceId);
@@ -80,7 +80,7 @@ export class ProviderRegistry {
80
80
  enabled: entry.enabled ?? true,
81
81
  config,
82
82
  });
83
- this.byId.set(instanceId, { instanceId, live });
83
+ this.byId.set(instanceId, { instanceId, live: decorate ? decorate(live) : live });
84
84
  }
85
85
  catch (e) {
86
86
  this.byId.set(instanceId, {
@@ -225,6 +225,7 @@ export class ProviderRegistry {
225
225
  browserMcp: inst.adapter.capabilities.browserMcp === true,
226
226
  images: inst.adapter.capabilities.images === true,
227
227
  effortLevels: inst.adapter.capabilities.effortLevels,
228
+ modelVariants: inst.adapter.capabilities.modelVariants === true,
228
229
  queueing: inst.adapter.capabilities.queueing === true,
229
230
  localComputerMcp: inst.adapter.capabilities.localComputerMcp === true,
230
231
  approvalReview: inst.reviewPermission !== undefined,