openmausbot 0.1.84 → 0.1.85

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 (71) hide show
  1. package/dist/assets/index-Ba9G44HI.js +310 -0
  2. package/dist/assets/{index-DNa2umw-.js → index-CTNNoqSJ.js} +1 -1
  3. package/dist/assets/index-CkRNp7wX.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +1 -0
  6. package/dist-server/container-mcp.js +212 -7
  7. package/dist-server/drivers/agents-proxy.js +135 -12
  8. package/dist-server/enterprise/server/index.js +1 -0
  9. package/dist-server/hooks/omb-hook.js +74 -0
  10. package/dist-server/index.js +8051 -6252
  11. package/dist-server/local-computer-proxy.js +83 -1
  12. package/dist-server/local-computer.js +14 -2
  13. package/dist-server/openmausbot.js +759 -301
  14. package/dist-server/pair-cli.js +759 -301
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/agent-tool-policy.js +1 -0
  17. package/dist-server/server/bot-package.js +2 -0
  18. package/dist-server/server/box.js +23 -4
  19. package/dist-server/server/browser-engine.js +87 -10
  20. package/dist-server/server/browser-live.js +7 -5
  21. package/dist-server/server/browser-runtime.js +65 -10
  22. package/dist-server/server/checkpoints.js +62 -18
  23. package/dist-server/server/cli-prompts.js +3 -1
  24. package/dist-server/server/commands.js +27 -0
  25. package/dist-server/server/compaction-summary.js +78 -0
  26. package/dist-server/server/config.js +59 -2
  27. package/dist-server/server/context-budget.js +29 -0
  28. package/dist-server/server/context-rebuild.js +72 -0
  29. package/dist-server/server/digest.js +130 -0
  30. package/dist-server/server/drivers/acp/core.js +540 -229
  31. package/dist-server/server/drivers/agents-proxy.js +51 -12
  32. package/dist-server/server/drivers/agents-result.js +23 -0
  33. package/dist-server/server/drivers/claude.js +62 -10
  34. package/dist-server/server/drivers/codex.js +6 -0
  35. package/dist-server/server/drivers/openai-chat.js +66 -45
  36. package/dist-server/server/drivers/openai-compat.js +1 -0
  37. package/dist-server/server/hooks/omb-hook.js +103 -0
  38. package/dist-server/server/index.js +1127 -365
  39. package/dist-server/server/local-computer.js +21 -2
  40. package/dist-server/server/mcp-bridge.js +10 -2
  41. package/dist-server/server/mcp-tool-images.js +37 -0
  42. package/dist-server/server/mcp-tool-schema.js +108 -0
  43. package/dist-server/server/message-db.js +80 -21
  44. package/dist-server/server/message-file.js +3 -2
  45. package/dist-server/server/package-export.js +1 -0
  46. package/dist-server/server/peer-roster.js +4 -2
  47. package/dist-server/server/provider-icon.js +20 -0
  48. package/dist-server/server/proxy-paths.js +1 -0
  49. package/dist-server/server/request-auth.js +1 -0
  50. package/dist-server/server/room-handoffs.js +16 -2
  51. package/dist-server/server/routine-requests.js +39 -1
  52. package/dist-server/server/routines.js +43 -9
  53. package/dist-server/server/shared-computer-control.js +31 -4
  54. package/dist-server/server/steer-queue.js +6 -0
  55. package/dist-server/server/store.js +94 -14
  56. package/dist-server/server/system-prompt.js +4 -4
  57. package/dist-server/server/team-backup.js +9 -1
  58. package/dist-server/server/tool-results.js +72 -0
  59. package/dist-server/server/tts/grok.js +74 -0
  60. package/dist-server/server/tts/index.js +19 -1
  61. package/dist-server/server/webhooks.js +28 -0
  62. package/dist-server/shared/approval-mode.js +7 -1
  63. package/dist-server/shared/digest.js +1 -0
  64. package/dist-server/shared/markdown-windows-paths.js +36 -0
  65. package/dist-server/shared/provider-icon.js +110 -0
  66. package/dist-server/shared/team-backup.js +3 -0
  67. package/dist-server/vps-container-mcp.js +217 -12
  68. package/enterprise/server/index.js +1 -0
  69. package/package.json +1 -1
  70. package/dist/assets/index-BbU5REzd.js +0 -310
  71. package/dist/assets/index-CLGfYlx_.css +0 -1
@@ -38,6 +38,7 @@ import { CREDENTIAL_TARGETS, isCredentialTargetId } from "../../shared/credentia
38
38
  import { normalizeCronSchedule } from "../../shared/routine-schedule.js";
39
39
  import { agentToolAnnotations } from "../agent-tool-policy.js";
40
40
  import { peerName } from "../peer-roster.js";
41
+ import { boundedAgentResult } from "./agents-result.js";
41
42
  const HARNESS = process.env.OMB_HARNESS_URL ?? "http://127.0.0.1:8799";
42
43
  const BOT_ID = process.env.OMB_BOT_ID ?? "";
43
44
  const THREAD_ID = process.env.OMB_THREAD_ID ?? "";
@@ -349,8 +350,8 @@ const ROUTINE_FIELDS_SCHEMA = {
349
350
  schedule: ROUTINE_SCHEDULE_SCHEMA,
350
351
  run_on: {
351
352
  type: "string",
352
- enum: ["maus", "cloud"],
353
- description: "Where the routine runs. Defaults to maus (this OpenMausBot setup).",
353
+ enum: ["maus", "box"],
354
+ description: "Default maus keeps the bot's selected model and configured computer, INCLUDING a self-hosted VPS. Omit this field for normal schedules. box explicitly switches the agent to the Box-hosted runner; it requires Box setup and is not the generic cloud/VPS option. Legacy cloud values from list_routines mean box, not VPS.",
354
355
  },
355
356
  timeout_minutes: {
356
357
  type: "integer",
@@ -366,9 +367,26 @@ const ROUTINE_FIELDS_SCHEMA = {
366
367
  type: "boolean",
367
368
  description: "Opt in to using the latest completed run's bounded report as historical context. Defaults to false; set false in an update to start fresh again. Included in the applied result or pending confirmation.",
368
369
  },
370
+ overlap: {
371
+ type: "string",
372
+ enum: ["skip", "queue"],
373
+ description: "While this routine is still working, skip scheduled occurrences (default) or queue at most one run. Queue skips further occurrences until the pending run starts; it never builds an unlimited backlog. Manual and webhook requests are separate.",
374
+ },
369
375
  };
370
376
  const PROPOSAL_OUTCOME = " Read the result: granted Full Access may apply the change immediately. If applied, continue the requested work without another confirmation. Only a pending result requires ending the turn and waiting for the in-app decision. Never claim success from the permission mode alone; report failed or cancelled results honestly. This does not elevate another bot's execution permissions.";
371
377
  const TOOLS = [
378
+ {
379
+ name: "tool_result_read",
380
+ description: "Read a missing portion of an oversized agents-tool result using the saved id and next offset from its notice. Returns at most 16,000 characters, only from this bot in this conversation. Use only when the preview is insufficient; do not load every page by default. Results expire after one hour, on app restart, or under cache pressure. This never reruns the original action.",
381
+ inputSchema: {
382
+ type: "object", additionalProperties: false,
383
+ properties: {
384
+ id: { type: "string", description: "Saved result id copied from the truncation notice." },
385
+ offset: { type: "integer", minimum: 0, description: "Character offset copied from the previous result's notice. Defaults to 0." },
386
+ },
387
+ required: ["id"],
388
+ },
389
+ },
372
390
  {
373
391
  name: "list_shared_computers",
374
392
  description: "List online desktop computers explicitly shared with this workspace, and their allowed folders/capabilities. These are the user's computers, not this server. An offline or unshared computer cannot be accessed. Folder paths use opaque folder IDs and relative paths.",
@@ -412,7 +430,7 @@ const TOOLS = [
412
430
  },
413
431
  {
414
432
  name: "ask_bot",
415
- description: "SYNCHRONOUS consultation: send a short question to another bot and stay blocked until its reply is returned inline. Use only when that reply is required to write your current response. Do not use for assigning work, background tasks, or potentially long work; use delegate_bot for those. Returns promptly with a note if that bot is busy.",
433
+ description: "Brief synchronous consultation: send a short question to another bot. Quick replies return inline; slow replies become asynchronous delegations and return automatically after you finish your turn. Use only when that reply is required to write your current response. Do not use for assigning work, background tasks, or potentially long work; use delegate_bot for those. Returns promptly with a note if that bot is busy.",
416
434
  inputSchema: {
417
435
  type: "object",
418
436
  properties: {
@@ -854,6 +872,8 @@ async function api(path, init) {
854
872
  throw new Error(String(body.error ?? `HTTP ${status}`));
855
873
  return body;
856
874
  }
875
+ const capResult = (text) => boundedAgentResult(text, (retained, truncated) => api("/api/internal/tool-result", { method: "POST", signal: AbortSignal.timeout(3_000),
876
+ body: JSON.stringify({ text: retained, truncated }) }));
857
877
  /** Like api, but a refusal comes back as its body instead of an Error —
858
878
  * for the tools whose refusals carry more than a sentence. */
859
879
  async function apiResponse(path, init) {
@@ -884,16 +904,17 @@ function routineFields(args) {
884
904
  const fields = {};
885
905
  // list_routines returns the harness names. Accept those when a model
886
906
  // copies back a definition, as we already do for interval fields.
887
- if (args.run_on != null && args.runOn != null && args.run_on !== args.runOn) {
907
+ const destination = (value) => value === "box" ? "cloud" : value;
908
+ if (args.run_on != null && args.runOn != null && destination(args.run_on) !== destination(args.runOn)) {
888
909
  return { fields, error: "Choose one run_on destination; run_on and runOn disagree." };
889
910
  }
890
911
  if (args.timeout_minutes != null && args.timeoutMinutes != null && args.timeout_minutes !== args.timeoutMinutes) {
891
912
  return { fields, error: "Choose one timeout_minutes limit; timeout_minutes and timeoutMinutes disagree." };
892
913
  }
893
- const runOn = args.run_on ?? args.runOn;
914
+ const runOn = destination(args.run_on ?? args.runOn);
894
915
  const timeoutMinutes = args.timeout_minutes ?? args.timeoutMinutes;
895
916
  if (runOn != null && runOn !== "maus" && runOn !== "cloud") {
896
- return { fields, error: 'run_on must be "maus" or "cloud".' };
917
+ return { fields, error: 'Use run_on="maus" for the bot’s current model and configured computer (including VPS), or run_on="box" only for the Box-hosted agent. Legacy "cloud" also means Box.' };
897
918
  }
898
919
  if (timeoutMinutes != null && (typeof timeoutMinutes !== "number" || !Number.isInteger(timeoutMinutes) || timeoutMinutes < 5 || timeoutMinutes > 240)) {
899
920
  return { fields, error: "timeout_minutes must be a whole number from 5 to 240. Use clear_timeout to remove a limit." };
@@ -901,6 +922,9 @@ function routineFields(args) {
901
922
  if (args.continuity != null && typeof args.continuity !== "boolean") {
902
923
  return { fields, error: "continuity must be true or false." };
903
924
  }
925
+ if (args.overlap !== undefined && args.overlap !== "skip" && args.overlap !== "queue") {
926
+ return { fields, error: "overlap must be skip or queue." };
927
+ }
904
928
  if (args.clear_timeout != null && typeof args.clear_timeout !== "boolean") {
905
929
  return { fields, error: "clear_timeout must be true or false." };
906
930
  }
@@ -925,6 +949,8 @@ function routineFields(args) {
925
949
  fields.timeoutMinutes = timeoutMinutes;
926
950
  if (typeof args.continuity === "boolean")
927
951
  fields.continuity = args.continuity;
952
+ if (args.overlap !== undefined)
953
+ fields.overlap = args.overlap;
928
954
  return { fields };
929
955
  }
930
956
  /** Full Access is decided by the harness, not inferred from a model claim or
@@ -965,6 +991,17 @@ function recallSpeaker(hit) {
965
991
  return hit.role === "user" ? "user" : "you";
966
992
  }
967
993
  async function callTool(name, args) {
994
+ if (name === "tool_result_read") {
995
+ if (typeof args.id !== "string" || !/^r-[0-9a-f-]{36}$/.test(args.id) ||
996
+ (args.offset !== undefined && (!Number.isSafeInteger(args.offset) || Number(args.offset) < 0))) {
997
+ return { text: "Use the saved result id and a non-negative integer offset from its notice.", isError: true };
998
+ }
999
+ const r = await api(`/api/internal/tool-result?id=${encodeURIComponent(args.id)}&offset=${args.offset ?? 0}`, { signal: AbortSignal.timeout(3_000) });
1000
+ const text = String(r.text ?? "");
1001
+ return { text: `${text}\n\n[${Number(r.nextOffset) < Number(r.length)
1002
+ ? `Read more with tool_result_read id "${args.id}" and offset ${r.nextOffset}.`
1003
+ : `End of retained result.${r.truncated ? " The original tail exceeded the storage limit and was omitted." : ""}`}]` };
1004
+ }
968
1005
  if (name === "list_room_targets") {
969
1006
  const r = await api("/api/internal/room-targets");
970
1007
  return { text: JSON.stringify(r), ...(r.error ? { isError: true } : {}) };
@@ -1081,9 +1118,11 @@ async function callTool(name, args) {
1081
1118
  const taskId = String(r.taskId ?? "").trim();
1082
1119
  if (taskId)
1083
1120
  delegationTaskIdsThisTurn.add(taskId);
1084
- const waitedMinutes = Math.max(1, Math.round((Number(r.waitedMs) || 0) / 60_000));
1121
+ const waitedSeconds = Math.max(1, Math.round((Number(r.waitedMs) || 0) / 1000));
1122
+ const amount = waitedSeconds < 60 ? waitedSeconds : Math.round(waitedSeconds / 60);
1123
+ const unit = waitedSeconds < 60 ? "second" : "minute";
1085
1124
  return {
1086
- text: `${r.toBotName ?? "That bot"} is still working after ${waitedMinutes} minute${waitedMinutes === 1 ? "" : "s"} — the ask was converted to a delegation so the reply is not lost. Task id: ${taskId}. Finish your turn now; the result will be delivered to this conversation automatically. Use check_delegation in a later turn only if the user asks for status.`,
1125
+ text: `${r.toBotName ?? "That bot"} is still working after ${amount} ${unit}${amount === 1 ? "" : "s"} — the ask was converted to a delegation so the reply is not lost. Task id: ${taskId}. Finish your turn now; the result will be delivered to this conversation automatically. Use check_delegation in a later turn only if the user asks for status.`,
1087
1126
  };
1088
1127
  }
1089
1128
  if (r.busy) {
@@ -1734,7 +1773,7 @@ async function handle(msg) {
1734
1773
  return;
1735
1774
  }
1736
1775
  if (name === "list_shared_computers") {
1737
- textResult(id, JSON.stringify(await api("/api/internal/shared-computers")));
1776
+ textResult(id, await capResult(JSON.stringify(await api("/api/internal/shared-computers"))));
1738
1777
  return;
1739
1778
  }
1740
1779
  if (name === "shared_computer") {
@@ -1743,14 +1782,14 @@ async function handle(msg) {
1743
1782
  if (Array.isArray(result?.content))
1744
1783
  ok(id, result);
1745
1784
  else
1746
- textResult(id, JSON.stringify(result));
1785
+ textResult(id, await capResult(JSON.stringify(result)));
1747
1786
  return;
1748
1787
  }
1749
1788
  const { text, isError } = await callTool(name, (params.arguments ?? {}));
1750
- textResult(id, text, isError);
1789
+ textResult(id, name === "tool_result_read" ? text : await capResult(text), isError);
1751
1790
  }
1752
1791
  catch (e) {
1753
- textResult(id, e.message, true);
1792
+ textResult(id, await capResult(e.message), true);
1754
1793
  }
1755
1794
  return;
1756
1795
  }
@@ -0,0 +1,23 @@
1
+ import { redactSecretsInText } from "../../shared/redact.js";
2
+ import { TOOL_RESULT_MAX_CHARS, TOOL_RESULT_PREVIEW_CHARS, toolResultPrefix } from "../tool-results.js";
3
+ /** The operation already happened. Saving overflow must never retry it or
4
+ * turn a successful operation into a failed MCP call. Only cache I/O is timed. */
5
+ export async function boundedAgentResult(text, save) {
6
+ if (text.length <= 24_000)
7
+ return text;
8
+ const redacted = redactSecretsInText(text);
9
+ const prefix = toolResultPrefix(redacted, TOOL_RESULT_PREVIEW_CHARS);
10
+ const retained = toolResultPrefix(redacted, TOOL_RESULT_MAX_CHARS);
11
+ const truncated = retained.length < redacted.length;
12
+ try {
13
+ const saved = await save(retained, truncated);
14
+ if (!saved || typeof saved.id !== "string" || !/^r-[0-9a-f-]{36}$/.test(saved.id))
15
+ throw new Error("Invalid saved result");
16
+ return `${prefix}\n\n[Large tool result: showing the first ${prefix.length} characters. ${truncated || saved.truncated
17
+ ? "Only a bounded portion was retained; the remaining tail was omitted."
18
+ : "The remaining redacted result is temporarily saved."} If a missing detail is needed, call tool_result_read with id "${saved.id}" and offset ${prefix.length}. Saved results expire after one hour, on app restart, or under cache pressure. Do not repeat an action just to retrieve its output.]`;
19
+ }
20
+ catch {
21
+ return `${prefix}\n\n[Large tool result: showing the first ${prefix.length} characters. The remaining output could not be saved. The original operation was not retried. Do not repeat an action just to retrieve its output.]`;
22
+ }
23
+ }
@@ -9,11 +9,12 @@
9
9
  // - the bot's cloud computer (box.ascii.dev) via server/computer-proxy.ts
10
10
  // — screenshot/exec/open_url, the CUA-on-the-box bridge
11
11
  import { createHash, randomBytes } from "node:crypto";
12
- import { chmodSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
13
13
  import { createServer as createNetServer } from "node:net";
14
14
  import { homedir, tmpdir } from "node:os";
15
15
  import { join, dirname, isAbsolute, normalize } from "node:path";
16
16
  import { DATA_DIR, stripWorkspaceCredentialEnv } from "../config.js";
17
+ import { writeFileAtomic } from "../atomic.js";
17
18
  import { augmentedPath } from "../env-path.js";
18
19
  import { brokerSocketPath, describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
19
20
  import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
@@ -25,6 +26,7 @@ import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS }
25
26
  import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
26
27
  import { appendNative } from "./native.js";
27
28
  import { SPAWNED_PROXIES } from "../proxy-paths.js";
29
+ import { extractMcpImages } from "../mcp-tool-images.js";
28
30
  import { ASK_USER_QUESTION_TOOL, askQuestionSummary, parseAskQuestions, parseChoices, questionChoices, } from "../../shared/ask-question.js";
29
31
  /** Whether `claude` has been signed in.
30
32
  *
@@ -392,6 +394,7 @@ export function readClaudeModelCatalog(env = process.env) {
392
394
  // far. See server/proxy-paths.ts.
393
395
  const PERM_PROXY_PATH = SPAWNED_PROXIES.permission;
394
396
  const DWEB_PROXY_PATH = SPAWNED_PROXIES.dweb;
397
+ const HOOK_HELPER_PATH = SPAWNED_PROXIES.hook;
395
398
  // in the packaged app process.execPath is the Electron binary — this env
396
399
  // makes it behave as plain node for the spawned MCP proxies (harmless in dev)
397
400
  const NODE_ENV_FLAG = { ELECTRON_RUN_AS_NODE: "1" };
@@ -430,6 +433,26 @@ function askSummary(ask) {
430
433
  return askQuestionSummary(questions).slice(0, 300);
431
434
  return askInputSummary(ask.input) ?? ask.tool ?? "tool";
432
435
  }
436
+ /** Where the hook helper reads this thread's current turn token. Stable per
437
+ * thread (so the CLI's environment can name it once) and private. */
438
+ export function hookTokenFile(threadId, botId) {
439
+ const digest = createHash("sha256").update(`${botId ?? ""}\0${threadId}`).digest("hex").slice(0, 24);
440
+ return join(DATA_DIR, "hook-tokens", `${digest}.token`);
441
+ }
442
+ /** The `hooks` block for the private --settings file: one command for each
443
+ * event the harness observes. Claude Code runs it with the event JSON on
444
+ * stdin and applies any hookSpecificOutput it prints. The command string is
445
+ * a shell line, so both paths are quoted (this repo's own path has a space). */
446
+ export function claudeHookSettings(helperPath) {
447
+ // JSON quoting is not shell quoting: $(), backticks and $names still
448
+ // expand inside double quotes on POSIX. Windows paths come through env
449
+ // variables so their backslashes are not JSON-escaped into the command.
450
+ const command = process.platform === "win32"
451
+ ? '"%OMB_HOOK_NODE%" "%OMB_HOOK_HELPER%"'
452
+ : [process.execPath, helperPath].map(path => `'${path.replace(/'/g, "'\\''")}'`).join(" ");
453
+ const entry = [{ matcher: "", hooks: [{ type: "command", command, timeout: 5 }] }];
454
+ return { PostToolUse: entry, PreCompact: entry, SessionStart: entry, Stop: entry };
455
+ }
433
456
  export function permissionSocketPath(threadId, botId) {
434
457
  // A readable prefix alone is not unique: ids that agree on their first
435
458
  // characters ("t-perm-dup-1", "t-perm-dup-2") would share a socket. POSIX
@@ -953,7 +976,7 @@ export const ClaudeDriver = {
953
976
  // a retry relaunches the whole CLI; the backoff is scaled down in tests
954
977
  // so a fake's transient failures don't stall real seconds
955
978
  const retryScale = Number(process.env.FAKE_CLAUDE_RETRY_SCALE ?? "1");
956
- const sessionId = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
979
+ const sessionId = !turn.sessionReset && typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
957
980
  const newSessionId = sessionId ? null : newId();
958
981
  const args = [
959
982
  "-p",
@@ -1140,7 +1163,28 @@ export const ClaudeDriver = {
1140
1163
  const env = environment(turnModel);
1141
1164
  const authSettings = isolated && !injected.injected
1142
1165
  ? readClaudeAuthSettings(env, input.environment) : {};
1143
- const authSettingsPath = mcpConfigPath && Object.keys(authSettings).length
1166
+ // Harness hooks (item 0.2): one helper command for the events the
1167
+ // harness observes. The helper reads its bearer from a per-thread file
1168
+ // the harness rewrites every turn, so a long-lived CLI process never
1169
+ // presents a stale token. Registered through the same private
1170
+ // --settings file as the auth override; both are 0600 and per launch.
1171
+ const hooks = turn.integrations?.hooks;
1172
+ const hookTokenPath = hooks ? hookTokenFile(threadId, botId) : null;
1173
+ if (hooks && hookTokenPath) {
1174
+ mkdirSync(dirname(hookTokenPath), { recursive: true, mode: 0o700 });
1175
+ writeFileAtomic(hookTokenPath, hooks.token, { mode: 0o600 });
1176
+ env.OMB_HOOK_URL = hooks.url;
1177
+ env.OMB_HOOK_TOKEN_FILE = hookTokenPath;
1178
+ env.OMB_HOOK_NODE = process.execPath;
1179
+ env.OMB_HOOK_HELPER = HOOK_HELPER_PATH;
1180
+ // in the packaged app process.execPath is Electron — run the helper as node
1181
+ if (process.versions.electron)
1182
+ env.ELECTRON_RUN_AS_NODE = "1";
1183
+ }
1184
+ const settings = { ...authSettings };
1185
+ if (hooks)
1186
+ settings.hooks = claudeHookSettings(HOOK_HELPER_PATH);
1187
+ const authSettingsPath = mcpConfigPath && Object.keys(settings).length
1144
1188
  ? join(dirname(mcpConfigPath), "auth-settings.json") : null;
1145
1189
  if (authSettingsPath)
1146
1190
  args.push("--settings", authSettingsPath);
@@ -1163,6 +1207,8 @@ export const ClaudeDriver = {
1163
1207
  model: injected.model ?? null,
1164
1208
  base: env.ANTHROPIC_BASE_URL ?? null,
1165
1209
  configDir: env.CLAUDE_CONFIG_DIR ?? null,
1210
+ // hooks on/off changes the settings file the process was launched with
1211
+ hooks: Boolean(hooks),
1166
1212
  // Rotating an account's key/helper must not reuse the old process.
1167
1213
  auth: createHash("sha256").update(JSON.stringify({
1168
1214
  settings: authSettings,
@@ -1170,10 +1216,10 @@ export const ClaudeDriver = {
1170
1216
  })).digest("hex"),
1171
1217
  });
1172
1218
  // Reuse the live process when it is idle, unchanged, and is the session
1173
- // the harness wants resumed. Anything else: close it and spawn fresh
1174
- // (with --resume, so the conversation continues in the new process).
1219
+ // the harness wants resumed. Clearing a cursor alone does not opt out
1220
+ // of legacy reuse: an explicit rebuild must discard the idle context.
1175
1221
  const live = sessions.get(threadId);
1176
- if (live && !live.turn && !live.closing && live.child.exitCode === null && live.argsKey === argsKey && (!sessionId || sessionId === live.sessionId)) {
1222
+ if (!turn.sessionReset && live && !live.turn && !live.closing && live.child.exitCode === null && live.argsKey === argsKey && (!sessionId || sessionId === live.sessionId)) {
1177
1223
  if (live.idleTimer)
1178
1224
  clearTimeout(live.idleTimer);
1179
1225
  live.turn = { turnId, input: turn, retryAbort, settled: false, sawStreamDelta: false };
@@ -1213,7 +1259,7 @@ export const ClaudeDriver = {
1213
1259
  return { turnId };
1214
1260
  }
1215
1261
  if (live)
1216
- closeSession(threadId, "spawn contract changed");
1262
+ closeSession(threadId, turn.sessionReset ? "context reset" : "spawn contract changed");
1217
1263
  // Until sessions.set() below, this turn owns every launch resource.
1218
1264
  // Any bind, private-config or synchronous spawn failure must release
1219
1265
  // them here rather than leave a live listener or credential temp file.
@@ -1308,7 +1354,7 @@ export const ClaudeDriver = {
1308
1354
  writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
1309
1355
  }
1310
1356
  if (authSettingsPath) {
1311
- writeFileSync(authSettingsPath, JSON.stringify(authSettings), { mode: 0o600 });
1357
+ writeFileSync(authSettingsPath, JSON.stringify(settings), { mode: 0o600 });
1312
1358
  }
1313
1359
  if (sessionId)
1314
1360
  args.push("--resume", sessionId);
@@ -1493,6 +1539,9 @@ export const ClaudeDriver = {
1493
1539
  for (const b of Array.isArray(o.message?.content) ? o.message.content : []) {
1494
1540
  if (b.type === "tool_result") {
1495
1541
  emit({ ...base(threadId, currentTurnId()), type: "item.completed", itemType: "tool", itemId: b.tool_use_id, ok: !b.is_error, output: toolDetailPreview(b.content) });
1542
+ for (const img of extractMcpImages(b.content)) {
1543
+ emit({ ...base(threadId, currentTurnId()), type: "item.completed", itemType: "assistant_image", data: img.data });
1544
+ }
1496
1545
  }
1497
1546
  }
1498
1547
  break;
@@ -1627,7 +1676,9 @@ export const ClaudeDriver = {
1627
1676
  active.set(threadId, { stop: () => { retry.cancelled = true; retryAbort.abort(); }, turnId });
1628
1677
  try {
1629
1678
  const cursor = session.sessionId ?? sessionId ?? undefined;
1630
- await sendTurn({ ...turn, resumeCursor: cursor }, turnId);
1679
+ // The reset was consumed by the initial launch. Retry the
1680
+ // new session, never the context that launch replaced.
1681
+ await sendTurn({ ...turn, sessionReset: false, resumeCursor: cursor }, turnId);
1631
1682
  }
1632
1683
  catch (e) {
1633
1684
  if (active.get(threadId)?.turnId === turnId)
@@ -1887,6 +1938,7 @@ export const ClaudeDriver = {
1887
1938
  // Harness turns reassert a per-bot mode and restore the broker even
1888
1939
  // when an old instance was configured with bypassPermissions.
1889
1940
  localComputerMcp: true,
1941
+ hooks: true,
1890
1942
  },
1891
1943
  sendTurn,
1892
1944
  steer,
@@ -1914,7 +1966,7 @@ export const ClaudeDriver = {
1914
1966
  return () => listeners.delete(listener);
1915
1967
  },
1916
1968
  },
1917
- generateText: (prompt) => generateReview(prompt),
1969
+ generateText: (prompt, options) => generateReview(prompt, options?.signal),
1918
1970
  reviewPermission: generateReview,
1919
1971
  dispose: async () => {
1920
1972
  try {
@@ -25,6 +25,7 @@ import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instr
25
25
  import { CodexDeviceAuthController } from "./codex-device-auth.js";
26
26
  import { codexAccountEmail } from "./codex-identity.js";
27
27
  import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
28
+ import { extractMcpImages } from "../mcp-tool-images.js";
28
29
  export { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.js";
29
30
  const DRIVER_KIND = "codex";
30
31
  const ASTRA_MODEL_ID = "gpt-6-astra";
@@ -1039,6 +1040,11 @@ export const CodexDriver = {
1039
1040
  ok: item.status !== "failed" && item.status !== "declined",
1040
1041
  output: toolDetailPreview(item.type === "commandExecution" ? { output: item.aggregatedOutput, exitCode: item.exitCode } : item.type === "mcpToolCall" ? item.error ?? item.result : item.type === "fileChange" ? item.changes : item.action),
1041
1042
  });
1043
+ if (item.type === "mcpToolCall") {
1044
+ for (const img of extractMcpImages(item.result)) {
1045
+ emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_image", data: img.data });
1046
+ }
1047
+ }
1042
1048
  }
1043
1049
  else if (item.type === "reasoning") {
1044
1050
  emit({ ...base(threadId, turnId), type: "item.updated", itemType: "reasoning", tokens: null });
@@ -115,11 +115,67 @@ export function createOpenAIChatRuntime(options) {
115
115
  const reader = response.body.getReader();
116
116
  const decoder = new TextDecoder();
117
117
  let buffer = "";
118
+ const consumeDataLine = (line, atEof = false) => {
119
+ if (!line.startsWith("data:"))
120
+ return false;
121
+ const data = line.slice(5).trim();
122
+ if (data === "[DONE]")
123
+ return true;
124
+ let chunk;
125
+ try {
126
+ chunk = JSON.parse(data);
127
+ }
128
+ catch {
129
+ // A tail left in the buffer when the socket closed is an INCOMPLETE
130
+ // frame, not a bad one: a stop, an abort or a dropped connection all
131
+ // end mid-frame. Only a properly newline-terminated frame that will
132
+ // not parse means the provider actually sent something malformed.
133
+ // Counting the tail here turned a stopped turn into a hard,
134
+ // non-retryable failure (`calls.finish(finishReason, malformedFrame)`).
135
+ if (!atEof)
136
+ malformedFrame = true;
137
+ return false;
138
+ }
139
+ const chunkError = providerError(chunk);
140
+ if (chunkError)
141
+ throw new ChatProtocolError(`provider returned a streaming completion error: ${chunkError.slice(0, 200)}`);
142
+ const choice = chunk.choices?.find((row) => row.index === undefined || row.index === 0);
143
+ const delta = choice?.delta;
144
+ if (object(delta))
145
+ sawChoice = true;
146
+ if (delta?.function_call)
147
+ throw new ChatProtocolError("legacy function_call is unsupported; use structured tool_calls");
148
+ calls.add(delta?.tool_calls, true);
149
+ details.add(delta?.reasoning_details);
150
+ if (choice?.finish_reason)
151
+ finishReason = choice.finish_reason;
152
+ const reasoningPart = delta?.reasoning_content ?? delta?.reasoning;
153
+ if (typeof reasoningPart === "string")
154
+ protocolReasoning += reasoningPart;
155
+ const reasoningDelta = options.reasoning && typeof reasoningPart === "string"
156
+ ? reasoningPart
157
+ : "";
158
+ const contentDelta = typeof delta?.content === "string" ? delta.content : "";
159
+ if (reasoningDelta) {
160
+ reasoning += reasoningDelta;
161
+ onDelta?.(reasoningDelta, "reasoning_text");
162
+ }
163
+ if (contentDelta) {
164
+ text += contentDelta;
165
+ onDelta?.(contentDelta, "assistant_text");
166
+ }
167
+ if (chunk.usage)
168
+ usage = usageFrom(chunk.usage);
169
+ return false;
170
+ };
118
171
  try {
119
172
  readLoop: for (;;) {
120
173
  const { done, value } = await reader.read();
121
174
  if (done) {
122
175
  buffer += decoder.decode();
176
+ const line = buffer.trim();
177
+ if (line && line !== "data: [DONE]")
178
+ consumeDataLine(line, true);
123
179
  // MiniMax's api.minimax.io/v1 closes the connection after the
124
180
  // finish_reason chunk and never sends `[DONE]`.
125
181
  if (buffer.trim() === "data: [DONE]" || finishReason)
@@ -146,49 +202,8 @@ export function createOpenAIChatRuntime(options) {
146
202
  while ((newline = buffer.indexOf("\n")) !== -1) {
147
203
  const line = buffer.slice(0, newline).trim();
148
204
  buffer = buffer.slice(newline + 1);
149
- if (!line.startsWith("data:"))
150
- continue;
151
- const data = line.slice(5).trim();
152
- if (data === "[DONE]")
205
+ if (consumeDataLine(line))
153
206
  break readLoop;
154
- let chunk;
155
- try {
156
- chunk = JSON.parse(data);
157
- }
158
- catch {
159
- malformedFrame = true;
160
- continue;
161
- }
162
- const chunkError = providerError(chunk);
163
- if (chunkError)
164
- throw new ChatProtocolError(`provider returned a streaming completion error: ${chunkError.slice(0, 200)}`);
165
- const choice = chunk.choices?.find((row) => row.index === undefined || row.index === 0);
166
- const delta = choice?.delta;
167
- if (object(delta))
168
- sawChoice = true;
169
- if (delta?.function_call)
170
- throw new ChatProtocolError("legacy function_call is unsupported; use structured tool_calls");
171
- calls.add(delta?.tool_calls, true);
172
- details.add(delta?.reasoning_details);
173
- if (choice?.finish_reason)
174
- finishReason = choice.finish_reason;
175
- const reasoningPart = delta?.reasoning_content ?? delta?.reasoning;
176
- if (typeof reasoningPart === "string")
177
- protocolReasoning += reasoningPart;
178
- const reasoningDelta = options.reasoning && typeof reasoningPart === "string"
179
- ? reasoningPart
180
- : "";
181
- const contentDelta = typeof delta?.content === "string" ? delta.content : "";
182
- if (reasoningDelta) {
183
- reasoning += reasoningDelta;
184
- onDelta?.(reasoningDelta, "reasoning_text");
185
- }
186
- if (contentDelta) {
187
- text += contentDelta;
188
- onDelta?.(contentDelta, "assistant_text");
189
- }
190
- if (chunk.usage)
191
- usage = usageFrom(chunk.usage);
192
207
  }
193
208
  }
194
209
  }
@@ -383,7 +398,13 @@ export function createOpenAIChatRuntime(options) {
383
398
  throw new ChatProtocolError("tool arguments must be a JSON object");
384
399
  tools.validate(call.function.name, args);
385
400
  const inputPreview = preview(args);
386
- const allowed = await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.");
401
+ // Full access is the person's explicit grant to answer every
402
+ // prompt. This runtime has no provider reviewer to hand it to,
403
+ // so it is honoured here: without it every single tool call on
404
+ // an OpenAI-compatible engine stops for a card, and a Chief's
405
+ // delegated Full access cannot help either.
406
+ const allowed = turn.approvalMode === "full"
407
+ || await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.");
387
408
  abort.signal.throwIfAborted();
388
409
  emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id,
389
410
  title: call.function.name, ...(inputPreview ? { input: inputPreview } : {}),
@@ -489,9 +510,9 @@ export function createOpenAIChatRuntime(options) {
489
510
  return () => listeners.delete(listener);
490
511
  },
491
512
  },
492
- generateText: async (prompt) => {
513
+ generateText: async (prompt, { signal } = {}) => {
493
514
  const model = options.generateModel?.() ?? options.models().default;
494
- const { text, reasoning, toolCalls } = await complete([{ role: "user", content: prompt }], model, false);
515
+ const { text, reasoning, toolCalls } = await complete([{ role: "user", content: prompt }], model, false, signal);
495
516
  if (toolCalls.length)
496
517
  throw new ChatProtocolError("provider returned tool calls to a text-only helper");
497
518
  return text.trim() ? text : reasoning;
@@ -132,6 +132,7 @@ export const OpenAICompatDriver = {
132
132
  model,
133
133
  messages,
134
134
  stream,
135
+ stream_options: stream ? { include_usage: true } : undefined,
135
136
  ...(config.provider && isOpenRouterUrl(config.url)
136
137
  ? { provider: { order: [config.provider], allow_fallbacks: false } }
137
138
  : {}),
@@ -0,0 +1,103 @@
1
+ // omb-hook — the one command OpenMausBot registers for Claude Code hooks
2
+ // (PostToolUse, PreCompact, SessionStart, Stop). Claude Code runs it with the
3
+ // hook's JSON on stdin and waits for it, so it obeys three rules learned
4
+ // the hard way by other harnesses:
5
+ //
6
+ // 1. It ALWAYS exits 0. A hook that fails or times out can block the
7
+ // agent's next step; the harness observing is never worth that.
8
+ // 2. It has a hard budget (OMB_HOOK_TIMEOUT_MS, default 4000) below Claude
9
+ // Code's own per-hook timeout, and speaks only to the loopback harness.
10
+ // 3. It is dependency-free and does no work itself: it forwards the event
11
+ // to POST /api/internal/hook with the turn's capability token (read
12
+ // from OMB_HOOK_TOKEN_FILE at run time — the token rotates per turn
13
+ // while the CLI process, and its environment, live on) and prints the
14
+ // harness's `hookSpecificOutput`, if any, for Claude Code to apply.
15
+ //
16
+ // stdout is the hook channel — never console.log anything else here.
17
+ import { readFileSync } from "node:fs";
18
+ const CONTEXT_EVENTS = new Set(["SessionStart", "UserPromptSubmit"]);
19
+ const requestedBudget = Number(process.env.OMB_HOOK_TIMEOUT_MS);
20
+ const budgetMs = Number.isFinite(requestedBudget) && requestedBudget > 0 ? Math.min(requestedBudget, 4_000) : 4_000;
21
+ const MAX_INPUT_BYTES = 1024 * 1024;
22
+ const url = process.env.OMB_HOOK_URL ?? "";
23
+ const tokenFile = process.env.OMB_HOOK_TOKEN_FILE ?? "";
24
+ const done = (out) => {
25
+ if (out === undefined)
26
+ process.exit(0);
27
+ process.stdout.write(out, () => process.exit(0));
28
+ };
29
+ process.stdout.on("error", () => process.exit(0));
30
+ // the outer fuse: whatever is still pending when this fires, we leave
31
+ const fuse = setTimeout(() => done(), budgetMs);
32
+ fuse.unref();
33
+ function readStdin() {
34
+ return new Promise((resolve) => {
35
+ let data = "";
36
+ let bytes = 0;
37
+ process.stdin.setEncoding("utf8");
38
+ process.stdin.on("data", (chunk) => {
39
+ bytes += Buffer.byteLength(chunk);
40
+ if (bytes > MAX_INPUT_BYTES)
41
+ return done();
42
+ data += chunk;
43
+ });
44
+ process.stdin.on("end", () => resolve(data));
45
+ process.stdin.on("error", () => resolve(data));
46
+ });
47
+ }
48
+ async function main() {
49
+ const raw = await readStdin();
50
+ let payload;
51
+ try {
52
+ const parsed = JSON.parse(raw);
53
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
54
+ return done();
55
+ payload = parsed;
56
+ }
57
+ catch {
58
+ return done();
59
+ }
60
+ const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
61
+ if (!event || !/^https?:\/\/127\.0\.0\.1(:\d+)?$/.test(url) || !tokenFile)
62
+ return done();
63
+ let token = "";
64
+ try {
65
+ token = readFileSync(tokenFile, "utf8").trim();
66
+ }
67
+ catch {
68
+ return done();
69
+ }
70
+ if (!token)
71
+ return done();
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(), Math.max(200, budgetMs - 300));
74
+ try {
75
+ const response = await fetch(`${url}/api/internal/hook`, {
76
+ method: "POST",
77
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
78
+ body: JSON.stringify({ event, payload }),
79
+ redirect: "error",
80
+ signal: controller.signal,
81
+ });
82
+ if (!response.ok)
83
+ return done();
84
+ const body = (await response.json().catch(() => null));
85
+ if (body && typeof body === "object" && body.hookSpecificOutput && typeof body.hookSpecificOutput === "object") {
86
+ return done(JSON.stringify({ hookSpecificOutput: body.hookSpecificOutput }));
87
+ }
88
+ // On SessionStart (and UserPromptSubmit) Claude Code reads plain-text
89
+ // stdout as context the model sees; on every other event stdout is
90
+ // only a debug line, so a context body is printed nowhere else.
91
+ if (body && typeof body === "object" && typeof body.context === "string" && body.context && CONTEXT_EVENTS.has(event)) {
92
+ return done(body.context);
93
+ }
94
+ return done();
95
+ }
96
+ catch {
97
+ return done();
98
+ }
99
+ finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+ void main();