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.
- package/dist/assets/index-Cu8BkIfo.js +305 -0
- package/dist/assets/{index-C2Je5jdF.js → index-D6RZKWks.js} +1 -1
- package/dist/assets/index-tfpeJAIG.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/drivers/agents-proxy.js +21 -6
- package/dist-server/index.js +988 -544
- package/dist-server/openmausbot.js +170 -85
- package/dist-server/pair-cli.js +170 -85
- package/dist-server/server/drivers/agents-proxy.js +27 -2
- package/dist-server/server/drivers/claude.js +55 -62
- package/dist-server/server/drivers/codex.js +158 -46
- package/dist-server/server/harness/registry.js +2 -2
- package/dist-server/server/index.js +203 -12
- package/dist-server/server/managed-desktop.js +205 -0
- package/dist-server/server/model-context-window.js +20 -0
- package/dist-server/server/redact.js +1 -0
- package/dist-server/server/room-handoffs.js +16 -6
- package/dist-server/server/store.js +13 -1
- package/package.json +1 -1
- package/dist/assets/index-D_TFZLlo.js +0 -305
- package/dist/assets/index-Pbb6Ao0s.css +0 -1
|
@@ -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["--
|
|
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
|
|
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
|
-
"
|
|
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
|
|
811
|
-
//
|
|
812
|
-
// snapshots every instance whenever it describes them — app
|
|
813
|
-
// Engines page, and right after `claude update
|
|
814
|
-
// the answer changes
|
|
815
|
-
//
|
|
816
|
-
//
|
|
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
|
|
819
|
-
|
|
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 &&
|
|
974
|
+
if (compactWindow && claudeCliSupports(cliVersion, "--autocompact")) {
|
|
978
975
|
args.push("--autocompact", compactWindow);
|
|
979
976
|
}
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
516
|
-
// wants this turn's figure
|
|
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
|
-
// `
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
|
|
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:
|
|
848
|
-
output:
|
|
849
|
-
...(typeof
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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,
|
|
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
|
-
|
|
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, {
|