openmausbot 0.1.69 → 0.1.71
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-BmHupgVJ.js → index-CJtyBonr.js} +1 -1
- package/dist/assets/index-CYLdYtYp.css +1 -0
- package/dist/assets/index-Di4ovwu5.js +289 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +6 -2
- package/dist-server/drivers/agents-proxy.js +305 -16
- package/dist-server/drivers/pi-mcp-extension.ts +1 -1
- package/dist-server/index.js +3141 -1124
- package/dist-server/local-computer-proxy.js +2 -0
- package/dist-server/local-computer.js +2 -1
- package/dist-server/mcp-gate.js +543 -0
- package/dist-server/mcp-server.js +38 -0
- package/dist-server/openmausbot.js +772 -260
- package/dist-server/pair-cli.js +772 -260
- package/dist-server/prepare-cloudflared.js +20 -3
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +29 -0
- package/dist-server/server/bot-package.js +55 -2
- package/dist-server/server/browser-live.js +12 -7
- package/dist-server/server/config.js +40 -4
- package/dist-server/server/delegations.js +132 -74
- package/dist-server/server/drivers/acp/core.js +2 -0
- package/dist-server/server/drivers/acp/cursor.js +1 -1
- package/dist-server/server/drivers/acp/grok.js +5 -1
- package/dist-server/server/drivers/agents-proxy.js +324 -16
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude-login-auth.js +102 -9
- package/dist-server/server/drivers/claude.js +298 -21
- package/dist-server/server/drivers/codex.js +13 -4
- package/dist-server/server/drivers/pi.js +1 -1
- package/dist-server/server/engine-install.js +137 -0
- package/dist-server/server/env-path.js +12 -0
- package/dist-server/server/harness/bus.js +1 -1
- package/dist-server/server/harness/registry.js +31 -6
- package/dist-server/server/index.js +860 -215
- package/dist-server/server/local-computer.js +1 -1
- package/dist-server/server/mcp-gate-config.js +56 -0
- package/dist-server/server/mcp-gate.js +227 -0
- package/dist-server/server/mcp-trim.js +182 -0
- package/dist-server/server/memory-journal.js +440 -0
- package/dist-server/server/memory-store.js +308 -0
- package/dist-server/server/message-db.js +90 -3
- package/dist-server/server/package-export.js +25 -0
- package/dist-server/server/peer-approval.js +1 -1
- package/dist-server/server/peer-provenance.js +6 -2
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/redact.js +15 -0
- package/dist-server/server/resume-recovery.js +46 -0
- package/dist-server/server/routines.js +116 -55
- package/dist-server/server/screen-frame-gate.js +9 -6
- package/dist-server/server/skill-library.js +5 -5
- package/dist-server/server/steer-queue.js +20 -1
- package/dist-server/server/store.js +29 -7
- package/dist-server/server/system-prompt.js +14 -1
- package/dist-server/server/team-backup.js +53 -4
- package/dist-server/server/tool-summary.js +39 -0
- package/dist-server/server/tts/speech-text.js +3 -1
- package/dist-server/server/turn-context.js +18 -0
- package/dist-server/server/workspace.js +321 -22
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/team-backup.js +14 -2
- package/dist-server/vps-container-mcp.js +6 -2
- package/package.json +1 -1
- package/skills/create-verification-skill/SKILL.md +2 -3
- package/dist/assets/index-COp5lMnv.js +0 -279
- package/dist/assets/index-Jk7TIeyW.css +0 -1
|
@@ -16,9 +16,12 @@ import { join, dirname, isAbsolute, normalize } from "node:path";
|
|
|
16
16
|
import { DATA_DIR, stripWorkspaceCredentialEnv } from "../config.js";
|
|
17
17
|
import { augmentedPath } from "../env-path.js";
|
|
18
18
|
import { brokerSocketPath, describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
|
|
19
|
+
import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
|
|
19
20
|
import { ClaudeLoginController } from "./claude-login-auth.js";
|
|
20
21
|
import { computerProxyEnv } from "../container-computer.js";
|
|
22
|
+
import { gateServer, resultBudget } from "../mcp-gate-config.js";
|
|
21
23
|
import { newEventId, newId } from "../contracts.js";
|
|
24
|
+
import { askInputSummary, commandSummary } from "../tool-summary.js";
|
|
22
25
|
import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS } from "./retry.js";
|
|
23
26
|
import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
|
|
24
27
|
import { appendNative } from "./native.js";
|
|
@@ -131,6 +134,133 @@ function claudeEnvironment(model, source = process.env, configDir, instanceEnvir
|
|
|
131
134
|
delete env.ANTHROPIC_API_KEY;
|
|
132
135
|
return env;
|
|
133
136
|
}
|
|
137
|
+
/** Escape hatch back to the pre-isolation launch, where a bot inherited this
|
|
138
|
+
* machine's Claude Code setup: its MCP servers and connectors, skills,
|
|
139
|
+
* agents, hooks and personal CLAUDE.md. Set it only to recover a bot that
|
|
140
|
+
* genuinely depended on a user- or local-scope MCP server; the supported way
|
|
141
|
+
* to give a bot a server is the app's own `mcpServers` config or the bot
|
|
142
|
+
* project's `.mcp.json`. */
|
|
143
|
+
function inheritsUserConfig(env) {
|
|
144
|
+
return env.OMB_CLAUDE_INHERIT_USER_CONFIG === "1";
|
|
145
|
+
}
|
|
146
|
+
/** MCP servers the bot's own project declares in `<cwd>/.mcp.json`.
|
|
147
|
+
*
|
|
148
|
+
* The CLI would find this file itself, but the harness launches it with
|
|
149
|
+
* --strict-mcp-config, which makes the harness's config the only source.
|
|
150
|
+
* The project file IS part of the bot's definition (its cwd is chosen per
|
|
151
|
+
* bot), so it is forwarded verbatim — including `type: "http"`/`"sse"`
|
|
152
|
+
* entries the harness never mounts itself, because the CLI, not this code,
|
|
153
|
+
* is what has to understand them. A malformed file is ignored rather than
|
|
154
|
+
* failing the turn: an unreadable project config must not brick a bot. */
|
|
155
|
+
function projectMcpServers(cwd) {
|
|
156
|
+
let parsed;
|
|
157
|
+
try {
|
|
158
|
+
parsed = JSON.parse(readFileSync(join(cwd, ".mcp.json"), "utf8"));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
const servers = parsed?.mcpServers;
|
|
164
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
165
|
+
return {};
|
|
166
|
+
const out = {};
|
|
167
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
168
|
+
if (server && typeof server === "object" && !Array.isArray(server))
|
|
169
|
+
out[name] = server;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** The CLI compacts its own session when it approaches a window. Left alone
|
|
174
|
+
* that window is the model's, so a Sonnet 5 session runs to something near a
|
|
175
|
+
* million tokens before anything happens — and every model call until then
|
|
176
|
+
* re-reads the whole thing. The measured food-ordering thread sat at 330k
|
|
177
|
+
* tokens per call and looked perfectly healthy to the CLI.
|
|
178
|
+
*
|
|
179
|
+
* So the harness picks the window instead. This delegates the actual
|
|
180
|
+
* compaction to the CLI, which owns the session and already has a summarizer
|
|
181
|
+
* for it; the harness only decides when it is worth paying for.
|
|
182
|
+
*
|
|
183
|
+
* OMB_CLAUDE_AUTOCOMPACT takes a token count, "auto" to hand the decision
|
|
184
|
+
* back to the CLI, or "off" to pass nothing at all. The CLI rejects a window
|
|
185
|
+
* outside 100k-1M as a hard argument error, so a configured value is clamped
|
|
186
|
+
* rather than passed through: a mistyped setting must not fail every turn. */
|
|
187
|
+
export function autoCompactWindow(env) {
|
|
188
|
+
const raw = (env.OMB_CLAUDE_AUTOCOMPACT ?? "").trim().toLowerCase();
|
|
189
|
+
if (raw === "off")
|
|
190
|
+
return null;
|
|
191
|
+
if (raw === "auto")
|
|
192
|
+
return "auto";
|
|
193
|
+
const parsed = raw ? Number(raw) : DEFAULT_AUTOCOMPACT_TOKENS;
|
|
194
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
195
|
+
return String(DEFAULT_AUTOCOMPACT_TOKENS);
|
|
196
|
+
return String(Math.min(1_000_000, Math.max(100_000, Math.floor(parsed))));
|
|
197
|
+
}
|
|
198
|
+
/** Generous for real work, and still a third of where a 1M-window session
|
|
199
|
+
* would otherwise get to. With bot tool results gated (mcp-gate.ts) most
|
|
200
|
+
* threads never reach it; this is the backstop for the ones that do. */
|
|
201
|
+
const DEFAULT_AUTOCOMPACT_TOKENS = 200_000;
|
|
202
|
+
/** The Claude CLI version that first accepted each flag the harness passes
|
|
203
|
+
* for context control. An unknown flag is a hard argument error, so passing
|
|
204
|
+
* one to an older CLI would fail every turn rather than degrade; each flag
|
|
205
|
+
* is therefore only passed to a CLI known to accept it.
|
|
206
|
+
*
|
|
207
|
+
* Verified against the published binaries, not the changelog (which never
|
|
208
|
+
* records `--autocompact`): `--strict-mcp-config` is present in 1.0.60 and
|
|
209
|
+
* absent from 1.0.0; `--setting-sources` first appears in 1.0.122 (1.0.120
|
|
210
|
+
* lacks it); `--autocompact` first appears in 2.1.122 (2.1.121 lacks it). */
|
|
211
|
+
export const CLAUDE_FLAG_FLOORS = {
|
|
212
|
+
"--strict-mcp-config": [1, 0, 60],
|
|
213
|
+
"--setting-sources": [1, 0, 122],
|
|
214
|
+
"--autocompact": [2, 1, 122],
|
|
215
|
+
};
|
|
216
|
+
/** The newest floor above: a CLI at or past it accepts everything the
|
|
217
|
+
* harness sends. Below it the engine still works, minus the flags the CLI
|
|
218
|
+
* predates, and the Engines page suggests an update. */
|
|
219
|
+
export const CLAUDE_CONTEXT_CONTROL_MIN_VERSION = CLAUDE_FLAG_FLOORS["--autocompact"];
|
|
220
|
+
/** `claude --version` prints "2.1.232 (Claude Code)"; the first dotted triple
|
|
221
|
+
* is the version. Null when nothing parses, e.g. a wrapper that prints its
|
|
222
|
+
* own banner first — see claudeCliSupports for how that is treated. */
|
|
223
|
+
export function parseClaudeCliVersion(stdout) {
|
|
224
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout ?? "");
|
|
225
|
+
if (!match)
|
|
226
|
+
return null;
|
|
227
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
228
|
+
}
|
|
229
|
+
function versionAtLeast(installed, floor) {
|
|
230
|
+
for (let i = 0; i < 3; i += 1) {
|
|
231
|
+
if (installed[i] !== floor[i])
|
|
232
|
+
return installed[i] > floor[i];
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
/** Whether a CLI reporting `version` accepts `flag`. A version that could
|
|
237
|
+
* not be parsed counts as current: every CLI that predates a floor prints a
|
|
238
|
+
* plain "x.y.z (Claude Code)", so an unreadable version is far more likely
|
|
239
|
+
* a newer wrapper than an old build, and withholding the flags from a modern
|
|
240
|
+
* CLI would silently re-open the context leak this file exists to close. */
|
|
241
|
+
export function claudeCliSupports(version, flag) {
|
|
242
|
+
return version === null || versionAtLeast(version, CLAUDE_FLAG_FLOORS[flag]);
|
|
243
|
+
}
|
|
244
|
+
/** The Engines-page notice for a CLI older than the newest floor. The engine
|
|
245
|
+
* keeps working: turns run without the flags the CLI predates, which means
|
|
246
|
+
* no harness-picked compaction window and, on a very old CLI, no isolation
|
|
247
|
+
* from this machine's own Claude Code setup. */
|
|
248
|
+
export function claudeCliUpdate(version, cli) {
|
|
249
|
+
const parsed = parseClaudeCliVersion(version);
|
|
250
|
+
if (!parsed || versionAtLeast(parsed, CLAUDE_CONTEXT_CONTROL_MIN_VERSION))
|
|
251
|
+
return undefined;
|
|
252
|
+
const floor = CLAUDE_CONTEXT_CONTROL_MIN_VERSION.join(".");
|
|
253
|
+
const missing = Object.keys(CLAUDE_FLAG_FLOORS)
|
|
254
|
+
.filter((flag) => !claudeCliSupports(parsed, flag));
|
|
255
|
+
return {
|
|
256
|
+
title: "Update Claude Code for context controls",
|
|
257
|
+
message: `Claude Code ${parsed.join(".")} predates ${floor}, so bots run without ${missing.join(", ")}: ` +
|
|
258
|
+
"no compaction window picked by OpenMausBot" +
|
|
259
|
+
(missing.includes("--setting-sources") ? ", and bots still see this machine's own Claude Code setup" : "") +
|
|
260
|
+
". Update it, then refresh Engines.",
|
|
261
|
+
command: cli === "claude" ? "claude update" : `${cli} update`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
134
264
|
const DRIVER_KIND = "claudeAgent";
|
|
135
265
|
// model catalog ported from upstream packages/contracts/src/model.ts
|
|
136
266
|
export const STATIC_CLAUDE_MODELS = {
|
|
@@ -235,15 +365,7 @@ function systemEndedReply(kind) {
|
|
|
235
365
|
}
|
|
236
366
|
/** One human-readable line for an ask — what the card subtitle shows. */
|
|
237
367
|
function askSummary(ask) {
|
|
238
|
-
|
|
239
|
-
if (typeof input.question === "string")
|
|
240
|
-
return input.question.slice(0, 300);
|
|
241
|
-
if (typeof input.command === "string")
|
|
242
|
-
return input.command.slice(0, 200);
|
|
243
|
-
if (typeof input.url === "string")
|
|
244
|
-
return input.url.slice(0, 200);
|
|
245
|
-
const text = JSON.stringify(input);
|
|
246
|
-
return text === "{}" ? (ask.tool ?? "tool") : text.slice(0, 200);
|
|
368
|
+
return askInputSummary(ask.input) ?? ask.tool ?? "tool";
|
|
247
369
|
}
|
|
248
370
|
export function permissionSocketPath(threadId) {
|
|
249
371
|
// A readable prefix alone is not unique: ids that agree on their first
|
|
@@ -435,7 +557,7 @@ export async function createPermissionBroker(opts) {
|
|
|
435
557
|
if (!server)
|
|
436
558
|
throw new Error("claude: permission broker could not bind a local socket");
|
|
437
559
|
const drain = () => {
|
|
438
|
-
for (const p of
|
|
560
|
+
for (const p of Array.from(pending.values())) {
|
|
439
561
|
const { behavior, message } = systemEndedReply(p.ask.kind);
|
|
440
562
|
p.finish(behavior, message, "system");
|
|
441
563
|
}
|
|
@@ -524,6 +646,17 @@ function firstText(content) {
|
|
|
524
646
|
/** Claude's stream-json input accepts the same image source blocks as the
|
|
525
647
|
* Anthropic Messages API. Keep the old string form for text-only turns so a
|
|
526
648
|
* CLI update cannot disturb the overwhelmingly common path. */
|
|
649
|
+
/** How a mid-session change to the volatile half of the system prompt
|
|
650
|
+
* reaches a model whose process was launched with the old copy. The CLI's
|
|
651
|
+
* own out-of-band convention inside a user turn, and it costs one short
|
|
652
|
+
* append rather than a relaunch that re-uploads the whole prompt cache. */
|
|
653
|
+
function withVolatileNote(text, volatile) {
|
|
654
|
+
const body = volatile.trim()
|
|
655
|
+
? `This part of your instructions changed since this session started. It replaces the earlier copy:\n\n${volatile.trim()}`
|
|
656
|
+
: "The notes that were in your instructions when this session started have been cleared.";
|
|
657
|
+
const note = `<system-reminder>\n${body}\n</system-reminder>`;
|
|
658
|
+
return text ? `${note}\n\n${text}` : note;
|
|
659
|
+
}
|
|
527
660
|
function claudeUserMessage(text, images) {
|
|
528
661
|
if (!images?.length)
|
|
529
662
|
return { type: "user", message: { role: "user", content: text } };
|
|
@@ -594,6 +727,15 @@ export const ClaudeDriver = {
|
|
|
594
727
|
}
|
|
595
728
|
};
|
|
596
729
|
await refreshModels();
|
|
730
|
+
// The installed CLI's version as snapshot() last read it, so a flag the
|
|
731
|
+
// CLI does not know is never passed to it (CLAUDE_FLAG_FLOORS). The
|
|
732
|
+
// harness snapshots every instance whenever it describes them — app
|
|
733
|
+
// load, the Engines page, and right after `claude update`, which is
|
|
734
|
+
// exactly when the answer changes — so a turn normally finds it filled.
|
|
735
|
+
// A turn before any snapshot assumes a current CLI rather than paying a
|
|
736
|
+
// CLI start-up of its own: the flags are the default, the exception is
|
|
737
|
+
// the older install, and the next snapshot corrects it.
|
|
738
|
+
let cliVersion = null;
|
|
597
739
|
const listeners = new Set();
|
|
598
740
|
// one active turn per thread; a second send while busy is a caller bug
|
|
599
741
|
const active = new Map();
|
|
@@ -659,7 +801,7 @@ export const ClaudeDriver = {
|
|
|
659
801
|
});
|
|
660
802
|
};
|
|
661
803
|
const emit = (event) => {
|
|
662
|
-
for (const l of
|
|
804
|
+
for (const l of Array.from(listeners))
|
|
663
805
|
l(event);
|
|
664
806
|
};
|
|
665
807
|
const base = (threadId, turnId) => ({
|
|
@@ -718,6 +860,26 @@ export const ClaudeDriver = {
|
|
|
718
860
|
args.push("--disallowedTools", config.disallowedTools.join(","));
|
|
719
861
|
}
|
|
720
862
|
const turnEnvironment = environment();
|
|
863
|
+
const isolated = !inheritsUserConfig(turnEnvironment);
|
|
864
|
+
if (isolated) {
|
|
865
|
+
// A bot gets the tools and instructions its owner gave it, not
|
|
866
|
+
// whatever this machine's Claude Code happens to be set up with.
|
|
867
|
+
// Without these the CLI silently adds, to EVERY turn of every bot:
|
|
868
|
+
// the desktop's own MCP servers and claude.ai connectors (one
|
|
869
|
+
// measured desktop mounted 407 extra tools, ~10k tokens), its skill
|
|
870
|
+
// and agent listings, its hooks, and its personal CLAUDE.md. Every
|
|
871
|
+
// model call in the session then re-reads all of it.
|
|
872
|
+
// Each flag only on a CLI that accepts it: an unknown flag is an
|
|
873
|
+
// argument error that would fail every turn (CLAUDE_FLAG_FLOORS).
|
|
874
|
+
if (claudeCliSupports(cliVersion, "--strict-mcp-config"))
|
|
875
|
+
args.push("--strict-mcp-config");
|
|
876
|
+
if (claudeCliSupports(cliVersion, "--setting-sources"))
|
|
877
|
+
args.push("--setting-sources", "project");
|
|
878
|
+
}
|
|
879
|
+
const compactWindow = autoCompactWindow(turnEnvironment);
|
|
880
|
+
if (compactWindow && claudeCliSupports(cliVersion, "--autocompact")) {
|
|
881
|
+
args.push("--autocompact", compactWindow);
|
|
882
|
+
}
|
|
721
883
|
const turnModel = await resolveClaudeTurnModel(turn.model, turnEnvironment);
|
|
722
884
|
const injected = applyClaudeInject({ ...turnEnvironment }, turnModel);
|
|
723
885
|
if (injected.model)
|
|
@@ -794,10 +956,39 @@ export const ClaudeDriver = {
|
|
|
794
956
|
// routes every custom tool call through the ogb permission broker
|
|
795
957
|
// into an Allow/Deny card. Reserved names were filtered upstream;
|
|
796
958
|
// skip any residual collision instead of clobbering a built-in.
|
|
959
|
+
// Bot-owned servers, gated below: they are the ones that answer for a
|
|
960
|
+
// machine rather than for a context window.
|
|
961
|
+
const botOwned = new Set();
|
|
797
962
|
for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
|
|
798
963
|
if (name in mcpServers)
|
|
799
964
|
continue;
|
|
800
965
|
mcpServers[name] = { ...server };
|
|
966
|
+
botOwned.add(name);
|
|
967
|
+
}
|
|
968
|
+
// --strict-mcp-config (above) makes this config the CLI's only source
|
|
969
|
+
// of MCP servers, so a server the bot's OWN project declares would
|
|
970
|
+
// otherwise vanish with the machine's. Merge it last: a project file
|
|
971
|
+
// can add servers but never shadow a harness-owned mount.
|
|
972
|
+
if (isolated && turn.cwd) {
|
|
973
|
+
for (const [name, server] of Object.entries(projectMcpServers(turn.cwd))) {
|
|
974
|
+
if (name in mcpServers)
|
|
975
|
+
continue;
|
|
976
|
+
mcpServers[name] = server;
|
|
977
|
+
botOwned.add(name);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
// One tool call can put more into the conversation than the whole rest
|
|
981
|
+
// of the session: a single product search measured 60-140 KB of JSON,
|
|
982
|
+
// and the CLI re-reads it on every later model call. The harness never
|
|
983
|
+
// sees these calls — the CLI runs the server itself — so the only place
|
|
984
|
+
// to stand is between the two processes. Harness-owned mounts (the
|
|
985
|
+
// permission broker, computer, browser, agents, dweb) are already
|
|
986
|
+
// bounded and are deliberately left alone.
|
|
987
|
+
const budget = resultBudget(turnEnvironment);
|
|
988
|
+
for (const name of botOwned) {
|
|
989
|
+
const gated = gateServer({ name, server: mcpServers[name], threadId, budget, nodeEnv: NODE_ENV_FLAG });
|
|
990
|
+
if (gated)
|
|
991
|
+
mcpServers[name] = gated;
|
|
801
992
|
}
|
|
802
993
|
// Keep ask_user available even in Full access. Native bypass skips
|
|
803
994
|
// permission prompts, not questions requiring a person's answer.
|
|
@@ -832,7 +1023,9 @@ export const ClaudeDriver = {
|
|
|
832
1023
|
const keyArgs = args.filter((a, i) => !privateFileFlags.has(a) && !privateFileFlags.has(args[i - 1] ?? ""));
|
|
833
1024
|
const argsKey = JSON.stringify({
|
|
834
1025
|
args: keyArgs,
|
|
835
|
-
|
|
1026
|
+
// the volatile half is deliberately absent: it must not respawn a
|
|
1027
|
+
// healthy session (see Session.volatile)
|
|
1028
|
+
system: turn.systemStable ?? turn.system ?? null,
|
|
836
1029
|
mcpServers,
|
|
837
1030
|
cwd,
|
|
838
1031
|
model: injected.model ?? null,
|
|
@@ -852,7 +1045,12 @@ export const ClaudeDriver = {
|
|
|
852
1045
|
killCliTree(live.child);
|
|
853
1046
|
}, turnId, broker: live.broker });
|
|
854
1047
|
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
855
|
-
const
|
|
1048
|
+
const volatile = turn.systemVolatile ?? "";
|
|
1049
|
+
const message = volatile === live.volatile
|
|
1050
|
+
? promptMsg
|
|
1051
|
+
: claudeUserMessage(withVolatileNote(turn.text, volatile), turn.images);
|
|
1052
|
+
live.volatile = volatile;
|
|
1053
|
+
const written = await writeUser(live, threadId, message);
|
|
856
1054
|
if (!written) {
|
|
857
1055
|
active.delete(threadId);
|
|
858
1056
|
live.turn = null;
|
|
@@ -982,7 +1180,9 @@ export const ClaudeDriver = {
|
|
|
982
1180
|
mcpConfigPath,
|
|
983
1181
|
systemPromptPath,
|
|
984
1182
|
argsKey,
|
|
1183
|
+
volatile: turn.systemVolatile ?? "",
|
|
985
1184
|
sessionId: sessionId ?? newSessionId,
|
|
1185
|
+
sawInit: false,
|
|
986
1186
|
turn: { turnId, settled: false, sawStreamDelta: false },
|
|
987
1187
|
idleTimer: null,
|
|
988
1188
|
closing: false,
|
|
@@ -1035,6 +1235,7 @@ export const ClaudeDriver = {
|
|
|
1035
1235
|
switch (o.type) {
|
|
1036
1236
|
case "system":
|
|
1037
1237
|
if (o.subtype === "init") {
|
|
1238
|
+
session.sawInit = true;
|
|
1038
1239
|
if (typeof o.session_id === "string")
|
|
1039
1240
|
session.sessionId = o.session_id;
|
|
1040
1241
|
emit({ ...base(threadId, currentTurnId()), type: "session.started", sessionId: o.session_id, model: o.model });
|
|
@@ -1087,7 +1288,14 @@ export const ClaudeDriver = {
|
|
|
1087
1288
|
}
|
|
1088
1289
|
for (const b of Array.isArray(msg.content) ? msg.content : []) {
|
|
1089
1290
|
if (b.type === "tool_use") {
|
|
1090
|
-
emit({
|
|
1291
|
+
emit({
|
|
1292
|
+
...base(threadId, currentTurnId()),
|
|
1293
|
+
type: "item.started",
|
|
1294
|
+
itemType: "tool",
|
|
1295
|
+
itemId: b.id,
|
|
1296
|
+
title: b.name,
|
|
1297
|
+
summary: commandSummary(b.input),
|
|
1298
|
+
});
|
|
1091
1299
|
}
|
|
1092
1300
|
}
|
|
1093
1301
|
if (msg.usage) {
|
|
@@ -1240,6 +1448,67 @@ export const ClaudeDriver = {
|
|
|
1240
1448
|
})();
|
|
1241
1449
|
return;
|
|
1242
1450
|
}
|
|
1451
|
+
// A --resume that the CLI never acknowledged: it exited without
|
|
1452
|
+
// an `init` frame, so it never read the prompt and this turn has
|
|
1453
|
+
// caused nothing. Without this the thread is BRICKED — the dead
|
|
1454
|
+
// cursor is never cleared, so every later turn resumes the same
|
|
1455
|
+
// missing session and fails identically, and the user has no way
|
|
1456
|
+
// back except switching engines. One fresh session, carrying the
|
|
1457
|
+
// harness's rebuild of the conversation. Exactly one: the relaunch
|
|
1458
|
+
// offers no cursor, so `attempted` is false there and a second
|
|
1459
|
+
// failure is reported like any other.
|
|
1460
|
+
const resumeFailure = classifyResumeFailure({
|
|
1461
|
+
attempted: Boolean(sessionId),
|
|
1462
|
+
rejected: !session.sawInit,
|
|
1463
|
+
promptSubmitted: session.sawInit,
|
|
1464
|
+
producedOutput: session.turn.sawStreamDelta,
|
|
1465
|
+
});
|
|
1466
|
+
if (mayReplay(resumeFailure) && !retry.cancelled) {
|
|
1467
|
+
const recovery = recoveryPromptFor({
|
|
1468
|
+
recoveryText: turn.recoveryText,
|
|
1469
|
+
currentText: turn.text,
|
|
1470
|
+
failure: resumeFailure,
|
|
1471
|
+
});
|
|
1472
|
+
session.broker?.close();
|
|
1473
|
+
session.broker = undefined;
|
|
1474
|
+
if (session.mcpConfigPath) {
|
|
1475
|
+
try {
|
|
1476
|
+
rmSync(dirname(session.mcpConfigPath), { recursive: true, force: true });
|
|
1477
|
+
}
|
|
1478
|
+
catch { }
|
|
1479
|
+
session.mcpConfigPath = null;
|
|
1480
|
+
}
|
|
1481
|
+
if (session.systemPromptPath) {
|
|
1482
|
+
removePrivateTempDir(session.systemPromptPath);
|
|
1483
|
+
session.systemPromptPath = null;
|
|
1484
|
+
}
|
|
1485
|
+
sessions.delete(threadId);
|
|
1486
|
+
session.turn = null;
|
|
1487
|
+
active.delete(threadId);
|
|
1488
|
+
emit({
|
|
1489
|
+
...base(threadId, turnId),
|
|
1490
|
+
type: "turn.retrying",
|
|
1491
|
+
attempt: retry.attempt + 1,
|
|
1492
|
+
delayMs: 0,
|
|
1493
|
+
reason: "resume_rejected",
|
|
1494
|
+
});
|
|
1495
|
+
void (async () => {
|
|
1496
|
+
try {
|
|
1497
|
+
// no cursor: a fresh session, carrying the rebuild
|
|
1498
|
+
await sendTurn({ ...turn, resumeCursor: undefined, recoveryText: undefined, text: recovery.text });
|
|
1499
|
+
}
|
|
1500
|
+
catch (e) {
|
|
1501
|
+
retryState.delete(threadId);
|
|
1502
|
+
emit({
|
|
1503
|
+
...base(threadId, turnId),
|
|
1504
|
+
type: "runtime.error",
|
|
1505
|
+
message: e instanceof Error ? e.message : String(e),
|
|
1506
|
+
});
|
|
1507
|
+
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "exit_before_result", cost: null });
|
|
1508
|
+
}
|
|
1509
|
+
})();
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1243
1512
|
retryState.delete(threadId);
|
|
1244
1513
|
emit({
|
|
1245
1514
|
...base(threadId, currentTurnId()),
|
|
@@ -1298,11 +1567,13 @@ export const ClaudeDriver = {
|
|
|
1298
1567
|
});
|
|
1299
1568
|
if (!version)
|
|
1300
1569
|
return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
|
|
1570
|
+
cliVersion = parseClaudeCliVersion(version);
|
|
1301
1571
|
const auth = await claudeAuthStatus(config.cli, env);
|
|
1302
1572
|
// claudeEnvironment strips ANTHROPIC_API_KEY, so turns run on the
|
|
1303
1573
|
// CLI's own login (Pro/Max): the cost it reports is what the call
|
|
1304
1574
|
// WOULD bill, not a charge
|
|
1305
|
-
|
|
1575
|
+
const update = claudeCliUpdate(version, config.cli);
|
|
1576
|
+
return { state: "available", version, ...auth, ...(update ? { update } : {}), billing: "subscription" };
|
|
1306
1577
|
};
|
|
1307
1578
|
/** One-shot Claude call with the prompt on stdin, never argv. Approval
|
|
1308
1579
|
* summaries can contain paths, commands, or secrets, so the generic
|
|
@@ -1376,6 +1647,7 @@ export const ClaudeDriver = {
|
|
|
1376
1647
|
getAuthentication: (flowId) => login.get(flowId),
|
|
1377
1648
|
completeAuthentication: (flowId, code) => login.complete(flowId, code),
|
|
1378
1649
|
cancelAuthentication: () => login.cancel(),
|
|
1650
|
+
signOut: () => login.signOut(),
|
|
1379
1651
|
adapter: {
|
|
1380
1652
|
provider: DRIVER_KIND,
|
|
1381
1653
|
capabilities: {
|
|
@@ -1412,7 +1684,7 @@ export const ClaudeDriver = {
|
|
|
1412
1684
|
stopAll: async () => {
|
|
1413
1685
|
for (const { stop } of active.values())
|
|
1414
1686
|
stop();
|
|
1415
|
-
for (const threadId of
|
|
1687
|
+
for (const threadId of Array.from(sessions.keys()))
|
|
1416
1688
|
closeSession(threadId, "stopAll");
|
|
1417
1689
|
},
|
|
1418
1690
|
onEvent: (listener) => {
|
|
@@ -1423,11 +1695,16 @@ export const ClaudeDriver = {
|
|
|
1423
1695
|
generateText: (prompt) => generateReview(prompt),
|
|
1424
1696
|
reviewPermission: generateReview,
|
|
1425
1697
|
dispose: async () => {
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1698
|
+
try {
|
|
1699
|
+
await login.dispose();
|
|
1700
|
+
}
|
|
1701
|
+
finally {
|
|
1702
|
+
for (const { stop } of active.values())
|
|
1703
|
+
stop();
|
|
1704
|
+
for (const threadId of Array.from(sessions.keys()))
|
|
1705
|
+
closeSession(threadId, "dispose");
|
|
1706
|
+
listeners.clear();
|
|
1707
|
+
}
|
|
1431
1708
|
},
|
|
1432
1709
|
};
|
|
1433
1710
|
},
|
|
@@ -22,6 +22,7 @@ import { codexLocalProviderArgs } from "./local-inject.js";
|
|
|
22
22
|
import { augmentedPath, splitCliString } from "../env-path.js";
|
|
23
23
|
import { classifyError, computeBackoff, RETRY_MAX_ATTEMPTS } from "./retry.js";
|
|
24
24
|
import { appendNative } from "./native.js";
|
|
25
|
+
import { commandSummary } from "../tool-summary.js";
|
|
25
26
|
import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instructions.js";
|
|
26
27
|
import { CodexDeviceAuthController } from "./codex-device-auth.js";
|
|
27
28
|
import { codexAccountEmail } from "./codex-identity.js";
|
|
@@ -434,7 +435,7 @@ export const CodexDriver = {
|
|
|
434
435
|
const listeners = new Set();
|
|
435
436
|
const active = new Map();
|
|
436
437
|
const emit = (event) => {
|
|
437
|
-
for (const l of
|
|
438
|
+
for (const l of Array.from(listeners))
|
|
438
439
|
l(event);
|
|
439
440
|
};
|
|
440
441
|
const base = (threadId, turnId) => ({
|
|
@@ -571,7 +572,7 @@ export const CodexDriver = {
|
|
|
571
572
|
if (state.settled)
|
|
572
573
|
return;
|
|
573
574
|
state.settled = true;
|
|
574
|
-
for (const finish of
|
|
575
|
+
for (const finish of Array.from(asks.values()))
|
|
575
576
|
finish("deny", "OpenMausBot: the turn ended", "system");
|
|
576
577
|
for (const p of rpcPending.values())
|
|
577
578
|
p.reject(new Error("turn settled"));
|
|
@@ -738,8 +739,16 @@ export const CodexDriver = {
|
|
|
738
739
|
: item.type === "webSearch"
|
|
739
740
|
? "web_search"
|
|
740
741
|
: null;
|
|
741
|
-
if (title)
|
|
742
|
-
emit({
|
|
742
|
+
if (title) {
|
|
743
|
+
emit({
|
|
744
|
+
...base(threadId, turnId),
|
|
745
|
+
type: "item.started",
|
|
746
|
+
itemType: "tool",
|
|
747
|
+
itemId: item.id,
|
|
748
|
+
title,
|
|
749
|
+
summary: item.type === "commandExecution" ? commandSummary({ command: item.command }) : undefined,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
743
752
|
break;
|
|
744
753
|
}
|
|
745
754
|
case "item/completed": {
|
|
@@ -389,7 +389,7 @@ export const PiDriver = {
|
|
|
389
389
|
// one active turn per thread
|
|
390
390
|
const active = new Map();
|
|
391
391
|
const emit = (event) => {
|
|
392
|
-
for (const l of
|
|
392
|
+
for (const l of Array.from(listeners))
|
|
393
393
|
l(event);
|
|
394
394
|
};
|
|
395
395
|
const base = (threadId, turnId) => ({
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Install an engine's command-line app from Settings, on the machine that
|
|
2
|
+
// runs this server, as this process's own user, into a directory the app
|
|
3
|
+
// owns. No sudo, no shell, no terminal: the package name comes from the
|
|
4
|
+
// driver's own install descriptor and never from a request, npm runs with
|
|
5
|
+
// a fixed argument list, and the binary is found on the engines' PATH
|
|
6
|
+
// afterwards because that directory is registered ahead of everything else.
|
|
7
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { stripVTControlCharacters } from "node:util";
|
|
10
|
+
import { DATA_DIR, stripWorkspaceCredentialEnv } from "./config.js";
|
|
11
|
+
import { augmentedPath, findCliCandidates, registerPathDir, resetPathCache } from "./env-path.js";
|
|
12
|
+
import { killCliTree, spawnCli } from "./procs.js";
|
|
13
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60_000;
|
|
15
|
+
/** npm's global prefix for engines the app installs itself. */
|
|
16
|
+
export function enginesPrefix(baseDir = DATA_DIR) {
|
|
17
|
+
return join(baseDir, "tools", "npm");
|
|
18
|
+
}
|
|
19
|
+
/** Where that prefix puts executables: `bin/` on POSIX, the prefix itself on Windows. */
|
|
20
|
+
export function enginesBinDir(baseDir = DATA_DIR, platform = process.platform) {
|
|
21
|
+
return platform === "win32" ? enginesPrefix(baseDir) : join(enginesPrefix(baseDir), "bin");
|
|
22
|
+
}
|
|
23
|
+
/** Called once at boot: engines installed here win over any other copy on PATH. */
|
|
24
|
+
export function registerEnginesBinDir(baseDir = DATA_DIR) {
|
|
25
|
+
registerPathDir(enginesBinDir(baseDir));
|
|
26
|
+
}
|
|
27
|
+
/** The npm package a driver's own install one-liner names, when it is one. */
|
|
28
|
+
export function npmPackageOf(install) {
|
|
29
|
+
const line = install?.command?.linux ?? install?.command?.darwin ?? install?.command?.win32;
|
|
30
|
+
const match = line ? /^npm install -g ((?:@[\w.-]+\/)?[\w.-]+)$/.exec(line.trim()) : null;
|
|
31
|
+
return match ? match[1] : null;
|
|
32
|
+
}
|
|
33
|
+
export function npmAvailable() {
|
|
34
|
+
return findCliCandidates("npm").length > 0;
|
|
35
|
+
}
|
|
36
|
+
/** What Settings may install for this engine on this machine, or null. A
|
|
37
|
+
* managed engine keeps its own verified download; anything else needs a
|
|
38
|
+
* plain npm package and npm on PATH. */
|
|
39
|
+
export function serverInstallFor(install, npmPresent = npmAvailable()) {
|
|
40
|
+
if (!install || install.managed)
|
|
41
|
+
return null;
|
|
42
|
+
const pkg = npmPackageOf(install);
|
|
43
|
+
return pkg && npmPresent ? { package: pkg } : null;
|
|
44
|
+
}
|
|
45
|
+
const running = new Map();
|
|
46
|
+
/** Install or update one npm engine. Concurrent clicks share one npm run. */
|
|
47
|
+
export function installNpmEngine(pkg, options = {}) {
|
|
48
|
+
const key = `${options.baseDir ?? DATA_DIR} ${pkg}`;
|
|
49
|
+
const existing = running.get(key);
|
|
50
|
+
if (existing)
|
|
51
|
+
return existing;
|
|
52
|
+
const run = installOnce(pkg, options).finally(() => running.delete(key));
|
|
53
|
+
running.set(key, run);
|
|
54
|
+
return run;
|
|
55
|
+
}
|
|
56
|
+
async function installOnce(pkg, options) {
|
|
57
|
+
const prefix = enginesPrefix(options.baseDir);
|
|
58
|
+
mkdirSync(prefix, { recursive: true });
|
|
59
|
+
const env = {
|
|
60
|
+
...(options.env ?? process.env),
|
|
61
|
+
PATH: options.path ?? augmentedPath(),
|
|
62
|
+
NO_COLOR: "1",
|
|
63
|
+
npm_config_update_notifier: "false",
|
|
64
|
+
npm_config_fund: "false",
|
|
65
|
+
npm_config_audit: "false",
|
|
66
|
+
};
|
|
67
|
+
// Workspace credentials (xai/box/voice keys) are not npm's to see.
|
|
68
|
+
stripWorkspaceCredentialEnv(env);
|
|
69
|
+
// npm 11 skips a dependency's install script unless the package is named
|
|
70
|
+
// here; the engines that need one (Claude Code) are exactly these.
|
|
71
|
+
const args = ["install", "-g", "--prefix", prefix, "--loglevel=error", `--allow-scripts=${pkg}`, `${pkg}@latest`];
|
|
72
|
+
const result = await runNpm(args, env, prefix, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
73
|
+
if (result.code !== 0) {
|
|
74
|
+
throw new Error(`npm could not install ${pkg} on this server.${tail(result.output)}`);
|
|
75
|
+
}
|
|
76
|
+
// Whatever the boot registration did, the directory we just filled must
|
|
77
|
+
// be on the engines' PATH from here on.
|
|
78
|
+
registerPathDir(enginesBinDir(options.baseDir));
|
|
79
|
+
resetPathCache();
|
|
80
|
+
if (options.cli) {
|
|
81
|
+
const binDir = enginesBinDir(options.baseDir);
|
|
82
|
+
if (!findCliCandidates(options.cli).some((path) => path.startsWith(binDir))) {
|
|
83
|
+
throw new Error(`${pkg} installed, but it did not provide a \`${options.cli}\` command. Check the package name in this engine's install descriptor.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function tail(output) {
|
|
88
|
+
const clean = stripVTControlCharacters(output).trim();
|
|
89
|
+
if (!clean)
|
|
90
|
+
return "";
|
|
91
|
+
const lines = clean.split(/\r?\n/).filter((line) => line.trim()).slice(-6);
|
|
92
|
+
return `\n${lines.join("\n").slice(-600)}`;
|
|
93
|
+
}
|
|
94
|
+
function runNpm(args, env, cwd, timeoutMs) {
|
|
95
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
96
|
+
let child;
|
|
97
|
+
try {
|
|
98
|
+
child = spawnCli("npm", args, { env, cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
rejectRun(new Error("npm could not start on this server. Install Node.js with npm for the user running OpenMausBot, then try again."));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
child.stdin.end();
|
|
105
|
+
let output = "";
|
|
106
|
+
const receive = (chunk) => {
|
|
107
|
+
if (output.length < MAX_OUTPUT)
|
|
108
|
+
output += chunk.toString("utf8").slice(0, MAX_OUTPUT - output.length);
|
|
109
|
+
};
|
|
110
|
+
child.stdout.on("data", receive);
|
|
111
|
+
child.stderr.on("data", receive);
|
|
112
|
+
let timedOut = false;
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
timedOut = true;
|
|
115
|
+
void killCliTree(child);
|
|
116
|
+
}, timeoutMs);
|
|
117
|
+
timer.unref();
|
|
118
|
+
child.once("error", (error) => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
rejectRun(new Error(error.code === "ENOENT"
|
|
121
|
+
? "npm is not installed on this server. Install Node.js with npm for the user running OpenMausBot, then try again."
|
|
122
|
+
: "npm could not start on this server. Check that Node.js is installed for the user running OpenMausBot."));
|
|
123
|
+
});
|
|
124
|
+
child.once("close", (code) => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
if (timedOut) {
|
|
127
|
+
rejectRun(new Error("The install took too long and was stopped. Check the server's network connection and try again."));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
resolveRun({ code, output });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** True once something has been installed here, for status pages. */
|
|
135
|
+
export function enginesInstalled(baseDir = DATA_DIR) {
|
|
136
|
+
return existsSync(enginesBinDir(baseDir));
|
|
137
|
+
}
|