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
@@ -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) {
@@ -1,237 +1,76 @@
1
- // Auto mode: when a bot may answer its own permission requests.
1
+ // What the harness does with a provider's permission request.
2
2
  //
3
- // Safe Auto and remembered grants stop at the destructive/sensitive and
4
- // unattended boundaries below. Full access is a separate, explicitly
5
- // acknowledged mode: it answers every permission request, including those
6
- // guards, while questions remain outside this module and always reach a human.
7
- //
8
- // The guard is deliberately tiny and literal. It is NOT a security
9
- // boundary (an agent set on damage has a thousand spellings for `rm`);
10
- // it is a "you probably didn't mean to hand THIS one over unattended"
11
- // backstop for the obvious catastrophes. Real containment is the
12
- // sandbox and the bot's own computer, not a regex.
13
- import { approvalModeFor } from "../shared/approval-mode.js";
3
+ // Nothing here decides whether an action is safe. Each approval level is a
4
+ // provider's own permission mode passed straight through (Claude `auto`,
5
+ // Grok `--permission-mode`, Codex `approvalsReviewer`, …), and a request that
6
+ // reaches this process is one the provider left for a person. The only
7
+ // verdict the app synthesizes is Full access, because that level is the
8
+ // person's explicit, separately confirmed grant to answer every prompt.
9
+ // Questions never come through here: a bot's question always reaches a human.
14
10
  /** Full access is the person's explicit grant to this receiving bot, including
15
11
  * delegated work. It never inherits the sender's mode or elevates another bot.
16
12
  * Custom is a provider-config choice rather than an app Full-access grant, so
17
- * peer-started Custom turns still use Auto and its unattended downgrade.
18
- * Provider support and grant confirmation are checked by the caller. */
13
+ * peer-started Custom turns use Auto. Provider support and grant confirmation
14
+ * are checked by the caller. */
19
15
  export function approvalModeForOrigin(mode, origin) {
20
16
  if (mode === "custom" && origin.peerInitiated)
21
17
  return "auto";
22
18
  return mode;
23
19
  }
24
- const DESTRUCTIVE = [
25
- /\brm\s+(-[a-z]*\s+)*-[a-z]*[rf]/i, // rm -rf, rm -fr, rm -r -f
26
- /\bmkfs\b|\bdiskutil\s+erase|\bdd\s+[^|]*\bof=\/dev\//i,
27
- /\bshutdown\b|\breboot\b|\bhalt\b/i,
28
- /:\(\)\s*\{.*\}\s*;?\s*:/, // fork bomb
29
- /\bgit\s+push\s+[^|]*--force(-with-lease)?\b|\bgit\s+reset\s+--hard\b/i,
30
- /\bDROP\s+(TABLE|DATABASE)\b|\bTRUNCATE\s+TABLE\b/i,
31
- /\bsudo\s+rm\b|\bchmod\s+-R\s+777\s+\//i,
32
- ];
33
- // Not destructive, but exactly what you don't hand over unattended: a
34
- // bot reading your keys is quiet, permanent, and unrecoverable.
35
- const SENSITIVE = [
36
- /(^|[\s/"'])\.env(\.|$|["'\s])/i,
37
- /\.ssh\/|id_rsa|id_ed25519|authorized_keys/i,
38
- /\.aws\/credentials|\.netrc|\.npmrc|\.pypirc|\.docker\/config\.json/i,
39
- /security\s+find-(generic|internet)-password|\bkeychain\b/i,
40
- /\bcredentials?\.json\b|\bserviceaccount\b/i,
41
- ];
42
- /** First matching pattern's source, so a verdict can NAME the rule that
43
- * made it — the decision log's whole value is "which rule", and deriving
44
- * the match a second time at the call site is how the log and the verdict
45
- * drift apart. */
46
- function matchFirst(rules, text) {
47
- for (const re of rules)
48
- if (re.test(text))
49
- return re.source;
50
- return null;
51
- }
52
- export function looksSensitive(text) {
53
- return matchFirst(SENSITIVE, text) !== null;
54
- }
55
- export function looksDestructive(text) {
56
- return matchFirst(DESTRUCTIVE, text) !== null;
57
- }
58
- /** The key an "Always allow" remembers.
59
- *
60
- * A bare tool name is far too coarse for a command runner: remembering
61
- * "Bash" would hand the bot a permanent unattended shell, which is the
62
- * opposite of what someone pressing "always allow" on `git status`
63
- * intends. Command tools are therefore keyed by their program —
64
- * `Bash:git`, `Bash:npm` — so the grant is as narrow as the thing you
65
- * actually looked at. Computed once, server-side, and echoed back by the
66
- * client so the two sides can never disagree about what was granted. */
67
- const COMMAND_TOOLS = new Set(["bash", "shell", "execute", "run_command", "computer_exec", "terminal"]);
68
- export function approvalKey(tool, summary, scope) {
69
- const bare = tool.replace(/^mcp__[^_]+__/, "").toLowerCase();
70
- if (!COMMAND_TOOLS.has(bare))
71
- return scope ? `${scope}:${tool}` : tool;
72
- // first bare word of the command, skipping env assignments and sudo
73
- const words = summary.trim().split(/\s+/);
74
- let i = 0;
75
- while (i < words.length && (/^[A-Z_][A-Z0-9_]*=/.test(words[i]) || words[i] === "sudo"))
76
- i += 1;
77
- const program = (words[i] ?? "").split("/").pop()?.replace(/[^\w.-]/g, "") ?? "";
78
- const key = program ? `${tool}:${program}` : tool;
79
- return scope ? `${scope}:${key}` : key;
80
- }
81
- /** A durable "Always allow" choice is offered only when that exact grant
82
- * would be honored on the next identical request. Custom delegates approval
83
- * semantics to config.toml, while guards and provider-sandbox changes are
84
- * intentionally never bypassed by remembered app grants. */
85
- export function rememberableApprovalKey(bot, tool, summary, context) {
86
- if (!bot ||
87
- approvalModeFor(bot) === "custom" ||
88
- context.source !== "no-grant" ||
89
- context.scope ||
90
- context.requiresExplicitApproval) {
91
- return undefined;
92
- }
93
- return approvalKey(tool, summary, context.scope);
94
- }
95
- /** The verdict AND its provenance. The decision itself is unchanged from
96
- * autoDecision below — this exists so the decision log can record which
97
- * rule decided without the call site re-deriving (and eventually
98
- * mis-deriving) the match. */
99
- export function autoVerdict(bot, tool, summary, context) {
100
- const mode = approvalModeFor(bot);
101
- if (context?.nativeApproval)
102
- return { approve: null, source: "native-approval" };
103
- // This branch intentionally precedes every guard. Entering Full access is
104
- // separately consent-gated by the bot PATCH endpoint, and its promise is
105
- // literal: even destructive, sensitive, unattended, and host-computer
106
- // permission requests are approved. The request.opened caller invokes this
107
- // function for permissions only, never for provider questions.
108
- if (mode === "full") {
109
- return {
110
- approve: `approved ${tool} (full access)`,
111
- source: "full-access",
112
- };
113
- }
114
- if (context?.requiresExplicitApproval) {
115
- return { approve: null, source: "explicit-approval-block" };
116
- }
117
- // the guards outrank the grants, so an "always allow" can never widen
118
- // into them
119
- const destructive = matchFirst(DESTRUCTIVE, summary) ?? matchFirst(DESTRUCTIVE, tool);
120
- const sensitive = destructive ? null : matchFirst(SENSITIVE, summary);
121
- // The grant is computed even when a hard block will refuse it: the row
122
- // worth auditing is "this WOULD have auto-approved, and only the block
123
- // stood in the way", which cannot be told apart from an ordinary
124
- // "nobody granted this" card without knowing both halves.
125
- const key = approvalKey(tool, summary, context?.scope);
126
- const grant = destructive || sensitive
127
- ? null
128
- : mode !== "custom" && bot.alwaysAllow?.includes(key)
129
- ? { approve: `auto-approved ${key} (always allowed)`, source: "always-allow", rule: key }
130
- : mode === "auto"
131
- ? { approve: `auto-approved ${tool}`, source: "auto-mode", rule: undefined }
132
- : null;
133
- if (context?.unattended) {
134
- // Auto mode is something a person switched on for turns they are present
135
- // for. A webhook turn begins with nobody watching, on a payload someone
136
- // else wrote, so it does not inherit that decision — the guard above is a
137
- // pattern list its own comment calls "not a security boundary", and it
138
- // must not stand in for a human at 3am. A guard that would have carded
139
- // anyway keeps its own name; the block is only the story when it is the
140
- // thing that changed the outcome.
141
- if (grant)
142
- return { approve: null, source: "unattended-block", rule: grant.rule };
143
- if (destructive)
144
- return { approve: null, source: "destructive-guard", rule: destructive };
145
- if (sensitive)
146
- return { approve: null, source: "sensitive-guard", rule: sensitive };
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"]);
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())) {
147
34
  return { approve: null, source: "no-grant" };
148
35
  }
149
- if (context?.scope === "local-computer" && mode !== "auto") {
150
- // Host control is not covered by a remembered always-allow grant.
151
- // After the Auto-on-this-computer warning, unclassified GUI actions
152
- // (click/type) may auto-approve; destructive/sensitive still card.
153
- if (grant)
154
- return { approve: null, source: "local-computer-block", rule: grant.rule };
155
- if (destructive)
156
- return { approve: null, source: "destructive-guard", rule: destructive };
157
- if (sensitive)
158
- return { approve: null, source: "sensitive-guard", rule: sensitive };
159
- return { approve: null, source: "no-grant" };
160
- }
161
- if (destructive)
162
- return { approve: null, source: "destructive-guard", rule: destructive };
163
- if (sensitive)
164
- return { approve: null, source: "sensitive-guard", rule: sensitive };
165
- if (grant)
166
- return { approve: grant.approve, source: grant.source, rule: grant.rule };
36
+ // Full's promise is literal: even a sandbox widening is approved. Entering
37
+ // Full is separately consent-gated by the bot PATCH endpoint, and the
38
+ // request.opened caller invokes this for permissions only, never questions.
39
+ if (mode === "full")
40
+ return { approve: `approved ${tool} (full access)`, source: "full-access" };
41
+ if (context?.requiresExplicitApproval)
42
+ return { approve: null, source: "explicit-approval-block" };
43
+ if (mode === "auto" || mode === "custom")
44
+ return { approve: null, source: "native-approval" };
167
45
  return { approve: null, source: "no-grant" };
168
46
  }
169
- /** Why this request may be answered without the human, or null to ask. */
170
- export function autoDecision(bot, tool, summary, context) {
171
- return autoVerdict(bot, tool, summary, context).approve;
172
- }
173
- /** The note a card shows above its buttons, explaining why the bot stopped
174
- * rather than answering for itself.
175
- *
176
- * The unattended case is the one users misread. A turn a webhook or another
177
- * bot started never runs Auto at all — approvalModeForTurn downgrades it to
178
- * Ask before the provider spawns — so "this action needs you" would name the
179
- * wrong cause and imply the next action might pass. It will not: with a fleet
180
- * delegating between bots, every card looks like this until someone answers.
181
- * Say that plainly, and name the mode that keeps running. */
182
- export function approvalHeldReason(context) {
183
- const key = approvalHeldNote(context);
184
- return key && HELD_NOTE[key];
185
- }
186
47
  /** Every fixed note a held card can show, by catalog key.
187
48
  *
188
49
  * The card is the last thing between a bot and someone's filesystem, so the
189
50
  * one line explaining why it stopped should not be the one line still in
190
51
  * English. The client translates by key and falls back to this text, which
191
52
  * the server keeps sending: cards saved before the key existed still render,
192
- * and so do the free-text apply errors that have no key at all.
193
- *
194
- * The unattended hint is a whole second sentence rather than a suffix. A
195
- * translator needs the sentence, not two halves to reassemble. */
53
+ * and so do the free-text apply errors that have no key at all. */
196
54
  export const HELD_NOTE = {
197
55
  "approval.held.native": "The provider requires your approval for this action.",
198
56
  "approval.held.sandbox": "This changes the provider sandbox, so only Full access can approve it automatically.",
199
- "approval.held.localComputer": "Controlling your computer is never covered by Always allow, so this needs you.",
200
- "approval.held.unattended": "A webhook or another bot started this turn, so Approve for me is paused and every action asks.",
201
- "approval.held.unattendedFullAccess": "A webhook or another bot started this turn, so Approve for me is paused and every action asks. Full access keeps working unattended.",
202
- "approval.held.destructive": "This looks destructive, so Approve for me stopped to ask.",
203
- "approval.held.sensitive": "This touches credentials, so Approve for me stopped to ask.",
204
- "approval.held.needsYou": "This action needs you, so Approve for me stopped to ask.",
205
57
  "approval.held.undeliveredFull": "Full access couldn't deliver this approval.",
206
58
  "approval.held.undelivered": "Approve for me couldn't answer this one.",
207
59
  };
208
60
  /** Which note, as a key. approvalHeldReason is this plus the English text, so
209
61
  * the branching that decides the note lives in exactly one place. */
210
62
  export function approvalHeldNote(context) {
211
- if (context.source === "native-approval")
212
- return "approval.held.native";
213
63
  if (!context.permission)
214
64
  return undefined;
215
- if (context.requiresExplicitApproval)
65
+ if (context.source === "explicit-approval-block")
216
66
  return "approval.held.sandbox";
217
- // Host control reaches this only over a grant that would otherwise have
218
- // fired, in a mode that explains nothing else. "I pressed Always allow and
219
- // it asked anyway" is the whole confusion, so answer that and not the mode.
220
- if (context.source === "local-computer-block")
221
- return "approval.held.localComputer";
222
- if (context.mode !== "auto")
223
- return undefined;
224
- if (context.unattended) {
225
- return context.fullAccessAvailable
226
- ? "approval.held.unattendedFullAccess"
227
- : "approval.held.unattended";
228
- }
229
- // A guard names itself. Both stop the same mode, but one is about damage
230
- // and the other about secrets, and a read-only .env card that says
231
- // "destructive" teaches people to stop reading these.
232
- if (context.source === "destructive-guard")
233
- return "approval.held.destructive";
234
- if (context.source === "sensitive-guard")
235
- return "approval.held.sensitive";
236
- return "approval.held.needsYou";
67
+ if (context.source === "native-approval")
68
+ return "approval.held.native";
69
+ return undefined;
70
+ }
71
+ /** The note a card shows above its buttons, explaining why the bot stopped
72
+ * rather than answering for itself. */
73
+ export function approvalHeldReason(context) {
74
+ const key = approvalHeldNote(context);
75
+ return key && HELD_NOTE[key];
237
76
  }
@@ -6,7 +6,20 @@
6
6
  // operation settles so the current responder cannot appear to answer words it
7
7
  // never saw.
8
8
  import { newId } from "./contracts.js";
9
+ import { chatFollowups, saveChatFollowup, settleChatFollowups } from "./message-db.js";
9
10
  const queues = new Map(); // threadId -> waiting sends
11
+ export function restoreChannelMessages() {
12
+ queues.clear();
13
+ for (const row of chatFollowups("channel")) {
14
+ if (row.status !== "pending")
15
+ continue;
16
+ const entry = queues.get(row.threadId) ?? { groupId: row.ownerId, items: [] };
17
+ if (entry.groupId !== row.ownerId)
18
+ throw new Error("queued task belongs to another channel");
19
+ entry.items.push({ ...row.payload, id: row.id, mode: row.payload.mode ?? "chat" });
20
+ queues.set(row.threadId, entry);
21
+ }
22
+ }
10
23
  export function queueChannelMessage(groupId, threadId, text, options = {}) {
11
24
  const entry = queues.get(threadId) ?? { groupId, items: [] };
12
25
  if (entry.groupId !== groupId)
@@ -19,6 +32,7 @@ export function queueChannelMessage(groupId, threadId, text, options = {}) {
19
32
  mode: options.mode ?? "chat",
20
33
  via: options.via,
21
34
  };
35
+ saveChatFollowup({ id: item.id, kind: "channel", ownerId: groupId, threadId, payload: item });
22
36
  entry.items.push(item);
23
37
  queues.set(threadId, entry);
24
38
  return { id: item.id };
@@ -38,6 +52,7 @@ export function cancelChannelMessage(groupId, queueId) {
38
52
  const items = entry.items.filter((item) => item.id !== queueId);
39
53
  if (items.length === entry.items.length)
40
54
  continue;
55
+ settleChatFollowups([queueId], "cancelled");
41
56
  if (items.length === 0)
42
57
  queues.delete(threadId);
43
58
  else
@@ -55,14 +70,17 @@ export function drainChannelMessages(isWorking, run) {
55
70
  for (const [threadId, entry] of queues) {
56
71
  if (isWorking(entry.groupId))
57
72
  continue;
58
- const item = entry.items.shift();
73
+ const item = entry.items[0];
59
74
  if (!item) {
60
75
  queues.delete(threadId);
61
76
  continue;
62
77
  }
78
+ settleChatFollowups([item.id], "dispatching");
79
+ entry.items.shift();
63
80
  if (entry.items.length === 0)
64
81
  queues.delete(threadId);
65
- run({ ...item, groupId: entry.groupId, threadId });
82
+ const running = run({ ...item, groupId: entry.groupId, threadId });
83
+ void Promise.resolve(running).then(() => settleChatFollowups([item.id], null), () => settleChatFollowups([item.id], "interrupted")).catch((error) => console.warn("channel-queue: could not settle durable follow-up", error));
66
84
  }
67
85
  }
68
86
  /** Test helper. */
@@ -209,6 +209,18 @@ const featureConfigSchema = z.object({
209
209
  * also has its own switch. */
210
210
  browser: z.boolean().optional(),
211
211
  });
212
+ /** First-run progress. Kept in the workspace config rather than a browser so
213
+ * it survives cleared site data and is shared by every paired client. Hint
214
+ * ids are short renderer-chosen slugs; the list is capped so a buggy client
215
+ * cannot grow the file without bound. */
216
+ const onboardingConfigSchema = z.object({
217
+ /** ISO timestamp of finishing (or skipping to the end of) the welcome flow. */
218
+ completedAt: z.string().trim().max(40).optional(),
219
+ /** Which welcome flow was completed; a newer flow may re-show itself. */
220
+ version: z.number().int().min(0).max(1000).optional(),
221
+ reelSeen: z.boolean().optional(),
222
+ hintsSeen: z.array(z.string().trim().min(1).max(60)).max(100).optional(),
223
+ }).strict();
212
224
  const instanceConfigSchema = z.object({
213
225
  driver: z.string().min(1),
214
226
  displayName: optionalText,
@@ -306,6 +318,7 @@ const appConfigSchema = z.object({
306
318
  threads: z.object({ maxConcurrentPerBot: z.number().int().min(1).max(MAX_CONCURRENT_BOT_THREADS) }).strict().optional(),
307
319
  localVm: localVmConfigSchema.optional(),
308
320
  features: featureConfigSchema.optional(),
321
+ onboarding: onboardingConfigSchema.optional(),
309
322
  browserProfiles: browserProfilesSchema.optional(),
310
323
  instances: instanceConfigMapSchema.optional(),
311
324
  /** User-configured MCP servers, mounted into every capable engine. Kept
@@ -422,6 +435,28 @@ export function showToolCallsEnabled(cfg) {
422
435
  export function builtInBrowserEnabled(cfg) {
423
436
  return cfg.features?.browser === true;
424
437
  }
438
+ /** Config sections no provider driver reads. A write that touches only
439
+ * these must not rebuild the fleet: rebuilding disposes every engine child
440
+ * and reloads it, seconds of work that would also interrupt in-flight
441
+ * turns. The guided tour writes `onboarding` on every step, so it in
442
+ * particular has to stay cheap. */
443
+ export const FLEET_NEUTRAL_KEYS = new Set([
444
+ "profile",
445
+ "language",
446
+ "tts",
447
+ "imageGen",
448
+ "vps",
449
+ "rooms",
450
+ "threads",
451
+ "localVm",
452
+ "features",
453
+ "browserProfiles",
454
+ "onboarding",
455
+ ]);
456
+ /** The keys of a config patch that require the provider fleet to reload. */
457
+ export function providerReloadKeys(patch) {
458
+ return Object.keys(patch).filter((key) => !FLEET_NEUTRAL_KEYS.has(key));
459
+ }
425
460
  // OMB_DATA_DIR isolates test/soak rigs from the user's real fleet.
426
461
  export const DATA_DIR = process.env.OMB_DATA_DIR ?? join(homedir(), ".openmausbot");
427
462
  const LEGACY_DATA_DIR = join(homedir(), ".opengrokbot");
@@ -648,7 +683,7 @@ export function saveConfig(patch, options = {}) {
648
683
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
649
684
  if (storedProfiles.success)
650
685
  disk.browserProfiles = storedProfiles.data;
651
- for (const key of ["xai", "anthropic", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "localVm", "features", "budgets", "billing"]) {
686
+ for (const key of ["xai", "anthropic", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "localVm", "features", "budgets", "billing", "onboarding"]) {
652
687
  const section = checkedPatch[key];
653
688
  if (!section)
654
689
  continue;