openmausbot 0.1.79 → 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.
@@ -243,11 +243,14 @@ export const CLAUDE_FLAG_FLOORS = {
243
243
  "--strict-mcp-config": [1, 0, 60],
244
244
  "--setting-sources": [1, 0, 122],
245
245
  "--autocompact": [2, 1, 122],
246
+ // 2.1.267 is the first CLI that accepts it; below that the recorded prompt
247
+ // simply is not refreshed, which is the pre-existing behaviour.
248
+ "--system-prompt-snapshot": [2, 1, 267],
246
249
  };
247
250
  /** The newest floor above: a CLI at or past it accepts everything the
248
251
  * harness sends. Below it the engine still works, minus the flags the CLI
249
252
  * predates, and the Engines page suggests an update. */
250
- export const CLAUDE_CONTEXT_CONTROL_MIN_VERSION = CLAUDE_FLAG_FLOORS["--autocompact"];
253
+ export const CLAUDE_CONTEXT_CONTROL_MIN_VERSION = CLAUDE_FLAG_FLOORS["--system-prompt-snapshot"];
251
254
  /** `claude --version` prints "2.1.232 (Claude Code)"; the first dotted triple
252
255
  * is the version. Null when nothing parses, e.g. a wrapper that prints its
253
256
  * own banner first — see claudeCliSupports for how that is treated. */
@@ -273,9 +276,7 @@ export function claudeCliSupports(version, flag) {
273
276
  return version === null || versionAtLeast(version, CLAUDE_FLAG_FLOORS[flag]);
274
277
  }
275
278
  /** The Engines-page notice for a CLI older than the newest floor. The engine
276
- * keeps working: turns run without the flags the CLI predates, which means
277
- * no harness-picked compaction window and, on a very old CLI, no isolation
278
- * from this machine's own Claude Code setup. */
279
+ * keeps working without the flags its CLI predates. */
279
280
  export function claudeCliUpdate(version, cli) {
280
281
  const parsed = parseClaudeCliVersion(version);
281
282
  if (!parsed || versionAtLeast(parsed, CLAUDE_CONTEXT_CONTROL_MIN_VERSION))
@@ -283,39 +284,18 @@ export function claudeCliUpdate(version, cli) {
283
284
  const floor = CLAUDE_CONTEXT_CONTROL_MIN_VERSION.join(".");
284
285
  const missing = Object.keys(CLAUDE_FLAG_FLOORS)
285
286
  .filter((flag) => !claudeCliSupports(parsed, flag));
287
+ const effects = [
288
+ ...(missing.includes("--autocompact") ? ["no compaction window picked by OpenMausBot"] : []),
289
+ ...(missing.includes("--setting-sources") ? ["bots still see this machine's own Claude Code setup"] : []),
290
+ ...(missing.includes("--system-prompt-snapshot") ? ["coordinated resumed turns cannot refresh stale system prompts"] : []),
291
+ ];
286
292
  return {
287
293
  title: "Update Claude Code for context controls",
288
294
  message: `Claude Code ${parsed.join(".")} predates ${floor}, so bots run without ${missing.join(", ")}: ` +
289
- "no compaction window picked by OpenMausBot" +
290
- (missing.includes("--setting-sources") ? ", and bots still see this machine's own Claude Code setup" : "") +
291
- ". Update it, then refresh Engines.",
295
+ `${effects.join("; ")}. Update it, then refresh Engines.`,
292
296
  command: cli === "claude" ? "claude update" : `${cli} update`,
293
297
  };
294
298
  }
295
- /** Whether `claude --help` output lists `flag` as a supported option.
296
- *
297
- * A wrapper may print its own banner before the real options list, and the
298
- * flag may appear only in an example or description rather than as an
299
- * option. We scan for a line that begins with the flag (after optional
300
- * leading whitespace), which is how the real CLI formats its `--help`.
301
- */
302
- export function claudeCliHelpSupportsFlag(help, flag) {
303
- if (!help)
304
- return false;
305
- const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
306
- return new RegExp(`^\\s*${escaped}\\b`, "m").test(help);
307
- }
308
- /** Whether the installed CLI supports `--autocompact`.
309
- *
310
- * When `snapshot()` has already probed `claude --help`, use that. If the
311
- * probe has not run or `--help` failed, fall back to the version floor so
312
- * the pre-snapshot behavior is preserved.
313
- */
314
- export function claudeAutoCompactSupported(version, help) {
315
- if (help !== null)
316
- return claudeCliHelpSupportsFlag(help, "--autocompact");
317
- return claudeCliSupports(version, "--autocompact");
318
- }
319
299
  const DRIVER_KIND = "claudeAgent";
320
300
  // model catalog ported from upstream packages/contracts/src/model.ts
321
301
  export const STATIC_CLAUDE_MODELS = {
@@ -707,6 +687,7 @@ function decodeConfig(raw) {
707
687
  return {
708
688
  cli: typeof o.cli === "string" ? o.cli : "claude",
709
689
  ...(configDir ? { configDir } : {}),
690
+ ...(o.managed === true ? { managed: true } : {}),
710
691
  permissionMode: mode ?? "acceptEdits",
711
692
  ...(tools !== undefined ? { tools } : {}),
712
693
  ...(disallowedTools !== undefined ? { disallowedTools } : {}),
@@ -793,10 +774,12 @@ export const ClaudeDriver = {
793
774
  defaultConfig: () => decodeConfig({}),
794
775
  async create(input) {
795
776
  const { instanceId, config } = input;
796
- const environment = (model) => claudeEnvironment(model, { ...process.env, ...input.environment }, config.configDir, input.environment);
777
+ const environment = (model) => claudeEnvironment(config.managed ? undefined : model, { ...process.env, ...input.environment }, config.configDir, input.environment);
797
778
  const catalogEnv = environment();
798
779
  let models = STATIC_CLAUDE_MODELS;
799
780
  const refreshModels = async () => {
781
+ if (config.managed)
782
+ return;
800
783
  try {
801
784
  const resolved = await mergeLocalInject(readClaudeModelCatalog(catalogEnv), catalogEnv);
802
785
  if (resolved.options.length)
@@ -807,16 +790,19 @@ export const ClaudeDriver = {
807
790
  }
808
791
  };
809
792
  await refreshModels();
810
- // The installed CLI's version and `--help` output as snapshot() last read
811
- // them, so a flag the CLI does not know is never passed to it. The harness
812
- // snapshots every instance whenever it describes them — app load, the
813
- // Engines page, and right after `claude update` — which is exactly when
814
- // the answer changes, so a turn normally finds it filled. A turn before
815
- // any snapshot falls back to the version floor for the flags that are not
816
- // known yet.
793
+ // The installed CLI's version as snapshot() last read it, so a flag the
794
+ // CLI does not know is never passed to it (CLAUDE_FLAG_FLOORS). The
795
+ // harness snapshots every instance whenever it describes them — app
796
+ // load, the Engines page, and right after `claude update`, which is
797
+ // exactly when the answer changes — so a turn normally finds it filled.
798
+ // Most turns before any snapshot assume a current CLI. A coordinated
799
+ // turn checks first because the snapshot-refresh flag is newer than the
800
+ // other context controls and an unknown flag would reject that request.
817
801
  let cliVersion = null;
818
- let cliHelp = null;
819
- let cliHelpVersion = null;
802
+ let cliVersionChecked = false;
803
+ const readCliVersion = (env) => new Promise((resolve) => {
804
+ execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout.trim() || null));
805
+ });
820
806
  const listeners = new Set();
821
807
  // one active turn per thread; a second send while busy is a caller bug
822
808
  const active = new Map();
@@ -901,6 +887,10 @@ export const ClaudeDriver = {
901
887
  // is a fresh sendTurn, and the attempt cap must survive across launches
902
888
  const retryState = new Map();
903
889
  const sendTurn = async (turn, logicalTurnId) => {
890
+ if (config.managed && (!turn.model || turn.model.includes("::") || !config.configDir ||
891
+ !input.environment.ANTHROPIC_API_KEY || !input.environment.ANTHROPIC_BASE_URL)) {
892
+ throw new Error("Company model access is unavailable. Reconnect your organization; personal billing will not be used.");
893
+ }
904
894
  const { threadId, botId } = turn;
905
895
  // An internal relaunch (transient failure, rejected resume) keeps the
906
896
  // logical turn's stop handle in `active` while it sets up, so Stop is
@@ -957,6 +947,13 @@ export const ClaudeDriver = {
957
947
  args.push("--disallowedTools", config.disallowedTools.join(","));
958
948
  }
959
949
  const turnEnvironment = environment();
950
+ if (turn.refreshSystemPrompt && !cliVersionChecked) {
951
+ const version = await readCliVersion(turnEnvironment);
952
+ if (version) {
953
+ cliVersion = parseClaudeCliVersion(version);
954
+ cliVersionChecked = true;
955
+ }
956
+ }
960
957
  const isolated = !inheritsUserConfig(turnEnvironment);
961
958
  if (isolated) {
962
959
  // A bot gets the tools and instructions its owner gave it, not
@@ -974,11 +971,19 @@ export const ClaudeDriver = {
974
971
  args.push("--setting-sources", "project");
975
972
  }
976
973
  const compactWindow = autoCompactWindow(turnEnvironment);
977
- if (compactWindow && claudeAutoCompactSupported(cliVersion, cliHelp)) {
974
+ if (compactWindow && claudeCliSupports(cliVersion, "--autocompact")) {
978
975
  args.push("--autocompact", compactWindow);
979
976
  }
980
- const turnModel = await resolveClaudeTurnModel(turn.model, turnEnvironment);
981
- const injected = applyClaudeInject({ ...turnEnvironment }, turnModel);
977
+ // An old pair conversation can still carry its first assignment in
978
+ // Claude's recorded system prompt. The current brief rides in the user
979
+ // turn, so refresh the recorded prompt on --resume too. Gated by the
980
+ // version floor like every other flag the CLI may predate: an unknown
981
+ // flag is a hard argument error, not a graceful degrade.
982
+ if (turn.refreshSystemPrompt && cliVersionChecked && claudeCliSupports(cliVersion, "--system-prompt-snapshot")) {
983
+ args.push("--system-prompt-snapshot", "off");
984
+ }
985
+ const turnModel = config.managed ? turn.model : await resolveClaudeTurnModel(turn.model, turnEnvironment);
986
+ const injected = config.managed ? { model: turnModel ?? null, injected: false } : applyClaudeInject({ ...turnEnvironment }, turnModel);
982
987
  if (injected.model)
983
988
  args.push("--model", injected.model);
984
989
  if (turn.effort)
@@ -1442,6 +1447,9 @@ export const ClaudeDriver = {
1442
1447
  ...(typeof msg.usage.cache_read_input_tokens === "number"
1443
1448
  ? { cachedInput: msg.usage.cache_read_input_tokens }
1444
1449
  : {}),
1450
+ // one assistant message = one model call, and its prompt is
1451
+ // everything in the window: fresh text, cache reads and writes
1452
+ contextTokens: (msg.usage.input_tokens || 0) + (msg.usage.cache_read_input_tokens || 0) + (msg.usage.cache_creation_input_tokens || 0),
1445
1453
  });
1446
1454
  }
1447
1455
  break;
@@ -1728,32 +1736,17 @@ export const ClaudeDriver = {
1728
1736
  const login = new ClaudeLoginController({ cli: config.cli, environment, onAuthenticated: async () => { await refreshModels(); } });
1729
1737
  const snapshot = async () => {
1730
1738
  const env = environment();
1731
- const version = await new Promise((resolve) => {
1732
- execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout.trim()));
1733
- });
1739
+ const version = await readCliVersion(env);
1734
1740
  if (!version)
1735
1741
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
1736
1742
  cliVersion = parseClaudeCliVersion(version);
1737
- if (version !== cliHelpVersion) {
1738
- cliHelp = await new Promise((resolve) => {
1739
- execCli(config.cli, ["--help"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout));
1740
- });
1741
- cliHelpVersion = version;
1742
- }
1743
- const features = cliHelp !== null ? { autocompact: claudeCliHelpSupportsFlag(cliHelp, "--autocompact") } : undefined;
1743
+ cliVersionChecked = true;
1744
1744
  const auth = await claudeAuthStatus(config.cli, env);
1745
1745
  // claudeEnvironment strips ANTHROPIC_API_KEY, so turns run on the
1746
1746
  // CLI's own login (Pro/Max): the cost it reports is what the call
1747
1747
  // WOULD bill, not a charge
1748
1748
  const update = claudeCliUpdate(version, config.cli);
1749
- return {
1750
- state: "available",
1751
- version,
1752
- ...auth,
1753
- ...(update ? { update } : {}),
1754
- ...(features ? { features } : {}),
1755
- billing: "subscription",
1756
- };
1749
+ return { state: "available", version, ...auth, ...(update ? { update } : {}), billing: "subscription" };
1757
1750
  };
1758
1751
  /** One-shot Claude call with the prompt on stdin, never argv. Approval
1759
1752
  * summaries can contain paths, commands, or secrets, so the generic
@@ -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,8 +94,33 @@ 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
+ 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
+ }
84
124
  const QUESTION_TIMEOUT_NOTE = "No answer was given — use your best judgment.";
85
125
  const DENY_TIMEOUT_NOTE = "OpenMausBot: nobody answered this permission request in time. Skip this action and finish what you can without it.";
86
126
  /** RequestPermissionProfile uses null for permission families that were not
@@ -416,8 +456,10 @@ export const CodexDriver = {
416
456
  return env;
417
457
  };
418
458
  const catalogEnv = childEnv();
419
- 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;
420
460
  const refreshModels = async () => {
461
+ if (config.managed)
462
+ return;
421
463
  try {
422
464
  const resolved = await readCodexModelCatalog(catalogEnv, fetch, config.cli);
423
465
  if (resolved.options.length)
@@ -447,9 +489,14 @@ export const CodexDriver = {
447
489
  createdAt: new Date().toISOString(),
448
490
  });
449
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
+ }
450
495
  // One driver instance serves many threads. Interrupt state belongs to
451
496
  // this turn so activity elsewhere cannot cancel or revive its retry.
452
497
  let stopRequested = false;
498
+ let promptSubmitted = false;
499
+ let recoveredMissingSession = false;
453
500
  // Wakes a retry backoff the moment Stop arrives, so the turn settles
454
501
  // now rather than after the full wait.
455
502
  const stopSignal = new AbortController();
@@ -473,7 +520,7 @@ export const CodexDriver = {
473
520
  const retryScale = Number(process.env.FAKE_CODEX_RETRY_SCALE ?? "1");
474
521
  const launchAttempt = async (attempt) => {
475
522
  const env = childEnv();
476
- const appServerArgs = ["app-server", ...codexLocalProviderArgs(env, turn.model)];
523
+ const appServerArgs = ["app-server", ...(config.managed ? managedCodexArgs(config.managed) : codexLocalProviderArgs(env, turn.model))];
477
524
  if (turn.integrations?.composio) {
478
525
  mountMcpServer(appServerArgs, env, "openmausbot_connectors", turn.integrations.composio);
479
526
  }
@@ -512,9 +559,12 @@ export const CodexDriver = {
512
559
  lastError: "",
513
560
  lastText: "",
514
561
  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
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.
517
566
  usage: undefined,
567
+ usageBaseline: undefined,
518
568
  };
519
569
  const asks = new Map();
520
570
  let nextId = 1;
@@ -546,6 +596,18 @@ export const CodexDriver = {
546
596
  rpcPending.set(id, {
547
597
  resolve: (v) => {
548
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
+ }
549
611
  if (method === "turn/start") {
550
612
  if (typeof v?.turn?.id !== "string" || !v.turn.id) {
551
613
  reject(new Error("Codex did not return a native turn id"));
@@ -730,6 +792,17 @@ export const CodexDriver = {
730
792
  if (!connectionError) {
731
793
  if (!codexThreadId || p.threadId !== codexThreadId)
732
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
+ }
733
806
  if (!codexTurnId) {
734
807
  // Some servers stream before acknowledging turn/start. Retain a
735
808
  // bounded prefix, then filter against the authoritative response.
@@ -835,32 +908,44 @@ export const CodexDriver = {
835
908
  break;
836
909
  }
837
910
  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) {
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);
846
929
  state.usage = {
847
- input: turnUsage.inputTokens ?? 0,
848
- output: turnUsage.outputTokens ?? 0,
849
- ...(typeof turnUsage.cachedInputTokens === "number"
850
- ? { cachedInput: turnUsage.cachedInputTokens }
851
- : {}),
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) } : {}),
852
933
  };
853
934
  }
854
- 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
+ }
855
938
  if (t) {
939
+ const window = p.tokenUsage?.modelContextWindow;
856
940
  emit({
857
941
  ...base(threadId, turnId),
858
942
  type: "thread.token-usage.updated",
859
943
  input: t.inputTokens ?? 0,
860
944
  output: t.outputTokens ?? 0,
861
- ...(typeof t.cachedInputTokens === "number"
862
- ? { cachedInput: t.cachedInputTokens }
863
- : {}),
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 } : {}),
864
949
  });
865
950
  }
866
951
  break;
@@ -920,7 +1005,7 @@ export const CodexDriver = {
920
1005
  if (pend) {
921
1006
  rpcPending.delete(msg.id);
922
1007
  if (msg.error)
923
- pend.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
1008
+ pend.reject(new CodexRpcError(msg.error));
924
1009
  else
925
1010
  pend.resolve(msg.result);
926
1011
  }
@@ -971,7 +1056,16 @@ export const CodexDriver = {
971
1056
  // websocket 426 logged at turn start.
972
1057
  const recentStderr = stderrSinceOutput.trim();
973
1058
  const hadStreamedOutput = codexTurnId !== null || state.sawStreamDelta;
974
- const verdict = classifyError({ exitCode: code, stderr: recentStderr || stderr });
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 });
975
1069
  // Safe re-dispatch: relaunch only when the app-server never
976
1070
  // acknowledged turn/start — no native turn began, nothing was
977
1071
  // streamed, so replaying the input cannot duplicate work. After
@@ -1069,35 +1163,48 @@ export const CodexDriver = {
1069
1163
  // Removed bot rules are cleared without dropping native configured rules.
1070
1164
  const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
1071
1165
  let startedModel = null;
1166
+ let resumedNativeThread = false;
1167
+ let promptText = turn.text;
1072
1168
  if (cursor) {
1169
+ const resumeThread = () => request("thread/resume", {
1170
+ threadId: cursor,
1171
+ developerInstructions,
1172
+ ...approvalParams.thread,
1173
+ });
1073
1174
  try {
1074
- const resumed = await request("thread/resume", {
1075
- threadId: cursor,
1076
- developerInstructions,
1077
- ...approvalParams.thread,
1078
- });
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
+ }
1079
1187
  codexThreadId = resumed?.thread?.id ?? cursor;
1188
+ resumedNativeThread = true;
1080
1189
  }
1081
1190
  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 {
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))
1095
1199
  throw error;
1096
- }
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;
1097
1204
  }
1098
1205
  }
1099
1206
  if (!codexThreadId) {
1100
- const selection = decodeCodexSelection(turn.model);
1207
+ const selection = config.managed ? { model: turn.model, modelProvider: "openmaus_company" } : decodeCodexSelection(turn.model);
1101
1208
  const startThread = () => request("thread/start", {
1102
1209
  developerInstructions,
1103
1210
  cwd: turn.cwd ?? homedir(),
@@ -1121,14 +1228,14 @@ export const CodexDriver = {
1121
1228
  }
1122
1229
  if (!codexThreadId)
1123
1230
  throw new Error("Codex did not return a native thread id");
1124
- await syncCodexInstructions(threadId, codexThreadId, developerInstructions, Boolean(cursor), request);
1231
+ await syncCodexInstructions(threadId, codexThreadId, developerInstructions, resumedNativeThread, request);
1125
1232
  emit({ ...base(threadId, turnId), type: "session.started", sessionId: codexThreadId, model: startedModel ?? turn.model ?? null });
1126
- const promptText = turn.text;
1127
1233
  const turnInput = [
1128
1234
  ...(promptText ? [{ type: "text", text: promptText }] : []),
1129
1235
  ...(turn.images ?? []).map((image) => ({ type: "localImage", path: image.path })),
1130
1236
  ];
1131
1237
  const startTurn = () => {
1238
+ promptSubmitted = true;
1132
1239
  startingNativeTurn = true;
1133
1240
  return request("turn/start", {
1134
1241
  threadId: codexThreadId,
@@ -1161,7 +1268,10 @@ export const CodexDriver = {
1161
1268
  const message = e instanceof Error ? e.message : String(e);
1162
1269
  const needsAuth = /(?:\b401\b|unauthorized|missing bearer|authentication required)/i.test(message);
1163
1270
  const verdict = classifyError(failure);
1164
- if (!state.settled && !abandoned && !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) {
1165
1275
  const delayMs = computeBackoff(attempt);
1166
1276
  attempt++;
1167
1277
  emit({
@@ -1210,6 +1320,8 @@ export const CodexDriver = {
1210
1320
  });
1211
1321
  if (!version)
1212
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" };
1213
1325
  const authenticated = await new Promise((resolve) => {
1214
1326
  execCli(config.cli, ["login", "status"], { timeout: 8000, env }, (err, stdout, stderr) => resolve(!err && /^logged in\b/im.test(`${stdout}\n${stderr ?? ""}`)));
1215
1327
  });
@@ -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, {