openmausbot 0.1.73 → 0.1.75

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 (42) hide show
  1. package/dist/assets/{index-BYbK7ber.js → index-BTlyRwLl.js} +1 -1
  2. package/dist/assets/index-CeMbvsgx.js +305 -0
  3. package/dist/assets/index-DH7Zax6L.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +9 -0
  6. package/dist-server/index.js +1402 -1048
  7. package/dist-server/mcp-gate.js +58 -16
  8. package/dist-server/openmausbot.js +543 -271
  9. package/dist-server/pair-cli.js +543 -271
  10. package/dist-server/permission-proxy.js +119 -14
  11. package/dist-server/server/auto-approve.js +44 -205
  12. package/dist-server/server/channel-queue.js +20 -2
  13. package/dist-server/server/config.js +36 -1
  14. package/dist-server/server/drivers/acp/core.js +58 -4
  15. package/dist-server/server/drivers/acp/grok.js +15 -2
  16. package/dist-server/server/drivers/antigravity-acp.js +5 -4
  17. package/dist-server/server/drivers/antigravity.js +9 -4
  18. package/dist-server/server/drivers/claude.js +113 -13
  19. package/dist-server/server/drivers/codex.js +37 -15
  20. package/dist-server/server/drivers/retry.js +4 -0
  21. package/dist-server/server/engine-install.js +9 -5
  22. package/dist-server/server/index.js +265 -233
  23. package/dist-server/server/message-db.js +70 -3
  24. package/dist-server/server/permission-proxy.js +90 -15
  25. package/dist-server/server/procs.js +82 -23
  26. package/dist-server/server/routines.js +67 -8
  27. package/dist-server/server/setup-mode.js +6 -7
  28. package/dist-server/server/steer-queue.js +27 -7
  29. package/dist-server/server/store.js +19 -0
  30. package/dist-server/server/team-manifest.js +1 -1
  31. package/dist-server/server/thread-events.js +10 -1
  32. package/dist-server/server/webhooks.js +4 -1
  33. package/dist-server/server/workspace-backup.js +24 -9
  34. package/dist-server/server/workspace.js +13 -0
  35. package/dist-server/shared/approval-mode.js +11 -10
  36. package/dist-server/shared/ask-question.js +172 -0
  37. package/dist-server/shared/provider-safety.js +6 -0
  38. package/dist-server/vps-container-mcp.js +9 -0
  39. package/package.json +1 -1
  40. package/dist/assets/index-BLJ7byG3.js +0 -296
  41. package/dist/assets/index-De2TRLxu.css +0 -1
  42. package/dist-server/server/auto-review.js +0 -79
@@ -101,6 +101,28 @@ function decodeAcpConfig(defaultCli) {
101
101
  * ACP JSON-RPC-over-stdio driver. Harness differences (argv, auth, catalog)
102
102
  * live in `support`; this is the shared handshake and turn runtime.
103
103
  */
104
+ /** What "the same operation" means for a remembered session allow: the
105
+ * tool call's shape with keys sorted, so two identical requests key alike
106
+ * however the agent ordered its JSON. null when nothing identifies it. */
107
+ function sessionOperationKey(toolCall) {
108
+ const rawInput = toolCall?.rawInput;
109
+ const command = typeof rawInput?.command === "string" ? rawInput.command : undefined;
110
+ const hasInput = rawInput && typeof rawInput === "object" && Object.keys(rawInput).length > 0;
111
+ if (!command && !hasInput)
112
+ return null;
113
+ const stable = (value) => Array.isArray(value)
114
+ ? value.map(stable)
115
+ : value && typeof value === "object"
116
+ ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]))
117
+ : value;
118
+ return JSON.stringify(stable({
119
+ kind: toolCall?.kind,
120
+ title: toolCall?.title,
121
+ command,
122
+ input: rawInput,
123
+ locations: toolCall?.locations,
124
+ }));
125
+ }
104
126
  export function createAcpDriver(support) {
105
127
  const DRIVER_KIND = support.driverKind;
106
128
  const SOURCE = support.nativeSource;
@@ -155,6 +177,13 @@ export function createAcpDriver(support) {
155
177
  await refreshModels();
156
178
  const listeners = new Set();
157
179
  const active = new Map();
180
+ // "Always allow this session", remembered by the driver when the agent
181
+ // offered no `allow_always` of its own: the exact operations (kind,
182
+ // title, command, input, locations) a person allowed for the session,
183
+ // per thread. A repeat is answered the way they did, once, for as long
184
+ // as the native session lasts. A generic title with no input identifies
185
+ // nothing and is never remembered.
186
+ const sessionAllows = new Map();
158
187
  const emit = (event) => {
159
188
  for (const listener of listeners)
160
189
  listener(event);
@@ -455,6 +484,7 @@ export function createAcpDriver(support) {
455
484
  const optionFor = (want) => options.find((o) => o.kind === `${want}_once` && typeof o.optionId === "string")?.optionId
456
485
  ?? options.find((o) => String(o.kind ?? "").startsWith(want) && typeof o.optionId === "string")?.optionId
457
486
  ?? null;
487
+ const optionAlways = options.find((o) => o.kind === "allow_always" && typeof o.optionId === "string")?.optionId ?? null;
458
488
  const cancelled = { outcome: { outcome: "cancelled" } };
459
489
  const missing = (want) => emit({
460
490
  ...base(threadId, turnId),
@@ -474,14 +504,23 @@ export function createAcpDriver(support) {
474
504
  });
475
505
  }
476
506
  const kind = String(toolCall.kind ?? "");
507
+ // an earlier "Always allow this session" on this exact operation
508
+ const operationKey = isQuestion || controlsHost ? null : sessionOperationKey(toolCall);
509
+ if (operationKey && sessionAllows.get(threadId)?.has(operationKey)) {
510
+ const allow = optionFor("allow");
511
+ if (allow) {
512
+ return send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "selected", optionId: allow } } });
513
+ }
514
+ }
477
515
  const tool = kind === "execute" ? "shell" : kind === "edit" ? "edit" : kind || "tool";
478
516
  const summary = String(toolCall.rawInput?.command ?? toolCall.title ?? tool).slice(0, 200);
479
517
  const requestId = newId();
480
- const finish = (behavior, source = "user", message) => {
518
+ const finish = (behavior, source = "user", message, always) => {
481
519
  if (!asks.delete(requestId))
482
520
  return;
483
521
  clearTimeout(timer);
484
522
  const want = behavior === "allow" ? "allow" : "reject";
523
+ const forSession = want === "allow" && always === true && !isQuestion && !controlsHost;
485
524
  const named = isQuestion && behavior === "answer"
486
525
  ? options.filter((option) => option.optionId === message || option.name?.trim() === message)
487
526
  : [];
@@ -489,7 +528,16 @@ export function createAcpDriver(support) {
489
528
  ? null
490
529
  : isQuestion
491
530
  ? named.length === 1 && typeof named[0].optionId === "string" ? named[0].optionId : null
492
- : optionFor(want);
531
+ : forSession
532
+ ? optionAlways ?? optionFor("allow")
533
+ : optionFor(want);
534
+ // the agent keeps its own allow_always; when it offered none, the
535
+ // driver keeps the operation for the session instead
536
+ if (forSession && !optionAlways && optionId && operationKey) {
537
+ const remembered = sessionAllows.get(threadId) ?? new Set();
538
+ remembered.add(operationKey);
539
+ sessionAllows.set(threadId, remembered);
540
+ }
493
541
  if (behavior !== "cancel" && !optionId)
494
542
  missing(isQuestion ? "matching answer" : want);
495
543
  send({
@@ -523,6 +571,8 @@ export function createAcpDriver(support) {
523
571
  ? options.flatMap((option) => typeof option.name === "string" && option.name.trim() ? [option.name.trim()] : [])
524
572
  : undefined,
525
573
  approvalScope: controlsHost ? "local-computer" : undefined,
574
+ // the driver can honor a session-wide allow either way
575
+ allowSession: !isQuestion && !controlsHost ? true : undefined,
526
576
  });
527
577
  };
528
578
  const handleNotification = (msg) => {
@@ -695,11 +745,15 @@ export function createAcpDriver(support) {
695
745
  }
696
746
  }
697
747
  const images = turn.images ?? [];
698
- const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true;
748
+ const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true ||
749
+ support.acceptsUnadvertisedImages?.(init) === true;
699
750
  if (images.length && support.images === true && !runtimeAcceptsImages) {
700
751
  throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
701
752
  }
702
753
  const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
754
+ // a fresh native session forgets what the previous one allowed
755
+ if (!cursor)
756
+ sessionAllows.delete(threadId);
703
757
  let sessionResult = null;
704
758
  if (cursor) {
705
759
  try {
@@ -878,7 +932,7 @@ export function createAcpDriver(support) {
878
932
  const finish = turn?.asks.get(requestId);
879
933
  if (!finish)
880
934
  return "unavailable"; // settled, timed out, or turn gone
881
- finish(decision.behavior, "user", decision.message);
935
+ finish(decision.behavior, "user", decision.message, decision.always === true);
882
936
  return decision.behavior === "allow"
883
937
  ? "allowed-once"
884
938
  : decision.behavior === "answer"
@@ -175,10 +175,21 @@ export function ensureGrokInjectSlug(modelId, env = process.env) {
175
175
  writeFileSync(path, next);
176
176
  return slug;
177
177
  }
178
+ /** Grok 1.0.25 consumes native image blocks but advertises image:false.
179
+ * Verified with the real CLI and a loopback model: scripts/verify-grok-images.ts.
180
+ * Keep unknown/older runtimes on the normal capability negotiation path. */
181
+ export function grokAcceptsUnadvertisedImages(init) {
182
+ const meta = init?._meta;
183
+ if (meta?.grokShell !== true || typeof meta.agentVersion !== "string")
184
+ return false;
185
+ const version = /^1\.0\.(\d+)$/.exec(meta.agentVersion);
186
+ return Boolean(version && Number(version[1]) >= 25);
187
+ }
178
188
  const support = {
179
189
  driverKind: "grokAgent",
180
190
  displayName: "Grok",
181
- images: false,
191
+ images: true,
192
+ acceptsUnadvertisedImages: grokAcceptsUnadvertisedImages,
182
193
  models: STATIC_GROK_MODELS,
183
194
  resolveModels: (env) => mergeLocalInject(readGrokModelCatalog(env), env),
184
195
  // Grok's accepted levels vary by model and the CLI validates lazily — a
@@ -214,7 +225,9 @@ const support = {
214
225
  // crates/codegen/xai-grok-pager-bin/src/main.rs:1259-1273.
215
226
  spawnArgs: (config, turn) => [
216
227
  "--permission-mode",
217
- config.fullAuto ? "bypassPermissions" : turn.approvalMode === "auto" ? "auto" : "default",
228
+ config.fullAuto
229
+ ? "bypassPermissions"
230
+ : turn.approvalMode === "auto" ? "auto" : turn.approvalMode === "edits" ? "acceptEdits" : "default",
218
231
  "agent",
219
232
  ...(turn.model ? ["-m", turn.model] : []),
220
233
  // long form on purpose: `--effort` is documented as an alias, and an
@@ -148,6 +148,7 @@ export class AntigravityAcpClient {
148
148
  startupDiagnosticBytes = 0;
149
149
  nativeStartupHint;
150
150
  closed = false;
151
+ stopping;
151
152
  onAuthorizationUrl;
152
153
  /** Settles once the runtime process is gone. On Windows a running
153
154
  * executable pins its file and its directory, so an installer must not
@@ -291,10 +292,10 @@ export class AntigravityAcpClient {
291
292
  return;
292
293
  this.closed = true;
293
294
  this.failAll(new Error("Antigravity ACP was closed."));
294
- killCliTree(this.child);
295
+ this.stopping = killCliTree(this.child);
295
296
  }
296
- /** Close, then wait (bounded) for the process to be gone. */
297
- async closeAndWait(timeoutMs = 5_000) {
297
+ /** Allow the shared 5s TERM grace and 1s force-stop verification to finish. */
298
+ async closeAndWait(timeoutMs = 7_000) {
298
299
  this.close();
299
300
  let timer;
300
301
  const timedOut = new Promise((resolve) => {
@@ -302,7 +303,7 @@ export class AntigravityAcpClient {
302
303
  timer.unref?.();
303
304
  });
304
305
  try {
305
- return await Promise.race([this.exited.then(() => true), timedOut]);
306
+ return await Promise.race([this.stopping, timedOut]);
306
307
  }
307
308
  finally {
308
309
  clearTimeout(timer);
@@ -11,8 +11,13 @@ export const STATIC_ANTIGRAVITY_MODELS = {
11
11
  { id: "gemini-3.8-flash-low", label: "Gemini 3.8 Flash (Low)" },
12
12
  ],
13
13
  };
14
- export function antigravityPermissionMode(fullAuto) {
15
- return fullAuto ? "yolo" : "default";
14
+ /** Antigravity's own session modes: `yolo` for Full access, `auto_edit` for
15
+ * auto-accept edits, `default` otherwise (Ask, and Auto, which it has no
16
+ * reviewer for). */
17
+ export function antigravityPermissionMode(fullAuto, approvalMode) {
18
+ if (fullAuto)
19
+ return "yolo";
20
+ return approvalMode === "edits" ? "auto_edit" : "default";
16
21
  }
17
22
  export function antigravityModelsFromSession(value) {
18
23
  return catalogFromAntigravityConfigOptions(value, STATIC_ANTIGRAVITY_MODELS.default);
@@ -88,8 +93,8 @@ const support = {
88
93
  return { state: "unavailable", reason: error instanceof Error ? error.message : String(error) };
89
94
  }
90
95
  },
91
- configureSession: async ({ request, sessionId, config }) => {
92
- const wanted = antigravityPermissionMode(config.fullAuto);
96
+ configureSession: async ({ request, sessionId, config, turn }) => {
97
+ const wanted = antigravityPermissionMode(config.fullAuto, turn.approvalMode);
93
98
  const result = await request("session/set_config_option", {
94
99
  sessionId,
95
100
  configId: "mode",
@@ -26,6 +26,7 @@ import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS }
26
26
  import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
27
27
  import { appendNative } from "./native.js";
28
28
  import { SPAWNED_PROXIES } from "../proxy-paths.js";
29
+ import { ASK_USER_QUESTION_TOOL, askQuestionSummary, parseAskQuestions, questionChoices, } from "../../shared/ask-question.js";
29
30
  /** Whether `claude` has been signed in.
30
31
  *
31
32
  * Credential storage is deliberately not inspected here. Claude Code uses the
@@ -150,6 +151,30 @@ function claudeEnvironment(model, source = process.env, configDir, instanceEnvir
150
151
  function inheritsUserConfig(env) {
151
152
  return env.OMB_CLAUDE_INHERIT_USER_CONFIG === "1";
152
153
  }
154
+ /** Retain the selected CLI account's authentication without importing its
155
+ * hooks, permissions, MCP servers or personal instructions. Explicit OMB
156
+ * connections/local endpoints own their entire routing + credential pair. */
157
+ export function readClaudeAuthSettings(env, instanceEnvironment = {}) {
158
+ if (CLAUDE_ACCOUNT_ENV_KEYS.some((key) => instanceEnvironment[key]))
159
+ return {};
160
+ try {
161
+ const settings = JSON.parse(readFileSync(join(resolveClaudeConfigDir(undefined, env), "settings.json"), "utf8"));
162
+ const authEnv = {};
163
+ for (const key of CLAUDE_ACCOUNT_ENV_KEYS) {
164
+ if (!key.endsWith("_FILE_DESCRIPTOR") && typeof settings?.env?.[key] === "string") {
165
+ authEnv[key] = settings.env[key];
166
+ }
167
+ }
168
+ return {
169
+ ...(Object.keys(authEnv).length ? { env: authEnv } : {}),
170
+ ...(typeof settings?.apiKeyHelper === "string" && settings.apiKeyHelper.trim()
171
+ ? { apiKeyHelper: settings.apiKeyHelper } : {}),
172
+ };
173
+ }
174
+ catch {
175
+ return {};
176
+ }
177
+ }
153
178
  /** MCP servers the bot's own project declares in `<cwd>/.mcp.json`.
154
179
  *
155
180
  * The CLI would find this file itself, but the harness launches it with
@@ -370,8 +395,17 @@ function systemEndedReply(kind) {
370
395
  ? { behavior: "answer", message: "OpenMausBot: the turn is ending — wrap up." }
371
396
  : { behavior: "deny", message: "OpenMausBot: the turn ended" };
372
397
  }
398
+ /** The structured questions behind an ask, when it is one. Claude's own
399
+ * AskUserQuestion carries them; everything else answers null and keeps the
400
+ * plain summary/choices card. */
401
+ function askQuestions(ask) {
402
+ return ask.tool === ASK_USER_QUESTION_TOOL ? parseAskQuestions(ask.input) : null;
403
+ }
373
404
  /** One human-readable line for an ask — what the card subtitle shows. */
374
405
  function askSummary(ask) {
406
+ const questions = askQuestions(ask);
407
+ if (questions)
408
+ return askQuestionSummary(questions).slice(0, 300);
375
409
  return askInputSummary(ask.input) ?? ask.tool ?? "tool";
376
410
  }
377
411
  export function permissionSocketPath(threadId) {
@@ -482,12 +516,18 @@ export async function createPermissionBroker(opts) {
482
516
  continue;
483
517
  }
484
518
  const ask = { id: askId, kind, tool: msg.tool ?? "tool", input: msg.input ?? {}, at: Date.now() };
485
- const finish = (behavior, message, source) => {
519
+ const finish = (behavior, message, source, always) => {
486
520
  if (!pending.delete(askId))
487
521
  return;
488
522
  clearTimeout(timer);
489
523
  try {
490
- conn.write(JSON.stringify({ t: "answer", id: askId, behavior, message }) + "\n");
524
+ // `always` rides to the proxy, which hands the CLI's own suggested
525
+ // permission rules back as updatedPermissions: Claude remembers
526
+ // the allow for the session, the harness remembers nothing.
527
+ // `source` travels with the answer too: a proxy that cannot tell
528
+ // the human's words from a timeout note would file the timeout
529
+ // note as the human's words.
530
+ conn.write(JSON.stringify({ t: "answer", id: askId, behavior, message, source, ...(always ? { always: true } : {}) }) + "\n");
491
531
  }
492
532
  catch { }
493
533
  opts.onResolve({ ...ask, behavior, source });
@@ -570,13 +610,13 @@ export async function createPermissionBroker(opts) {
570
610
  }
571
611
  };
572
612
  return {
573
- answer(askId, behavior, message) {
613
+ answer(askId, behavior, message, always) {
574
614
  const p = pending.get(askId);
575
615
  if (!p)
576
616
  return false;
577
617
  if (p.ask.kind === "question" ? behavior !== "answer" : behavior === "answer")
578
618
  return false;
579
- p.finish(behavior, message, "user");
619
+ p.finish(behavior, message, "user", always && behavior === "allow");
580
620
  return true;
581
621
  },
582
622
  pause() {
@@ -752,6 +792,12 @@ export const ClaudeDriver = {
752
792
  ? configuredIdleMinimum
753
793
  : 10_000;
754
794
  const SESSION_IDLE_MS = Math.max(sessionIdleMinimum, Number(process.env.OMB_CLAUDE_SESSION_IDLE_MS) || 10 * 60_000);
795
+ const stopSession = (session) => {
796
+ void killCliTree(session.child).then((stopped) => {
797
+ if (stopped)
798
+ void session.finishClose?.();
799
+ });
800
+ };
755
801
  const closeSession = (threadId, why) => {
756
802
  const s = sessions.get(threadId);
757
803
  if (!s || s.closing)
@@ -772,8 +818,7 @@ export const ClaudeDriver = {
772
818
  }
773
819
  catch { }
774
820
  const kill = setTimeout(() => {
775
- if (s.child.exitCode === null)
776
- killCliTree(s.child);
821
+ stopSession(s);
777
822
  }, 5_000);
778
823
  kill.unref?.();
779
824
  };
@@ -833,7 +878,8 @@ export const ClaudeDriver = {
833
878
  const permissionMode = turn.approvalMode === undefined
834
879
  ? config.permissionMode
835
880
  : turn.approvalMode === "full" ? "bypassPermissions"
836
- : turn.approvalMode === "auto" ? "auto" : "default";
881
+ : turn.approvalMode === "auto" ? "auto"
882
+ : turn.approvalMode === "edits" ? "acceptEdits" : "default";
837
883
  const controlsHost = turn.integrations?.localComputer?.scope === "local-computer";
838
884
  if (controlsHost && permissionMode === "bypassPermissions" && turn.approvalMode !== "full") {
839
885
  throw new Error("local computer control requires the interactive approval broker");
@@ -1019,6 +1065,12 @@ export const ClaudeDriver = {
1019
1065
  args.push("--allowedTools", allowed.join(","));
1020
1066
  }
1021
1067
  const env = environment(turnModel);
1068
+ const authSettings = isolated && !injected.injected
1069
+ ? readClaudeAuthSettings(env, input.environment) : {};
1070
+ const authSettingsPath = mcpConfigPath && Object.keys(authSettings).length
1071
+ ? join(dirname(mcpConfigPath), "auth-settings.json") : null;
1072
+ if (authSettingsPath)
1073
+ args.push("--settings", authSettingsPath);
1022
1074
  // Our approvals and browser credentials expire at the user-turn
1023
1075
  // boundary. Native background workers cannot outlive that boundary;
1024
1076
  // parallel bot work must use the harness's durable delegate_bot path.
@@ -1026,7 +1078,7 @@ export const ClaudeDriver = {
1026
1078
  const cwd = turn.cwd ?? homedir();
1027
1079
  // Everything that shapes the process, minus session/turn-specific temp
1028
1080
  // paths. Their contents are represented directly in the key instead.
1029
- const privateFileFlags = new Set(["--mcp-config"]);
1081
+ const privateFileFlags = new Set(["--mcp-config", "--settings"]);
1030
1082
  const keyArgs = args.filter((a, i) => !privateFileFlags.has(a) && !privateFileFlags.has(args[i - 1] ?? ""));
1031
1083
  const argsKey = JSON.stringify({
1032
1084
  args: keyArgs,
@@ -1038,6 +1090,11 @@ export const ClaudeDriver = {
1038
1090
  model: injected.model ?? null,
1039
1091
  base: env.ANTHROPIC_BASE_URL ?? null,
1040
1092
  configDir: env.CLAUDE_CONFIG_DIR ?? null,
1093
+ // Rotating an account's key/helper must not reuse the old process.
1094
+ auth: createHash("sha256").update(JSON.stringify({
1095
+ settings: authSettings,
1096
+ env: Object.fromEntries(CLAUDE_ACCOUNT_ENV_KEYS.map((key) => [key, env[key]])),
1097
+ })).digest("hex"),
1041
1098
  });
1042
1099
  // Reuse the live process when it is idle, unchanged, and is the session
1043
1100
  // the harness wants resumed. Anything else: close it and spawn fresh
@@ -1049,7 +1106,7 @@ export const ClaudeDriver = {
1049
1106
  live.turn = { turnId, settled: false, sawStreamDelta: false };
1050
1107
  active.set(threadId, { stop: () => {
1051
1108
  closeSession(threadId, "interrupted");
1052
- killCliTree(live.child);
1109
+ stopSession(live);
1053
1110
  }, turnId, broker: live.broker });
1054
1111
  emit({ ...base(threadId, turnId), type: "turn.started" });
1055
1112
  const volatile = turn.systemVolatile ?? "";
@@ -1121,6 +1178,14 @@ export const ClaudeDriver = {
1121
1178
  onAsk: (ask) => {
1122
1179
  const eventTurnId = sessions.get(threadId)?.turn?.turnId ?? turnId;
1123
1180
  askTools.set(ask.id, typeof ask.tool === "string" ? ask.tool : undefined);
1181
+ // Auto was requested: say whether the CLI's reviewer is actually
1182
+ // running, from init, so the harness can tell a classifier's
1183
+ // verdict from a Manual session asking about everything.
1184
+ const nativeMode = sessions.get(threadId)?.nativePermissionMode ?? null;
1185
+ const nativeReview = permissionMode === "auto" && nativeMode !== null
1186
+ ? nativeMode === "auto" ? "active" : "inactive"
1187
+ : undefined;
1188
+ const questions = askQuestions(ask);
1124
1189
  emit({
1125
1190
  ...base(threadId, eventTurnId),
1126
1191
  type: "request.opened",
@@ -1128,10 +1193,19 @@ export const ClaudeDriver = {
1128
1193
  requestType: ask.kind,
1129
1194
  tool: ask.tool,
1130
1195
  summary: askSummary(ask),
1196
+ nativeReview,
1197
+ // the proxy hands Claude its own suggested rules on `always`;
1198
+ // host control stays one action at a time
1199
+ allowSession: ask.kind === "permission" && !(controlsHost && typeof ask.tool === "string" && ask.tool.startsWith("mcp__computer")) ? true : undefined,
1131
1200
  approvalScope: typeof ask.tool === "string" && controlsHost && ask.tool.startsWith("mcp__computer")
1132
1201
  ? "local-computer"
1133
1202
  : undefined,
1134
- choices: Array.isArray(ask.input?.choices) ? ask.input.choices.slice(0, 5) : undefined,
1203
+ questions: questions ?? undefined,
1204
+ // A structured ask still offers flat labels, for the phone
1205
+ // companions and any client that predates the question card.
1206
+ choices: questions
1207
+ ? questionChoices(questions)
1208
+ : Array.isArray(ask.input?.choices) ? ask.input.choices.slice(0, 5) : undefined,
1135
1209
  });
1136
1210
  },
1137
1211
  onResolve: (resolved) => {
@@ -1160,6 +1234,9 @@ export const ClaudeDriver = {
1160
1234
  if (mcpConfigPath) {
1161
1235
  writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
1162
1236
  }
1237
+ if (authSettingsPath) {
1238
+ writeFileSync(authSettingsPath, JSON.stringify(authSettings), { mode: 0o600 });
1239
+ }
1163
1240
  if (sessionId)
1164
1241
  args.push("--resume", sessionId);
1165
1242
  else
@@ -1190,6 +1267,7 @@ export const ClaudeDriver = {
1190
1267
  volatile: turn.systemVolatile ?? "",
1191
1268
  sessionId: sessionId ?? newSessionId,
1192
1269
  sawInit: false,
1270
+ nativePermissionMode: null,
1193
1271
  turn: { turnId, settled: false, sawStreamDelta: false },
1194
1272
  idleTimer: null,
1195
1273
  closing: false,
@@ -1231,6 +1309,8 @@ export const ClaudeDriver = {
1231
1309
  };
1232
1310
  const currentTurnId = () => session.turn?.turnId ?? turnId;
1233
1311
  const handleLine = (line) => {
1312
+ if (session.closing)
1313
+ return;
1234
1314
  let o;
1235
1315
  try {
1236
1316
  o = JSON.parse(line);
@@ -1243,6 +1323,7 @@ export const ClaudeDriver = {
1243
1323
  case "system":
1244
1324
  if (o.subtype === "init") {
1245
1325
  session.sawInit = true;
1326
+ session.nativePermissionMode = typeof o.permissionMode === "string" ? o.permissionMode : null;
1246
1327
  if (typeof o.session_id === "string")
1247
1328
  session.sessionId = o.session_id;
1248
1329
  emit({ ...base(threadId, currentTurnId()), type: "session.started", sessionId: o.session_id, model: o.model });
@@ -1371,7 +1452,22 @@ export const ClaudeDriver = {
1371
1452
  emit({ ...base(threadId, currentTurnId()), type: "runtime.error", ...describeSpawnFailure(e, config.cli) });
1372
1453
  settle(false, "spawn_error");
1373
1454
  });
1374
- child.on("close", (code) => {
1455
+ let closeFinalized = false;
1456
+ const finalizeClose = async (code) => {
1457
+ if (closeFinalized)
1458
+ return;
1459
+ // The root can close while its MCP helpers are still running. Join
1460
+ // an in-flight stop (or reap its remaining group) before releasing
1461
+ // the turn so a replacement cannot overlap the old helpers.
1462
+ if (!(await killCliTree(child, 0))) {
1463
+ session.broker?.close();
1464
+ session.broker = undefined;
1465
+ emit({ ...base(threadId, currentTurnId()), type: "runtime.error", message: "Claude could not be confirmed stopped; its helper processes may still be running." });
1466
+ return;
1467
+ }
1468
+ if (closeFinalized)
1469
+ return;
1470
+ closeFinalized = true;
1375
1471
  // a turn still running when the process died is a failed turn; a
1376
1472
  // process that exited between turns (idle close, contract change)
1377
1473
  // is just a session ending
@@ -1536,6 +1632,10 @@ export const ClaudeDriver = {
1536
1632
  removePrivateTempDir(session.systemPromptPath);
1537
1633
  if (sessions.get(threadId) === session)
1538
1634
  sessions.delete(threadId);
1635
+ };
1636
+ child.on("close", (code) => {
1637
+ session.finishClose = () => finalizeClose(code);
1638
+ void session.finishClose();
1539
1639
  });
1540
1640
  const stop = () => {
1541
1641
  // taskkill is asynchronous on Windows. Retire steering and approvals
@@ -1543,7 +1643,7 @@ export const ClaudeDriver = {
1543
1643
  closeSession(threadId, "interrupted");
1544
1644
  retry.cancelled = true;
1545
1645
  retryAbort.abort();
1546
- killCliTree(child);
1646
+ stopSession(session);
1547
1647
  };
1548
1648
  active.set(threadId, { stop, turnId, broker });
1549
1649
  emit({ ...base(threadId, turnId), type: "turn.started" });
@@ -1683,7 +1783,7 @@ export const ClaudeDriver = {
1683
1783
  if (!broker)
1684
1784
  return "unavailable";
1685
1785
  const behavior = decision.behavior === "answer" ? "answer" : decision.behavior;
1686
- if (!broker.answer(requestId, behavior, decision.message))
1786
+ if (!broker.answer(requestId, behavior, decision.message, decision.always))
1687
1787
  return "unavailable";
1688
1788
  return behavior === "allow" ? "allowed-once" : behavior === "answer" ? "answered" : "rejected";
1689
1789
  },
@@ -181,6 +181,9 @@ function mcpAppApprovalForm(params) {
181
181
  /** Codex persists these values on its native thread. Keep them explicit on
182
182
  * start, resume, and every turn so switching modes cannot leave a more
183
183
  * permissive sandbox/reviewer stuck to the next request. */
184
+ /** Ask and Edits both run Codex's workspace-write sandbox with the person as
185
+ * reviewer: Codex has no narrower "edits only" mode, so the selector never
186
+ * offers Edits for it (supportsApprovalMode) and a stray value asks. */
184
187
  function namedApprovalParams(mode) {
185
188
  if (mode === "full") {
186
189
  return {
@@ -521,6 +524,7 @@ export const CodexDriver = {
521
524
  const earlyNotifications = [];
522
525
  const state = {
523
526
  settled: false,
527
+ lastError: "",
524
528
  lastText: "",
525
529
  sawStreamDelta: false,
526
530
  // codex reports token usage as a running THREAD total; the harness
@@ -582,10 +586,18 @@ export const CodexDriver = {
582
586
  send({ jsonrpc: "2.0", id, method, params });
583
587
  });
584
588
  let stopping;
585
- const terminate = () => stopping ??= killCliTree(child);
586
- const stop = () => {
589
+ const terminate = () => stopping ??= killCliTree(child).then((stopped) => {
590
+ if (!stopped)
591
+ stopping = undefined;
592
+ return stopped;
593
+ });
594
+ let completeStoppedTurn;
595
+ const stop = async () => {
587
596
  stopRequested = true;
588
- return terminate();
597
+ const stopped = await terminate();
598
+ if (stopped)
599
+ completeStoppedTurn?.();
600
+ return stopped;
589
601
  };
590
602
  const settle = async (ok, stopReason) => {
591
603
  if (state.settled)
@@ -602,15 +614,9 @@ export const CodexDriver = {
602
614
  active.delete(threadId);
603
615
  emit({ ...base(threadId, turnId), type: "turn.completed", ok, stopReason, cost: null, ...(state.usage ? { usage: state.usage } : {}) });
604
616
  };
605
- if (await stop()) {
606
- complete();
607
- }
608
- else {
617
+ completeStoppedTurn = complete;
618
+ if (!(await stop())) {
609
619
  emit({ ...base(threadId, turnId), type: "runtime.error", message: "codex did not shut down after termination was requested" });
610
- if (child.exitCode !== null || child.signalCode !== null)
611
- complete();
612
- else
613
- child.once("close", complete);
614
620
  }
615
621
  };
616
622
  // server→client approval request → canonical request.opened
@@ -873,7 +879,15 @@ export const CodexDriver = {
873
879
  }
874
880
  case "turn/completed": {
875
881
  const t = p.turn ?? {};
876
- void settle(t.status === "completed", t.status === "completed" ? null : (t.error?.message ?? t.status ?? "failed"));
882
+ const message = typeof t.error?.message === "string" ? t.error.message.slice(0, 400) : "";
883
+ if (t.status !== "completed" && message && message !== state.lastError) {
884
+ state.lastError = message;
885
+ emit({ ...base(threadId, turnId), type: "runtime.error", message,
886
+ ...(classifyError({ text: message }).reason === "auth" ? { setup: true } : {}),
887
+ });
888
+ }
889
+ void settle(t.status === "completed", t.status === "completed" ? null :
890
+ (classifyError({ text: message || state.lastError }).reason === "provider_safety" ? "provider_safety" : (message || t.status || "failed")));
877
891
  break;
878
892
  }
879
893
  case "error":
@@ -881,8 +895,10 @@ export const CodexDriver = {
881
895
  // {error:{message}} — surface either (agentcal armor)
882
896
  {
883
897
  const message = p.message ?? p.error?.message;
884
- if (message)
885
- emit({ ...base(threadId, turnId), type: "runtime.error", message: String(message).slice(0, 400) });
898
+ if (message) {
899
+ state.lastError = String(message).slice(0, 400);
900
+ emit({ ...base(threadId, turnId), type: "runtime.error", message: state.lastError });
901
+ }
886
902
  }
887
903
  break;
888
904
  }
@@ -943,6 +959,12 @@ export const CodexDriver = {
943
959
  child.on("close", (code) => {
944
960
  if (abandoned)
945
961
  return;
962
+ if (state.settled) {
963
+ // Root exit alone cannot release a turn after an uncertain stop.
964
+ // Recheck its group; an explicit later Stop can also retry this.
965
+ void stop();
966
+ return;
967
+ }
946
968
  if (!state.settled) {
947
969
  emit({
948
970
  ...base(threadId, turnId),
@@ -1134,7 +1156,7 @@ export const CodexDriver = {
1134
1156
  message,
1135
1157
  ...(needsAuth ? { setup: true } : {}),
1136
1158
  });
1137
- await settle(false, needsAuth ? "auth_required" : "rpc_error");
1159
+ await settle(false, needsAuth ? "auth_required" : verdict.reason === "provider_safety" ? "provider_safety" : "rpc_error");
1138
1160
  }
1139
1161
  }
1140
1162
  };
@@ -1,3 +1,4 @@
1
+ import { isProviderSafetyBlock } from "../../shared/provider-safety.js";
1
2
  export const RETRY_MAX_ATTEMPTS = 3;
2
3
  /** Backoff schedule before attempt N (N is 1-based over retries): 1s / 3s / 8s. */
3
4
  export const BACKOFF_BASE_MS = [1_000, 3_000, 8_000];
@@ -42,6 +43,9 @@ const messageOf = (err) => {
42
43
  */
43
44
  export function classifyError(err) {
44
45
  const text = messageOf(err);
46
+ // A surrounding HTTP 5xx/429 must not replay a provider safety block.
47
+ if (isProviderSafetyBlock(text))
48
+ return { transient: false, reason: "provider_safety" };
45
49
  if (err && "exitCode" in err) {
46
50
  const { exitCode: code } = err;
47
51
  if (code !== null && code < 0)