openmausbot 0.1.70 → 0.1.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/assets/index-BFPYw6P-.js +296 -0
  2. package/dist/assets/{index-D1RGhLUK.js → index-Bpai86ua.js} +1 -1
  3. package/dist/assets/index-D1odUdTi.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/listener.js +12 -2
  6. package/dist-server/container-mcp.js +28 -2
  7. package/dist-server/drivers/agents-proxy.js +202 -16
  8. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  9. package/dist-server/index.js +9443 -3023
  10. package/dist-server/local-computer.js +6 -1
  11. package/dist-server/mcp-gate.js +543 -0
  12. package/dist-server/mcp-server.js +38 -0
  13. package/dist-server/openmausbot.js +1893 -583
  14. package/dist-server/pair-cli.js +1893 -583
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/agent-tool-policy.js +29 -0
  17. package/dist-server/server/bot-overview.js +28 -0
  18. package/dist-server/server/bot-package.js +55 -2
  19. package/dist-server/server/browser-engine.js +2 -1
  20. package/dist-server/server/browser-live.js +12 -7
  21. package/dist-server/server/calendar-calls.js +1 -0
  22. package/dist-server/server/cli.js +102 -1
  23. package/dist-server/server/config.js +94 -6
  24. package/dist-server/server/drivers/acp/core.js +2 -0
  25. package/dist-server/server/drivers/acp/cursor.js +1 -1
  26. package/dist-server/server/drivers/acp/grok.js +5 -1
  27. package/dist-server/server/drivers/agents-proxy.js +195 -16
  28. package/dist-server/server/drivers/boxagent.js +1 -1
  29. package/dist-server/server/drivers/claude.js +301 -23
  30. package/dist-server/server/drivers/codex.js +13 -4
  31. package/dist-server/server/drivers/pi.js +1 -1
  32. package/dist-server/server/fleet-agent.js +161 -0
  33. package/dist-server/server/fleet-cli.js +225 -0
  34. package/dist-server/server/fleet-client.js +45 -0
  35. package/dist-server/server/fleet.js +410 -0
  36. package/dist-server/server/harness/bus.js +1 -1
  37. package/dist-server/server/index.js +836 -201
  38. package/dist-server/server/local-computer.js +5 -1
  39. package/dist-server/server/mcp-gate-config.js +56 -0
  40. package/dist-server/server/mcp-gate.js +227 -0
  41. package/dist-server/server/mcp-registry.js +71 -3
  42. package/dist-server/server/mcp-trim.js +182 -0
  43. package/dist-server/server/memory-journal.js +440 -0
  44. package/dist-server/server/memory-store.js +308 -0
  45. package/dist-server/server/message-db.js +90 -3
  46. package/dist-server/server/package-export.js +25 -0
  47. package/dist-server/server/peer-approval.js +1 -1
  48. package/dist-server/server/prices.js +16 -0
  49. package/dist-server/server/provider-auth-sessions.js +3 -0
  50. package/dist-server/server/provider-key-check.js +66 -0
  51. package/dist-server/server/proxy-paths.js +1 -0
  52. package/dist-server/server/redact.js +15 -0
  53. package/dist-server/server/resume-recovery.js +46 -0
  54. package/dist-server/server/routines.js +1 -0
  55. package/dist-server/server/screen-frame-gate.js +9 -6
  56. package/dist-server/server/skill-learn.js +15 -1
  57. package/dist-server/server/skill-library.js +5 -5
  58. package/dist-server/server/spend.js +61 -0
  59. package/dist-server/server/store.js +7 -3
  60. package/dist-server/server/system-prompt.js +28 -6
  61. package/dist-server/server/tailscale.js +2 -2
  62. package/dist-server/server/team-backup.js +35 -0
  63. package/dist-server/server/tool-summary.js +39 -0
  64. package/dist-server/server/tts/speech-text.js +3 -1
  65. package/dist-server/server/turn-context.js +18 -0
  66. package/dist-server/server/usage-ledger.js +239 -0
  67. package/dist-server/server/webhook-ingress.js +7 -2
  68. package/dist-server/server/workspace-backup-http.js +264 -0
  69. package/dist-server/server/workspace-backup-maintenance.js +40 -0
  70. package/dist-server/server/workspace-backup-policy.js +28 -0
  71. package/dist-server/server/workspace-backup.js +1022 -0
  72. package/dist-server/server/workspace.js +321 -22
  73. package/dist-server/shared/approval-mode.js +1 -1
  74. package/dist-server/shared/learn-request.js +6 -0
  75. package/dist-server/shared/team-backup.js +13 -2
  76. package/dist-server/shared/workspace-backup-client.js +15 -0
  77. package/dist-server/shared/workspace-backup.js +1 -0
  78. package/dist-server/vps-container-mcp.js +28 -2
  79. package/package.json +1 -1
  80. package/skills/create-verification-skill/SKILL.md +2 -3
  81. package/dist/assets/index-B2F__IRS.css +0 -1
  82. package/dist/assets/index-CLj3t5Ba.js +0 -280
@@ -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";
@@ -45,10 +48,14 @@ function claudeAuthStatus(cli, env, run = execCli) {
45
48
  const candidateEmail = boundedText(identity.email, 254);
46
49
  const email = candidateEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(candidateEmail) ? candidateEmail : undefined;
47
50
  const organization = boundedText(identity.orgName, 160);
48
- resolve({
49
- authenticated: true,
50
- ...(email || organization ? { account: { ...(email ? { email } : {}), ...(organization ? { organization } : {}) } } : {}),
51
- });
51
+ // An API key is a workspace decision, not a person: say so instead
52
+ // of showing an empty identity.
53
+ const account = {
54
+ ...(email ? { email } : {}),
55
+ ...(organization ? { organization } : {}),
56
+ ...(identity.authMethod === "api_key" ? { method: "api-key" } : {}),
57
+ };
58
+ resolve({ authenticated: true, ...(Object.keys(account).length ? { account } : {}) });
52
59
  }
53
60
  catch {
54
61
  resolve({ authenticated: false });
@@ -127,10 +134,140 @@ function claudeEnvironment(model, source = process.env, configDir, instanceEnvir
127
134
  // env-injected at boot); none of them are this CLI's to see.
128
135
  stripWorkspaceCredentialEnv(env);
129
136
  const applied = applyClaudeInject(env, model);
130
- if (!applied.injected)
137
+ // A key set on purpose for this workspace (Settings → Connections, carried
138
+ // in the instance environment) stays. One riding along in the parent's
139
+ // env never does: it would flip a subscription login to pay-as-you-go.
140
+ if (!applied.injected && !instanceEnvironment.ANTHROPIC_API_KEY)
131
141
  delete env.ANTHROPIC_API_KEY;
132
142
  return env;
133
143
  }
144
+ /** Escape hatch back to the pre-isolation launch, where a bot inherited this
145
+ * machine's Claude Code setup: its MCP servers and connectors, skills,
146
+ * agents, hooks and personal CLAUDE.md. Set it only to recover a bot that
147
+ * genuinely depended on a user- or local-scope MCP server; the supported way
148
+ * to give a bot a server is the app's own `mcpServers` config or the bot
149
+ * project's `.mcp.json`. */
150
+ function inheritsUserConfig(env) {
151
+ return env.OMB_CLAUDE_INHERIT_USER_CONFIG === "1";
152
+ }
153
+ /** MCP servers the bot's own project declares in `<cwd>/.mcp.json`.
154
+ *
155
+ * The CLI would find this file itself, but the harness launches it with
156
+ * --strict-mcp-config, which makes the harness's config the only source.
157
+ * The project file IS part of the bot's definition (its cwd is chosen per
158
+ * bot), so it is forwarded verbatim — including `type: "http"`/`"sse"`
159
+ * entries the harness never mounts itself, because the CLI, not this code,
160
+ * is what has to understand them. A malformed file is ignored rather than
161
+ * failing the turn: an unreadable project config must not brick a bot. */
162
+ function projectMcpServers(cwd) {
163
+ let parsed;
164
+ try {
165
+ parsed = JSON.parse(readFileSync(join(cwd, ".mcp.json"), "utf8"));
166
+ }
167
+ catch {
168
+ return {};
169
+ }
170
+ const servers = parsed?.mcpServers;
171
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
172
+ return {};
173
+ const out = {};
174
+ for (const [name, server] of Object.entries(servers)) {
175
+ if (server && typeof server === "object" && !Array.isArray(server))
176
+ out[name] = server;
177
+ }
178
+ return out;
179
+ }
180
+ /** The CLI compacts its own session when it approaches a window. Left alone
181
+ * that window is the model's, so a Sonnet 5 session runs to something near a
182
+ * million tokens before anything happens — and every model call until then
183
+ * re-reads the whole thing. The measured food-ordering thread sat at 330k
184
+ * tokens per call and looked perfectly healthy to the CLI.
185
+ *
186
+ * So the harness picks the window instead. This delegates the actual
187
+ * compaction to the CLI, which owns the session and already has a summarizer
188
+ * for it; the harness only decides when it is worth paying for.
189
+ *
190
+ * OMB_CLAUDE_AUTOCOMPACT takes a token count, "auto" to hand the decision
191
+ * back to the CLI, or "off" to pass nothing at all. The CLI rejects a window
192
+ * outside 100k-1M as a hard argument error, so a configured value is clamped
193
+ * rather than passed through: a mistyped setting must not fail every turn. */
194
+ export function autoCompactWindow(env) {
195
+ const raw = (env.OMB_CLAUDE_AUTOCOMPACT ?? "").trim().toLowerCase();
196
+ if (raw === "off")
197
+ return null;
198
+ if (raw === "auto")
199
+ return "auto";
200
+ const parsed = raw ? Number(raw) : DEFAULT_AUTOCOMPACT_TOKENS;
201
+ if (!Number.isFinite(parsed) || parsed <= 0)
202
+ return String(DEFAULT_AUTOCOMPACT_TOKENS);
203
+ return String(Math.min(1_000_000, Math.max(100_000, Math.floor(parsed))));
204
+ }
205
+ /** Generous for real work, and still a third of where a 1M-window session
206
+ * would otherwise get to. With bot tool results gated (mcp-gate.ts) most
207
+ * threads never reach it; this is the backstop for the ones that do. */
208
+ const DEFAULT_AUTOCOMPACT_TOKENS = 200_000;
209
+ /** The Claude CLI version that first accepted each flag the harness passes
210
+ * for context control. An unknown flag is a hard argument error, so passing
211
+ * one to an older CLI would fail every turn rather than degrade; each flag
212
+ * is therefore only passed to a CLI known to accept it.
213
+ *
214
+ * Verified against the published binaries, not the changelog (which never
215
+ * records `--autocompact`): `--strict-mcp-config` is present in 1.0.60 and
216
+ * absent from 1.0.0; `--setting-sources` first appears in 1.0.122 (1.0.120
217
+ * lacks it); `--autocompact` first appears in 2.1.122 (2.1.121 lacks it). */
218
+ export const CLAUDE_FLAG_FLOORS = {
219
+ "--strict-mcp-config": [1, 0, 60],
220
+ "--setting-sources": [1, 0, 122],
221
+ "--autocompact": [2, 1, 122],
222
+ };
223
+ /** The newest floor above: a CLI at or past it accepts everything the
224
+ * harness sends. Below it the engine still works, minus the flags the CLI
225
+ * predates, and the Engines page suggests an update. */
226
+ export const CLAUDE_CONTEXT_CONTROL_MIN_VERSION = CLAUDE_FLAG_FLOORS["--autocompact"];
227
+ /** `claude --version` prints "2.1.232 (Claude Code)"; the first dotted triple
228
+ * is the version. Null when nothing parses, e.g. a wrapper that prints its
229
+ * own banner first — see claudeCliSupports for how that is treated. */
230
+ export function parseClaudeCliVersion(stdout) {
231
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout ?? "");
232
+ if (!match)
233
+ return null;
234
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
235
+ }
236
+ function versionAtLeast(installed, floor) {
237
+ for (let i = 0; i < 3; i += 1) {
238
+ if (installed[i] !== floor[i])
239
+ return installed[i] > floor[i];
240
+ }
241
+ return true;
242
+ }
243
+ /** Whether a CLI reporting `version` accepts `flag`. A version that could
244
+ * not be parsed counts as current: every CLI that predates a floor prints a
245
+ * plain "x.y.z (Claude Code)", so an unreadable version is far more likely
246
+ * a newer wrapper than an old build, and withholding the flags from a modern
247
+ * CLI would silently re-open the context leak this file exists to close. */
248
+ export function claudeCliSupports(version, flag) {
249
+ return version === null || versionAtLeast(version, CLAUDE_FLAG_FLOORS[flag]);
250
+ }
251
+ /** The Engines-page notice for a CLI older than the newest floor. The engine
252
+ * keeps working: turns run without the flags the CLI predates, which means
253
+ * no harness-picked compaction window and, on a very old CLI, no isolation
254
+ * from this machine's own Claude Code setup. */
255
+ export function claudeCliUpdate(version, cli) {
256
+ const parsed = parseClaudeCliVersion(version);
257
+ if (!parsed || versionAtLeast(parsed, CLAUDE_CONTEXT_CONTROL_MIN_VERSION))
258
+ return undefined;
259
+ const floor = CLAUDE_CONTEXT_CONTROL_MIN_VERSION.join(".");
260
+ const missing = Object.keys(CLAUDE_FLAG_FLOORS)
261
+ .filter((flag) => !claudeCliSupports(parsed, flag));
262
+ return {
263
+ title: "Update Claude Code for context controls",
264
+ message: `Claude Code ${parsed.join(".")} predates ${floor}, so bots run without ${missing.join(", ")}: ` +
265
+ "no compaction window picked by OpenMausBot" +
266
+ (missing.includes("--setting-sources") ? ", and bots still see this machine's own Claude Code setup" : "") +
267
+ ". Update it, then refresh Engines.",
268
+ command: cli === "claude" ? "claude update" : `${cli} update`,
269
+ };
270
+ }
134
271
  const DRIVER_KIND = "claudeAgent";
135
272
  // model catalog ported from upstream packages/contracts/src/model.ts
136
273
  export const STATIC_CLAUDE_MODELS = {
@@ -235,15 +372,7 @@ function systemEndedReply(kind) {
235
372
  }
236
373
  /** One human-readable line for an ask — what the card subtitle shows. */
237
374
  function askSummary(ask) {
238
- const input = ask.input ?? {};
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);
375
+ return askInputSummary(ask.input) ?? ask.tool ?? "tool";
247
376
  }
248
377
  export function permissionSocketPath(threadId) {
249
378
  // A readable prefix alone is not unique: ids that agree on their first
@@ -435,7 +564,7 @@ export async function createPermissionBroker(opts) {
435
564
  if (!server)
436
565
  throw new Error("claude: permission broker could not bind a local socket");
437
566
  const drain = () => {
438
- for (const p of [...pending.values()]) {
567
+ for (const p of Array.from(pending.values())) {
439
568
  const { behavior, message } = systemEndedReply(p.ask.kind);
440
569
  p.finish(behavior, message, "system");
441
570
  }
@@ -524,6 +653,17 @@ function firstText(content) {
524
653
  /** Claude's stream-json input accepts the same image source blocks as the
525
654
  * Anthropic Messages API. Keep the old string form for text-only turns so a
526
655
  * CLI update cannot disturb the overwhelmingly common path. */
656
+ /** How a mid-session change to the volatile half of the system prompt
657
+ * reaches a model whose process was launched with the old copy. The CLI's
658
+ * own out-of-band convention inside a user turn, and it costs one short
659
+ * append rather than a relaunch that re-uploads the whole prompt cache. */
660
+ function withVolatileNote(text, volatile) {
661
+ const body = volatile.trim()
662
+ ? `This part of your instructions changed since this session started. It replaces the earlier copy:\n\n${volatile.trim()}`
663
+ : "The notes that were in your instructions when this session started have been cleared.";
664
+ const note = `<system-reminder>\n${body}\n</system-reminder>`;
665
+ return text ? `${note}\n\n${text}` : note;
666
+ }
527
667
  function claudeUserMessage(text, images) {
528
668
  if (!images?.length)
529
669
  return { type: "user", message: { role: "user", content: text } };
@@ -594,6 +734,15 @@ export const ClaudeDriver = {
594
734
  }
595
735
  };
596
736
  await refreshModels();
737
+ // The installed CLI's version as snapshot() last read it, so a flag the
738
+ // CLI does not know is never passed to it (CLAUDE_FLAG_FLOORS). The
739
+ // harness snapshots every instance whenever it describes them — app
740
+ // load, the Engines page, and right after `claude update`, which is
741
+ // exactly when the answer changes — so a turn normally finds it filled.
742
+ // A turn before any snapshot assumes a current CLI rather than paying a
743
+ // CLI start-up of its own: the flags are the default, the exception is
744
+ // the older install, and the next snapshot corrects it.
745
+ let cliVersion = null;
597
746
  const listeners = new Set();
598
747
  // one active turn per thread; a second send while busy is a caller bug
599
748
  const active = new Map();
@@ -659,7 +808,7 @@ export const ClaudeDriver = {
659
808
  });
660
809
  };
661
810
  const emit = (event) => {
662
- for (const l of [...listeners])
811
+ for (const l of Array.from(listeners))
663
812
  l(event);
664
813
  };
665
814
  const base = (threadId, turnId) => ({
@@ -718,6 +867,26 @@ export const ClaudeDriver = {
718
867
  args.push("--disallowedTools", config.disallowedTools.join(","));
719
868
  }
720
869
  const turnEnvironment = environment();
870
+ const isolated = !inheritsUserConfig(turnEnvironment);
871
+ if (isolated) {
872
+ // A bot gets the tools and instructions its owner gave it, not
873
+ // whatever this machine's Claude Code happens to be set up with.
874
+ // Without these the CLI silently adds, to EVERY turn of every bot:
875
+ // the desktop's own MCP servers and claude.ai connectors (one
876
+ // measured desktop mounted 407 extra tools, ~10k tokens), its skill
877
+ // and agent listings, its hooks, and its personal CLAUDE.md. Every
878
+ // model call in the session then re-reads all of it.
879
+ // Each flag only on a CLI that accepts it: an unknown flag is an
880
+ // argument error that would fail every turn (CLAUDE_FLAG_FLOORS).
881
+ if (claudeCliSupports(cliVersion, "--strict-mcp-config"))
882
+ args.push("--strict-mcp-config");
883
+ if (claudeCliSupports(cliVersion, "--setting-sources"))
884
+ args.push("--setting-sources", "project");
885
+ }
886
+ const compactWindow = autoCompactWindow(turnEnvironment);
887
+ if (compactWindow && claudeCliSupports(cliVersion, "--autocompact")) {
888
+ args.push("--autocompact", compactWindow);
889
+ }
721
890
  const turnModel = await resolveClaudeTurnModel(turn.model, turnEnvironment);
722
891
  const injected = applyClaudeInject({ ...turnEnvironment }, turnModel);
723
892
  if (injected.model)
@@ -794,10 +963,39 @@ export const ClaudeDriver = {
794
963
  // routes every custom tool call through the ogb permission broker
795
964
  // into an Allow/Deny card. Reserved names were filtered upstream;
796
965
  // skip any residual collision instead of clobbering a built-in.
966
+ // Bot-owned servers, gated below: they are the ones that answer for a
967
+ // machine rather than for a context window.
968
+ const botOwned = new Set();
797
969
  for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
798
- if (name in mcpServers)
970
+ if (Object.hasOwn(mcpServers, name))
799
971
  continue;
800
972
  mcpServers[name] = { ...server };
973
+ botOwned.add(name);
974
+ }
975
+ // --strict-mcp-config (above) makes this config the CLI's only source
976
+ // of MCP servers, so a server the bot's OWN project declares would
977
+ // otherwise vanish with the machine's. Merge it last: a project file
978
+ // can add servers but never shadow a harness-owned mount.
979
+ if (isolated && turn.cwd) {
980
+ for (const [name, server] of Object.entries(projectMcpServers(turn.cwd))) {
981
+ if (Object.hasOwn(mcpServers, name))
982
+ continue;
983
+ mcpServers[name] = server;
984
+ botOwned.add(name);
985
+ }
986
+ }
987
+ // One tool call can put more into the conversation than the whole rest
988
+ // of the session: a single product search measured 60-140 KB of JSON,
989
+ // and the CLI re-reads it on every later model call. The harness never
990
+ // sees these calls — the CLI runs the server itself — so the only place
991
+ // to stand is between the two processes. Harness-owned mounts (the
992
+ // permission broker, computer, browser, agents, dweb) are already
993
+ // bounded and are deliberately left alone.
994
+ const budget = resultBudget(turnEnvironment);
995
+ for (const name of botOwned) {
996
+ const gated = gateServer({ name, server: mcpServers[name], threadId, budget, nodeEnv: NODE_ENV_FLAG });
997
+ if (gated)
998
+ mcpServers[name] = gated;
801
999
  }
802
1000
  // Keep ask_user available even in Full access. Native bypass skips
803
1001
  // permission prompts, not questions requiring a person's answer.
@@ -832,7 +1030,9 @@ export const ClaudeDriver = {
832
1030
  const keyArgs = args.filter((a, i) => !privateFileFlags.has(a) && !privateFileFlags.has(args[i - 1] ?? ""));
833
1031
  const argsKey = JSON.stringify({
834
1032
  args: keyArgs,
835
- system: turn.system ?? null,
1033
+ // the volatile half is deliberately absent: it must not respawn a
1034
+ // healthy session (see Session.volatile)
1035
+ system: turn.systemStable ?? turn.system ?? null,
836
1036
  mcpServers,
837
1037
  cwd,
838
1038
  model: injected.model ?? null,
@@ -852,7 +1052,12 @@ export const ClaudeDriver = {
852
1052
  killCliTree(live.child);
853
1053
  }, turnId, broker: live.broker });
854
1054
  emit({ ...base(threadId, turnId), type: "turn.started" });
855
- const written = await writeUser(live, threadId, promptMsg);
1055
+ const volatile = turn.systemVolatile ?? "";
1056
+ const message = volatile === live.volatile
1057
+ ? promptMsg
1058
+ : claudeUserMessage(withVolatileNote(turn.text, volatile), turn.images);
1059
+ live.volatile = volatile;
1060
+ const written = await writeUser(live, threadId, message);
856
1061
  if (!written) {
857
1062
  active.delete(threadId);
858
1063
  live.turn = null;
@@ -982,7 +1187,9 @@ export const ClaudeDriver = {
982
1187
  mcpConfigPath,
983
1188
  systemPromptPath,
984
1189
  argsKey,
1190
+ volatile: turn.systemVolatile ?? "",
985
1191
  sessionId: sessionId ?? newSessionId,
1192
+ sawInit: false,
986
1193
  turn: { turnId, settled: false, sawStreamDelta: false },
987
1194
  idleTimer: null,
988
1195
  closing: false,
@@ -1035,6 +1242,7 @@ export const ClaudeDriver = {
1035
1242
  switch (o.type) {
1036
1243
  case "system":
1037
1244
  if (o.subtype === "init") {
1245
+ session.sawInit = true;
1038
1246
  if (typeof o.session_id === "string")
1039
1247
  session.sessionId = o.session_id;
1040
1248
  emit({ ...base(threadId, currentTurnId()), type: "session.started", sessionId: o.session_id, model: o.model });
@@ -1087,7 +1295,14 @@ export const ClaudeDriver = {
1087
1295
  }
1088
1296
  for (const b of Array.isArray(msg.content) ? msg.content : []) {
1089
1297
  if (b.type === "tool_use") {
1090
- emit({ ...base(threadId, currentTurnId()), type: "item.started", itemType: "tool", itemId: b.id, title: b.name });
1298
+ emit({
1299
+ ...base(threadId, currentTurnId()),
1300
+ type: "item.started",
1301
+ itemType: "tool",
1302
+ itemId: b.id,
1303
+ title: b.name,
1304
+ summary: commandSummary(b.input),
1305
+ });
1091
1306
  }
1092
1307
  }
1093
1308
  if (msg.usage) {
@@ -1240,6 +1455,67 @@ export const ClaudeDriver = {
1240
1455
  })();
1241
1456
  return;
1242
1457
  }
1458
+ // A --resume that the CLI never acknowledged: it exited without
1459
+ // an `init` frame, so it never read the prompt and this turn has
1460
+ // caused nothing. Without this the thread is BRICKED — the dead
1461
+ // cursor is never cleared, so every later turn resumes the same
1462
+ // missing session and fails identically, and the user has no way
1463
+ // back except switching engines. One fresh session, carrying the
1464
+ // harness's rebuild of the conversation. Exactly one: the relaunch
1465
+ // offers no cursor, so `attempted` is false there and a second
1466
+ // failure is reported like any other.
1467
+ const resumeFailure = classifyResumeFailure({
1468
+ attempted: Boolean(sessionId),
1469
+ rejected: !session.sawInit,
1470
+ promptSubmitted: session.sawInit,
1471
+ producedOutput: session.turn.sawStreamDelta,
1472
+ });
1473
+ if (mayReplay(resumeFailure) && !retry.cancelled) {
1474
+ const recovery = recoveryPromptFor({
1475
+ recoveryText: turn.recoveryText,
1476
+ currentText: turn.text,
1477
+ failure: resumeFailure,
1478
+ });
1479
+ session.broker?.close();
1480
+ session.broker = undefined;
1481
+ if (session.mcpConfigPath) {
1482
+ try {
1483
+ rmSync(dirname(session.mcpConfigPath), { recursive: true, force: true });
1484
+ }
1485
+ catch { }
1486
+ session.mcpConfigPath = null;
1487
+ }
1488
+ if (session.systemPromptPath) {
1489
+ removePrivateTempDir(session.systemPromptPath);
1490
+ session.systemPromptPath = null;
1491
+ }
1492
+ sessions.delete(threadId);
1493
+ session.turn = null;
1494
+ active.delete(threadId);
1495
+ emit({
1496
+ ...base(threadId, turnId),
1497
+ type: "turn.retrying",
1498
+ attempt: retry.attempt + 1,
1499
+ delayMs: 0,
1500
+ reason: "resume_rejected",
1501
+ });
1502
+ void (async () => {
1503
+ try {
1504
+ // no cursor: a fresh session, carrying the rebuild
1505
+ await sendTurn({ ...turn, resumeCursor: undefined, recoveryText: undefined, text: recovery.text });
1506
+ }
1507
+ catch (e) {
1508
+ retryState.delete(threadId);
1509
+ emit({
1510
+ ...base(threadId, turnId),
1511
+ type: "runtime.error",
1512
+ message: e instanceof Error ? e.message : String(e),
1513
+ });
1514
+ emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "exit_before_result", cost: null });
1515
+ }
1516
+ })();
1517
+ return;
1518
+ }
1243
1519
  retryState.delete(threadId);
1244
1520
  emit({
1245
1521
  ...base(threadId, currentTurnId()),
@@ -1298,11 +1574,13 @@ export const ClaudeDriver = {
1298
1574
  });
1299
1575
  if (!version)
1300
1576
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
1577
+ cliVersion = parseClaudeCliVersion(version);
1301
1578
  const auth = await claudeAuthStatus(config.cli, env);
1302
1579
  // claudeEnvironment strips ANTHROPIC_API_KEY, so turns run on the
1303
1580
  // CLI's own login (Pro/Max): the cost it reports is what the call
1304
1581
  // WOULD bill, not a charge
1305
- return { state: "available", version, ...auth, billing: "subscription" };
1582
+ const update = claudeCliUpdate(version, config.cli);
1583
+ return { state: "available", version, ...auth, ...(update ? { update } : {}), billing: "subscription" };
1306
1584
  };
1307
1585
  /** One-shot Claude call with the prompt on stdin, never argv. Approval
1308
1586
  * summaries can contain paths, commands, or secrets, so the generic
@@ -1413,7 +1691,7 @@ export const ClaudeDriver = {
1413
1691
  stopAll: async () => {
1414
1692
  for (const { stop } of active.values())
1415
1693
  stop();
1416
- for (const threadId of [...sessions.keys()])
1694
+ for (const threadId of Array.from(sessions.keys()))
1417
1695
  closeSession(threadId, "stopAll");
1418
1696
  },
1419
1697
  onEvent: (listener) => {
@@ -1430,7 +1708,7 @@ export const ClaudeDriver = {
1430
1708
  finally {
1431
1709
  for (const { stop } of active.values())
1432
1710
  stop();
1433
- for (const threadId of [...sessions.keys()])
1711
+ for (const threadId of Array.from(sessions.keys()))
1434
1712
  closeSession(threadId, "dispose");
1435
1713
  listeners.clear();
1436
1714
  }
@@ -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 [...listeners])
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 [...asks.values()])
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({ ...base(threadId, turnId), type: "item.started", itemType: "tool", itemId: item.id, title });
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 [...listeners])
392
+ for (const l of Array.from(listeners))
393
393
  l(event);
394
394
  };
395
395
  const base = (threadId, turnId) => ({