openmausbot 0.1.74 → 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.
@@ -1,6 +1,80 @@
1
1
  // server/permission-proxy.ts
2
2
  import { connect } from "node:net";
3
3
  import { randomUUID } from "node:crypto";
4
+
5
+ // shared/ask-question.ts
6
+ var MAX_QUESTIONS = 6;
7
+ var MAX_OPTIONS = 12;
8
+ var MAX_QUESTION_TEXT = 400;
9
+ var MAX_LABEL = 120;
10
+ var MAX_DESCRIPTION = 400;
11
+ function text(value, limit) {
12
+ if (typeof value !== "string") return void 0;
13
+ const trimmed = value.trim();
14
+ return trimmed ? trimmed.slice(0, limit) : void 0;
15
+ }
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function parseOption(value) {
20
+ if (typeof value === "string") {
21
+ const label2 = text(value, MAX_LABEL);
22
+ return label2 ? { label: label2 } : null;
23
+ }
24
+ if (!isRecord(value)) return null;
25
+ const label = text(value.label, MAX_LABEL);
26
+ if (!label) return null;
27
+ const description = text(value.description, MAX_DESCRIPTION);
28
+ return description ? { label, description } : { label };
29
+ }
30
+ function parseQuestion(value) {
31
+ if (!isRecord(value)) return null;
32
+ const question = text(value.question, MAX_QUESTION_TEXT);
33
+ if (!question) return null;
34
+ const options = [];
35
+ const seen = /* @__PURE__ */ new Set();
36
+ for (const raw of Array.isArray(value.options) ? value.options : []) {
37
+ const option = parseOption(raw);
38
+ if (!option || seen.has(option.label)) continue;
39
+ seen.add(option.label);
40
+ options.push(option);
41
+ if (options.length === MAX_OPTIONS) break;
42
+ }
43
+ const header = text(value.header, MAX_LABEL);
44
+ return {
45
+ question,
46
+ ...header ? { header } : {},
47
+ ...value.multiSelect === true ? { multiSelect: true } : {},
48
+ options
49
+ };
50
+ }
51
+ function parseAskQuestions(input) {
52
+ if (!isRecord(input) || !Array.isArray(input.questions)) return null;
53
+ const questions = [];
54
+ for (const raw of input.questions) {
55
+ const question = parseQuestion(raw);
56
+ if (!question) continue;
57
+ questions.push(question);
58
+ if (questions.length === MAX_QUESTIONS) break;
59
+ }
60
+ return questions.length ? questions : null;
61
+ }
62
+ function questionAnswersByQuestion(message, questions) {
63
+ const answers = {};
64
+ const known = new Map(questions.map((entry) => [entry.question, entry.question]));
65
+ for (const block of message.split("\n\n")) {
66
+ const match = /^Q: ([\s\S]+?)\nA: ([\s\S]+)$/.exec(block.trim());
67
+ if (!match) continue;
68
+ const question = known.get(match[1].trim());
69
+ if (question) answers[question] = match[2].trim();
70
+ }
71
+ if (Object.keys(answers).length) return answers;
72
+ const only = questions.length === 1 ? questions[0] : void 0;
73
+ const flat = message.trim();
74
+ return only && flat ? { [only.question]: flat } : {};
75
+ }
76
+
77
+ // server/permission-proxy.ts
4
78
  var socketPath = process.argv[2] ?? "";
5
79
  var waiting = /* @__PURE__ */ new Map();
6
80
  var conn = connect(socketPath);
@@ -32,6 +106,40 @@ conn.on("data", (chunk) => {
32
106
  }
33
107
  });
34
108
  var send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
109
+ function askBroker(ask) {
110
+ return new Promise((resolve) => {
111
+ waiting.set(String(ask.id), resolve);
112
+ if (conn.destroyed) return dead();
113
+ try {
114
+ conn.write(JSON.stringify(ask) + "\n");
115
+ } catch {
116
+ dead();
117
+ }
118
+ });
119
+ }
120
+ var ASK_USER_QUESTION = "AskUserQuestion";
121
+ var asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
122
+ async function answerNativeQuestions(input) {
123
+ const questions = parseAskQuestions(input);
124
+ if (!questions?.length) {
125
+ return JSON.stringify({
126
+ behavior: "deny",
127
+ message: "OpenMausBot: this AskUserQuestion call had no answerable question (each one needs question text), so nobody was shown it. Ask again with a well-formed call, or continue without it."
128
+ });
129
+ }
130
+ const answer = await askBroker({
131
+ t: "ask",
132
+ id: randomUUID(),
133
+ kind: "question",
134
+ tool: ASK_USER_QUESTION,
135
+ input: { questions }
136
+ });
137
+ if (answer.behavior === "deny") {
138
+ return JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
139
+ }
140
+ const answers = answer.source === "user" && typeof answer.message === "string" ? questionAnswersByQuestion(answer.message, questions) : {};
141
+ return JSON.stringify({ behavior: "allow", updatedInput: { ...asRecord(input), answers } });
142
+ }
35
143
  var TOOLS = [
36
144
  {
37
145
  name: "approve",
@@ -79,30 +187,27 @@ async function handle(msg) {
79
187
  if (msg.method === "tools/call") {
80
188
  const name = msg.params?.name;
81
189
  const args = msg.params?.arguments ?? {};
190
+ const reply = (text3) => send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: text3 }] } });
191
+ if (name === "approve" && args.tool_name === ASK_USER_QUESTION) {
192
+ return reply(await answerNativeQuestions(args.input));
193
+ }
82
194
  const askId = randomUUID();
83
195
  const isQuestion = name === "ask_user";
84
196
  const suggestions = Array.isArray(args.permission_suggestions) ? args.permission_suggestions : Array.isArray(args.suggestions) ? args.suggestions : null;
85
- const answer = await new Promise((resolve) => {
86
- waiting.set(askId, resolve);
87
- if (conn.destroyed) return dead();
88
- const ask = isQuestion ? { t: "ask", id: askId, kind: "question", tool: "ask_user", input: { question: args.question, choices: args.choices } } : { t: "ask", id: askId, tool: args.tool_name, input: args.input };
89
- try {
90
- conn.write(JSON.stringify(ask) + "\n");
91
- } catch {
92
- dead();
93
- }
94
- });
95
- let text = answer.message || "No answer was given \u2014 use your best judgment.";
197
+ const answer = await askBroker(
198
+ isQuestion ? { t: "ask", id: askId, kind: "question", tool: "ask_user", input: { question: args.question, choices: args.choices } } : { t: "ask", id: askId, tool: args.tool_name, input: args.input }
199
+ );
200
+ let text2 = answer.message || "No answer was given \u2014 use your best judgment.";
96
201
  if (!isQuestion) {
97
202
  if (answer.behavior === "allow") {
98
203
  const result = { behavior: "allow", updatedInput: args.input ?? {} };
99
204
  if (answer.always && suggestions) result.updatedPermissions = suggestions;
100
- text = JSON.stringify(result);
205
+ text2 = JSON.stringify(result);
101
206
  } else {
102
- text = JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
207
+ text2 = JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
103
208
  }
104
209
  }
105
- return send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } });
210
+ return reply(text2);
106
211
  }
107
212
  if (String(msg.method ?? "").startsWith("notifications/")) return;
108
213
  if (msg.id != null) {
@@ -17,7 +17,22 @@ export function approvalModeForOrigin(mode, origin) {
17
17
  return "auto";
18
18
  return mode;
19
19
  }
20
+ // Tools that ask a PERSON something. A question exists so that a human
21
+ // decides; any mode answering one on their behalf defeats the only reason
22
+ // it was asked. They normally arrive typed as questions and never reach a
23
+ // verdict at all — this is the backstop for the path where one arrives
24
+ // mis-typed as a permission (a malformed AskUserQuestion call falls back to
25
+ // the permission path in permission-proxy). Approving it there does not
26
+ // produce an answer: the CLI runs the tool with none and the model is told
27
+ // "The user did not answer the questions." — a question silently lost.
28
+ const ASKS_A_PERSON = new Set(["askuserquestion", "ask_user"]);
20
29
  export function autoVerdict(mode, tool, context) {
30
+ // A question is for a person, whatever channel it arrived on — and
31
+ // whatever the mode: even Full has no answer to give, only an approval
32
+ // that would run the tool with none.
33
+ if (ASKS_A_PERSON.has(tool.replace(/^mcp__[^_]+__/, "").toLowerCase())) {
34
+ return { approve: null, source: "no-grant" };
35
+ }
21
36
  // Full's promise is literal: even a sandbox widening is approved. Entering
22
37
  // Full is separately consent-gated by the bot PATCH endpoint, and the
23
38
  // request.opened caller invokes this for permissions only, never questions.
@@ -745,7 +745,8 @@ export function createAcpDriver(support) {
745
745
  }
746
746
  }
747
747
  const images = turn.images ?? [];
748
- const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true;
748
+ const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true ||
749
+ support.acceptsUnadvertisedImages?.(init) === true;
749
750
  if (images.length && support.images === true && !runtimeAcceptsImages) {
750
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.`);
751
752
  }
@@ -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
@@ -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) {
@@ -490,7 +524,10 @@ export async function createPermissionBroker(opts) {
490
524
  // `always` rides to the proxy, which hands the CLI's own suggested
491
525
  // permission rules back as updatedPermissions: Claude remembers
492
526
  // the allow for the session, the harness remembers nothing.
493
- conn.write(JSON.stringify({ t: "answer", id: askId, behavior, message, ...(always ? { always: true } : {}) }) + "\n");
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");
494
531
  }
495
532
  catch { }
496
533
  opts.onResolve({ ...ask, behavior, source });
@@ -1028,6 +1065,12 @@ export const ClaudeDriver = {
1028
1065
  args.push("--allowedTools", allowed.join(","));
1029
1066
  }
1030
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);
1031
1074
  // Our approvals and browser credentials expire at the user-turn
1032
1075
  // boundary. Native background workers cannot outlive that boundary;
1033
1076
  // parallel bot work must use the harness's durable delegate_bot path.
@@ -1035,7 +1078,7 @@ export const ClaudeDriver = {
1035
1078
  const cwd = turn.cwd ?? homedir();
1036
1079
  // Everything that shapes the process, minus session/turn-specific temp
1037
1080
  // paths. Their contents are represented directly in the key instead.
1038
- const privateFileFlags = new Set(["--mcp-config"]);
1081
+ const privateFileFlags = new Set(["--mcp-config", "--settings"]);
1039
1082
  const keyArgs = args.filter((a, i) => !privateFileFlags.has(a) && !privateFileFlags.has(args[i - 1] ?? ""));
1040
1083
  const argsKey = JSON.stringify({
1041
1084
  args: keyArgs,
@@ -1047,6 +1090,11 @@ export const ClaudeDriver = {
1047
1090
  model: injected.model ?? null,
1048
1091
  base: env.ANTHROPIC_BASE_URL ?? null,
1049
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"),
1050
1098
  });
1051
1099
  // Reuse the live process when it is idle, unchanged, and is the session
1052
1100
  // the harness wants resumed. Anything else: close it and spawn fresh
@@ -1137,6 +1185,7 @@ export const ClaudeDriver = {
1137
1185
  const nativeReview = permissionMode === "auto" && nativeMode !== null
1138
1186
  ? nativeMode === "auto" ? "active" : "inactive"
1139
1187
  : undefined;
1188
+ const questions = askQuestions(ask);
1140
1189
  emit({
1141
1190
  ...base(threadId, eventTurnId),
1142
1191
  type: "request.opened",
@@ -1151,7 +1200,12 @@ export const ClaudeDriver = {
1151
1200
  approvalScope: typeof ask.tool === "string" && controlsHost && ask.tool.startsWith("mcp__computer")
1152
1201
  ? "local-computer"
1153
1202
  : undefined,
1154
- 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,
1155
1209
  });
1156
1210
  },
1157
1211
  onResolve: (resolved) => {
@@ -1180,6 +1234,9 @@ export const ClaudeDriver = {
1180
1234
  if (mcpConfigPath) {
1181
1235
  writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
1182
1236
  }
1237
+ if (authSettingsPath) {
1238
+ writeFileSync(authSettingsPath, JSON.stringify(authSettings), { mode: 0o600 });
1239
+ }
1183
1240
  if (sessionId)
1184
1241
  args.push("--resume", sessionId);
1185
1242
  else
@@ -524,6 +524,7 @@ export const CodexDriver = {
524
524
  const earlyNotifications = [];
525
525
  const state = {
526
526
  settled: false,
527
+ lastError: "",
527
528
  lastText: "",
528
529
  sawStreamDelta: false,
529
530
  // codex reports token usage as a running THREAD total; the harness
@@ -878,7 +879,15 @@ export const CodexDriver = {
878
879
  }
879
880
  case "turn/completed": {
880
881
  const t = p.turn ?? {};
881
- 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")));
882
891
  break;
883
892
  }
884
893
  case "error":
@@ -886,8 +895,10 @@ export const CodexDriver = {
886
895
  // {error:{message}} — surface either (agentcal armor)
887
896
  {
888
897
  const message = p.message ?? p.error?.message;
889
- if (message)
890
- 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
+ }
891
902
  }
892
903
  break;
893
904
  }
@@ -1145,7 +1156,7 @@ export const CodexDriver = {
1145
1156
  message,
1146
1157
  ...(needsAuth ? { setup: true } : {}),
1147
1158
  });
1148
- await settle(false, needsAuth ? "auth_required" : "rpc_error");
1159
+ await settle(false, needsAuth ? "auth_required" : verdict.reason === "provider_safety" ? "provider_safety" : "rpc_error");
1149
1160
  }
1150
1161
  }
1151
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)
@@ -75,7 +75,7 @@ import { buildRecoveryText, buildTurnContext, engineIsFresh } from "./turn-conte
75
75
  import { extractTurnImages } from "./turn-images.js";
76
76
  import { TurnWatchdog } from "./turn-watchdog.js";
77
77
  import { TurnResources, workspaceResource } from "./turn-resources.js";
78
- import { ensureWorkspace, ensureTaskWorkspace, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
78
+ import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
79
79
  import { readMemoryTopic } from "./workspace.js";
80
80
  import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openMemoryLocation, readMemoryDoc, } from "./memory-store.js";
81
81
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
@@ -1213,6 +1213,14 @@ function handleDesktopTrustedApprovalMessage(raw) {
1213
1213
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
1214
1214
  return false;
1215
1215
  const message = raw;
1216
+ const threadCanReceiveGrant = (bot) => {
1217
+ const threadId = bot.approvalGrant?.threadId;
1218
+ if (!threadId)
1219
+ return true;
1220
+ const target = store.projectBotForTask(bot.id, threadId);
1221
+ return Boolean(target && !threadBusy(bot.id, threadId) &&
1222
+ registry.cliTarget(target.modelSelection.instanceId)?.driverKind === registry.cliTarget(bot.modelSelection.instanceId)?.driverKind);
1223
+ };
1216
1224
  if (message.type === "approval-trusted-mode-commit") {
1217
1225
  const requestId = typeof message.requestId === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(message.requestId)
1218
1226
  ? message.requestId
@@ -1231,7 +1239,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
1231
1239
  bot.approvalGrant.phase === "committed" &&
1232
1240
  bot.approvalMode === mode &&
1233
1241
  !bot.busy &&
1242
+ threadCanReceiveGrant(bot) &&
1234
1243
  supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1244
+ if (bot.approvalGrant.threadId) {
1245
+ store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
1246
+ }
1235
1247
  store.patchBot(botId, { approvalGrant: undefined });
1236
1248
  }
1237
1249
  else if (bot?.approvalGrant?.requestId === requestId) {
@@ -1272,7 +1284,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1272
1284
  return true;
1273
1285
  }
1274
1286
  store.patchBot(botId, {
1275
- approvalGrant: { requestId, mode, phase: "confirmed" },
1287
+ approvalGrant: { ...bot.approvalGrant, requestId, mode, phase: "confirmed" },
1276
1288
  });
1277
1289
  confirm(true);
1278
1290
  return true;
@@ -1321,7 +1333,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1321
1333
  }
1322
1334
  // Still inert: Electron must receive this acknowledgement and request
1323
1335
  // finalization before the durable mode can affect any turn.
1324
- store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "activated" } });
1336
+ store.patchBot(botId, { approvalGrant: { ...bot.approvalGrant, requestId, mode, phase: "activated" } });
1325
1337
  activate(true);
1326
1338
  return true;
1327
1339
  }
@@ -1359,10 +1371,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
1359
1371
  bot.approvalGrant.phase === "activated" &&
1360
1372
  bot.approvalMode === mode &&
1361
1373
  !bot.busy &&
1374
+ threadCanReceiveGrant(bot) &&
1362
1375
  supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1363
1376
  // Durable but still inert. Electron must observe this exact ACK before
1364
1377
  // sending the one-way commit release that clears the journal.
1365
- store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "committed" } });
1378
+ store.patchBot(botId, { approvalGrant: { ...bot.approvalGrant, requestId, mode, phase: "committed" } });
1366
1379
  finalize(true);
1367
1380
  return true;
1368
1381
  }
@@ -1406,6 +1419,20 @@ function handleDesktopTrustedApprovalMessage(raw) {
1406
1419
  return true;
1407
1420
  }
1408
1421
  const currentMode = approvalModeFor(existing);
1422
+ const threadId = message.threadId;
1423
+ if (threadId !== undefined) {
1424
+ const target = typeof threadId === "string" && /^[\w-]{1,128}$/.test(threadId)
1425
+ ? store.projectBotForTask(botId, threadId) : null;
1426
+ if (!target || (mode !== "full" && mode !== "custom") || currentMode !== mode || existing.approvalGrant) {
1427
+ respond({ ok: false, error: "Choose this bot's approval level in bot settings before applying it to an existing thread" });
1428
+ return true;
1429
+ }
1430
+ if (threadBusy(botId, threadId) ||
1431
+ registry.cliTarget(target.modelSelection.instanceId)?.driverKind !== registry.cliTarget(existing.modelSelection.instanceId)?.driverKind) {
1432
+ respond({ ok: false, error: "Stop this thread and use the bot's provider before applying its approval level" });
1433
+ return true;
1434
+ }
1435
+ }
1409
1436
  const emergencyDowngrade = existing.busy && isEmergencyApprovalDowngrade(currentMode, mode);
1410
1437
  const clearsPendingElevation = mode === "ask" && existing.approvalGrant !== undefined;
1411
1438
  if (existing.busy && !emergencyDowngrade && !clearsPendingElevation) {
@@ -1432,7 +1459,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1432
1459
  approvalMode: mode,
1433
1460
  autoApprove: mode === "auto",
1434
1461
  approvalGrant: mode === "full" || mode === "custom"
1435
- ? { requestId, mode, phase: "prepared" }
1462
+ ? { requestId, mode, phase: "prepared", ...(typeof threadId === "string" ? { threadId } : {}) }
1436
1463
  : undefined,
1437
1464
  });
1438
1465
  if (!updated) {
@@ -2197,6 +2224,15 @@ always) {
2197
2224
  outcome = "unavailable";
2198
2225
  }
2199
2226
  }
2227
+ // An answered question keeps its words. `request.resolved` only records
2228
+ // the behavior, so without this the card reads "answer" forever and the
2229
+ // person can no longer see what they told the bot.
2230
+ if (outcome !== "unavailable" && behavior === "answer" && message && cardMessage) {
2231
+ const settled = store.messagesFor(threadId).find((m) => m.id === cardMessage.id)?.card;
2232
+ if (settled) {
2233
+ store.patchMessage(threadId, cardMessage.id, { card: { ...settled, answeredText: message } });
2234
+ }
2235
+ }
2200
2236
  // The human's verdict, recorded only when it actually reached the engine:
2201
2237
  // `unavailable` means the action never ran, and a "user-approved" row
2202
2238
  // over a request nothing answered would be the audit log lying. A
@@ -2883,7 +2919,11 @@ bus.subscribe((event) => {
2883
2919
  }
2884
2920
  break;
2885
2921
  case "request.opened": {
2886
- const permission = event.requestType === "permission";
2922
+ // A structured ask carries the model's own options and has no
2923
+ // allow/deny answer, so it is a question no matter which channel the
2924
+ // provider routed it through — and, like every question, no approval
2925
+ // mode may ever answer it for the person.
2926
+ const permission = event.requestType === "permission" && !event.questions?.length;
2887
2927
  // A permission request here is one the provider left for a person: its
2888
2928
  // own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
2889
2929
  // OpenMausBot decides nothing about the action itself. Only Full access
@@ -2985,6 +3025,10 @@ bus.subscribe((event) => {
2985
3025
  break;
2986
3026
  }
2987
3027
  const heldContext = { source: verdict?.source, permission };
3028
+ // A structured ask (Claude's AskUserQuestion) is a question whatever
3029
+ // the provider routed it as: it has no allow/deny answer, only the
3030
+ // model's own options. The card carries them so the person can choose.
3031
+ const questions = event.questions?.length ? event.questions : undefined;
2988
3032
  const message = pushMessage({
2989
3033
  role: "bot",
2990
3034
  kind: "options",
@@ -2998,6 +3042,7 @@ bus.subscribe((event) => {
2998
3042
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
2999
3043
  requestId: event.requestId,
3000
3044
  tool: permission ? event.tool : undefined,
3045
+ questionRequest: questions ? { version: 1, questions } : undefined,
3001
3046
  // the provider can keep an allow for its session; the app keeps
3002
3047
  // no grant of its own for a provider's tool
3003
3048
  allowSession: permission && event.allowSession && !event.requiresExplicitApproval ? true : undefined,
@@ -4511,6 +4556,7 @@ async function startTurn(botId, text, opts) {
4511
4556
  // to a turn whose engine actually mounted them (setupMode is already
4512
4557
  // false when they are not — see agentsMounted above)
4513
4558
  { id: "setup", label: "Setup", text: setupSystemPrompt(setupMode, { skills: skillAuthoring, cwd: liveBot?.cwd ?? bot.cwd }) },
4559
+ { id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
4514
4560
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
4515
4561
  { id: "plan", label: "Surface", text: plan.note },
4516
4562
  // gated on the integration, not the key: the hint only goes to a
@@ -5616,6 +5662,7 @@ setupRetry = 0) {
5616
5662
  store.patchBot(bot.id, { soulDrift: drift.drift });
5617
5663
  }
5618
5664
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
5665
+ { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
5619
5666
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
5620
5667
  { id: "computer", label: "Computer", text: computerPrompt(roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
5621
5668
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -12392,15 +12439,17 @@ const handleRequest = async (req, res) => {
12392
12439
  const current = store.projectBotForTask(m[1], m[2]);
12393
12440
  if (!current)
12394
12441
  return json(res, 404, { error: "no such task" });
12395
- const allowed = new Set(["title", "projectId", "modelSelection", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto"]);
12442
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto"]);
12396
12443
  if (Object.keys(body).some((key) => !allowed.has(key)))
12397
12444
  return json(res, 400, { error: "unsupported thread setting" });
12398
- for (const key of ["requireAvailableModel", "acknowledgeLocalAuto"]) {
12445
+ for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault"]) {
12399
12446
  if (body[key] !== undefined && typeof body[key] !== "boolean")
12400
12447
  return json(res, 400, { error: `${key} must be a boolean` });
12401
12448
  }
12402
12449
  if (body.requireAvailableModel === true && body.modelSelection === undefined)
12403
12450
  return json(res, 400, { error: "requireAvailableModel requires modelSelection" });
12451
+ if (body.updateBotDefault === true && body.modelSelection === undefined)
12452
+ return json(res, 400, { error: "updateBotDefault requires modelSelection" });
12404
12453
  const patch = {};
12405
12454
  if (body.projectId !== undefined) {
12406
12455
  if (body.projectId === null)
@@ -12437,8 +12486,11 @@ const handleRequest = async (req, res) => {
12437
12486
  const mode = body.approvalMode ?? (body.autoApprove ? "auto" : "ask");
12438
12487
  // Elevated modes still require the trusted desktop transition. A
12439
12488
  // thread settings PATCH cannot manufacture that grant.
12440
- if (mode !== "ask" && mode !== "auto")
12441
- return json(res, 403, { error: "Full and Custom access require confirmation in bot settings" });
12489
+ if (mode !== "ask" && mode !== "auto" && mode !== "edits")
12490
+ return json(res, 403, { error: "Full and Custom access require trusted desktop confirmation" });
12491
+ if (!supportsApprovalMode(registry.cliTarget(current.modelSelection.instanceId)?.driverKind, mode)) {
12492
+ return json(res, 400, { error: "This provider does not support the selected approval level" });
12493
+ }
12442
12494
  if (threadBusy(current.id, current.threadId))
12443
12495
  return json(res, 409, { error: "stop this thread before changing its approval mode" });
12444
12496
  if (current.approvalGrant)
@@ -12458,6 +12510,24 @@ const handleRequest = async (req, res) => {
12458
12510
  registry.cliTarget(patch.modelSelection.instanceId)?.driverKind !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
12459
12511
  return json(res, 400, { error: "Choose Ask for this thread before changing providers with elevated permissions" });
12460
12512
  }
12513
+ if (body.updateBotDefault === true && patch.modelSelection) {
12514
+ const profile = store.bot(current.id);
12515
+ const checked = checkedModelSelection(patch.modelSelection, {
12516
+ selection: profile.modelSelection, busy: Boolean(activeGroupTurnForBot(current.id)),
12517
+ });
12518
+ if (!checked.ok)
12519
+ return json(res, checked.status, { error: checked.error });
12520
+ const defaultMode = approvalModeFor(profile);
12521
+ if ((defaultMode === "full" || defaultMode === "custom") &&
12522
+ (!supportsApprovalMode(registry.cliTarget(patch.modelSelection.instanceId)?.driverKind, defaultMode) ||
12523
+ registry.cliTarget(patch.modelSelection.instanceId)?.driverKind !== registry.cliTarget(profile.modelSelection.instanceId)?.driverKind)) {
12524
+ return json(res, 400, { error: "Choose Ask in bot settings before changing its default provider with elevated permissions" });
12525
+ }
12526
+ // Validate both scopes before saving either. Existing sibling threads
12527
+ // retain their selections; only this pinned thread and future/group
12528
+ // turns adopt the new default. Do not target the currently selected tab.
12529
+ store.patchBot(current.id, { modelSelection: patch.modelSelection });
12530
+ }
12461
12531
  const task = store.patchTask(m[1], m[2], patch);
12462
12532
  const fresh = botWithThread(store.bot(m[1]));
12463
12533
  broadcast({ kind: "bot", bot: fresh });