openmausbot 0.1.73 → 0.1.74

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 (33) hide show
  1. package/dist/assets/index-BRLHsQ5g.js +298 -0
  2. package/dist/assets/index-ByLQ7a3F.css +1 -0
  3. package/dist/assets/{index-BYbK7ber.js → index-CwoQdM0z.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +9 -0
  6. package/dist-server/index.js +592 -453
  7. package/dist-server/mcp-gate.js +58 -16
  8. package/dist-server/openmausbot.js +186 -64
  9. package/dist-server/pair-cli.js +186 -64
  10. package/dist-server/server/auto-approve.js +30 -206
  11. package/dist-server/server/channel-queue.js +20 -2
  12. package/dist-server/server/config.js +36 -1
  13. package/dist-server/server/drivers/acp/core.js +56 -3
  14. package/dist-server/server/drivers/acp/grok.js +3 -1
  15. package/dist-server/server/drivers/antigravity-acp.js +5 -4
  16. package/dist-server/server/drivers/antigravity.js +9 -4
  17. package/dist-server/server/drivers/claude.js +54 -11
  18. package/dist-server/server/drivers/codex.js +22 -11
  19. package/dist-server/server/engine-install.js +9 -5
  20. package/dist-server/server/index.js +185 -223
  21. package/dist-server/server/message-db.js +70 -3
  22. package/dist-server/server/procs.js +82 -23
  23. package/dist-server/server/routines.js +67 -8
  24. package/dist-server/server/steer-queue.js +27 -7
  25. package/dist-server/server/team-manifest.js +1 -1
  26. package/dist-server/server/webhooks.js +4 -1
  27. package/dist-server/server/workspace-backup.js +24 -9
  28. package/dist-server/shared/approval-mode.js +11 -10
  29. package/dist-server/vps-container-mcp.js +9 -0
  30. package/package.json +1 -1
  31. package/dist/assets/index-BLJ7byG3.js +0 -296
  32. package/dist/assets/index-De2TRLxu.css +0 -1
  33. package/dist-server/server/auto-review.js +0 -79
@@ -1,237 +1,61 @@
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) {
20
+ export function autoVerdict(mode, tool, context) {
21
+ // Full's promise is literal: even a sandbox widening is approved. Entering
22
+ // Full is separately consent-gated by the bot PATCH endpoint, and the
23
+ // request.opened caller invokes this for permissions only, never questions.
24
+ if (mode === "full")
25
+ return { approve: `approved ${tool} (full access)`, source: "full-access" };
26
+ if (context?.requiresExplicitApproval)
115
27
  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 };
147
- return { approve: null, source: "no-grant" };
148
- }
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 };
28
+ if (mode === "auto" || mode === "custom")
29
+ return { approve: null, source: "native-approval" };
167
30
  return { approve: null, source: "no-grant" };
168
31
  }
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
32
  /** Every fixed note a held card can show, by catalog key.
187
33
  *
188
34
  * The card is the last thing between a bot and someone's filesystem, so the
189
35
  * one line explaining why it stopped should not be the one line still in
190
36
  * English. The client translates by key and falls back to this text, which
191
37
  * 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. */
38
+ * and so do the free-text apply errors that have no key at all. */
196
39
  export const HELD_NOTE = {
197
40
  "approval.held.native": "The provider requires your approval for this action.",
198
41
  "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
42
  "approval.held.undeliveredFull": "Full access couldn't deliver this approval.",
206
43
  "approval.held.undelivered": "Approve for me couldn't answer this one.",
207
44
  };
208
45
  /** Which note, as a key. approvalHeldReason is this plus the English text, so
209
46
  * the branching that decides the note lives in exactly one place. */
210
47
  export function approvalHeldNote(context) {
211
- if (context.source === "native-approval")
212
- return "approval.held.native";
213
48
  if (!context.permission)
214
49
  return undefined;
215
- if (context.requiresExplicitApproval)
50
+ if (context.source === "explicit-approval-block")
216
51
  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";
52
+ if (context.source === "native-approval")
53
+ return "approval.held.native";
54
+ return undefined;
55
+ }
56
+ /** The note a card shows above its buttons, explaining why the bot stopped
57
+ * rather than answering for itself. */
58
+ export function approvalHeldReason(context) {
59
+ const key = approvalHeldNote(context);
60
+ return key && HELD_NOTE[key];
237
61
  }
@@ -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;
@@ -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) => {
@@ -700,6 +750,9 @@ export function createAcpDriver(support) {
700
750
  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
751
  }
702
752
  const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
753
+ // a fresh native session forgets what the previous one allowed
754
+ if (!cursor)
755
+ sessionAllows.delete(threadId);
703
756
  let sessionResult = null;
704
757
  if (cursor) {
705
758
  try {
@@ -878,7 +931,7 @@ export function createAcpDriver(support) {
878
931
  const finish = turn?.asks.get(requestId);
879
932
  if (!finish)
880
933
  return "unavailable"; // settled, timed out, or turn gone
881
- finish(decision.behavior, "user", decision.message);
934
+ finish(decision.behavior, "user", decision.message, decision.always === true);
882
935
  return decision.behavior === "allow"
883
936
  ? "allowed-once"
884
937
  : decision.behavior === "answer"
@@ -214,7 +214,9 @@ const support = {
214
214
  // crates/codegen/xai-grok-pager-bin/src/main.rs:1259-1273.
215
215
  spawnArgs: (config, turn) => [
216
216
  "--permission-mode",
217
- config.fullAuto ? "bypassPermissions" : turn.approvalMode === "auto" ? "auto" : "default",
217
+ config.fullAuto
218
+ ? "bypassPermissions"
219
+ : turn.approvalMode === "auto" ? "auto" : turn.approvalMode === "edits" ? "acceptEdits" : "default",
218
220
  "agent",
219
221
  ...(turn.model ? ["-m", turn.model] : []),
220
222
  // 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",