openmausbot 0.1.56 → 0.1.58

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 (49) hide show
  1. package/dist/assets/index-B433sv8K.css +1 -0
  2. package/dist/assets/{index-Dq2j4Ikr.js → index-CE7W1AIk.js} +1 -1
  3. package/dist/assets/index-r-WPT-Pu.js +264 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -1
  6. package/dist-server/connector-proxy.js +31 -12
  7. package/dist-server/container-mcp.js +49 -31
  8. package/dist-server/drivers/agents-proxy.js +56 -2
  9. package/dist-server/index.js +2126 -487
  10. package/dist-server/openmausbot.js +15219 -305
  11. package/dist-server/pair-cli.js +15219 -305
  12. package/dist-server/server/auto-approve.js +24 -0
  13. package/dist-server/server/bot-folder.js +102 -0
  14. package/dist-server/server/bot-overview.js +206 -0
  15. package/dist-server/server/bot-package.js +5 -0
  16. package/dist-server/server/bot-profile.js +7 -0
  17. package/dist-server/server/browser-engine-release.js +44 -0
  18. package/dist-server/server/browser-engine.js +185 -0
  19. package/dist-server/server/cli.js +55 -1
  20. package/dist-server/server/connector-proxy.js +37 -16
  21. package/dist-server/server/container-computer.js +9 -2
  22. package/dist-server/server/drivers/agents-proxy.js +63 -2
  23. package/dist-server/server/drivers/antigravity-acp.js +119 -20
  24. package/dist-server/server/drivers/antigravity-runtime.js +47 -3
  25. package/dist-server/server/drivers/antigravity.js +23 -2
  26. package/dist-server/server/drivers/claude.js +7 -1
  27. package/dist-server/server/index.js +634 -107
  28. package/dist-server/server/mcp-bridge.js +73 -44
  29. package/dist-server/server/package-export.js +1 -0
  30. package/dist-server/server/profile-requests.js +332 -0
  31. package/dist-server/server/profile-revision.js +15 -0
  32. package/dist-server/server/profile-versions.js +154 -0
  33. package/dist-server/server/routine-requests.js +34 -1
  34. package/dist-server/server/routines.js +70 -2
  35. package/dist-server/server/setup-mode.js +63 -0
  36. package/dist-server/server/skill-fetch.js +67 -11
  37. package/dist-server/server/skills.js +1 -1
  38. package/dist-server/server/store.js +87 -7
  39. package/dist-server/server/system-prompt.js +49 -0
  40. package/dist-server/server/team-backup.js +2 -2
  41. package/dist-server/server/team-manifest.js +7 -0
  42. package/dist-server/shared/bot-profile.js +5 -1
  43. package/dist-server/shared/line-diff.js +42 -0
  44. package/dist-server/shared/profile-request.js +9 -0
  45. package/dist-server/shared/team-backup.js +4 -0
  46. package/dist-server/vps-container-mcp.js +49 -31
  47. package/package.json +1 -1
  48. package/dist/assets/index-CJBrtDbC.css +0 -1
  49. package/dist/assets/index-D0xt96rv.js +0 -263
@@ -28,9 +28,10 @@ import { createInterface } from "node:readline/promises";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import qrcode from "qrcode-terminal";
30
30
  import { explainTailscaleFailure, tailscaleServe, tailscaleServeOff, tailscaleStatus } from "./tailscale.js";
31
+ import { browserEngineStatus, describeBrowserEngine, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, } from "./browser-engine.js";
31
32
  import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
32
33
  const HERE = dirname(fileURLToPath(import.meta.url));
33
- const COMMANDS = ["serve", "pair", "sessions", "status", "login", "logout", "help", "--help", "-h"];
34
+ const COMMANDS = ["serve", "pair", "sessions", "status", "login", "logout", "browser", "help", "--help", "-h"];
34
35
  export function parseArgs(argv, env = process.env) {
35
36
  const [command = "help", ...rest] = argv;
36
37
  if (!COMMANDS.includes(command)) {
@@ -44,6 +45,7 @@ export function parseArgs(argv, env = process.env) {
44
45
  tunnel: false,
45
46
  client: false,
46
47
  pair: true,
48
+ withDeps: false,
47
49
  json: false,
48
50
  };
49
51
  for (let i = 0; i < rest.length; i += 1) {
@@ -78,6 +80,10 @@ export function parseArgs(argv, env = process.env) {
78
80
  options.email = value();
79
81
  else if (options.command === "sessions" && arg === "revoke")
80
82
  options.revoke = value();
83
+ else if (options.command === "browser" && (arg === "install" || arg === "status"))
84
+ options.browserAction = arg;
85
+ else if (options.command === "browser" && arg === "--with-deps")
86
+ options.withDeps = true;
81
87
  else
82
88
  return { error: `unknown argument "${arg}"` };
83
89
  }
@@ -91,6 +97,8 @@ export function parseArgs(argv, env = process.env) {
91
97
  return { error: "--public-url must start with http:// or https://" };
92
98
  if (options.tailscale && options.tunnel)
93
99
  return { error: "choose one of --tailscale (your tailnet) and --tunnel (a public address)" };
100
+ if (options.command === "browser" && !options.browserAction)
101
+ return { error: "browser needs an action: install or status" };
94
102
  return options;
95
103
  }
96
104
  export const USAGE = `openmausbot — run the server anywhere, pair devices to it
@@ -102,6 +110,7 @@ export const USAGE = `openmausbot — run the server anywhere, pair devices to i
102
110
  openmausbot status
103
111
  openmausbot login [--email you@example.com]
104
112
  openmausbot logout
113
+ openmausbot browser install [--with-deps] | status
105
114
 
106
115
  serve starts the server and prints a pairing link + QR code
107
116
  pair mints a pairing code against a running server (--client: chat only)
@@ -110,6 +119,10 @@ status what the server says about itself
110
119
  login signs this machine in to an OpenMausBot account (an emailed code)
111
120
  and reserves its public address for --tunnel
112
121
  logout releases that address and signs out
122
+ browser install: the bots' browser engine (agent-browser, pinned) and a
123
+ Chrome for Testing, into the data dir; --with-deps also installs
124
+ the Linux libraries Chrome needs (run as root once). status: what
125
+ this machine has.
113
126
 
114
127
  --tailscale serve over your tailnet: Tailscale terminates HTTPS and the
115
128
  link uses this machine's MagicDNS name (needs Tailscale signed in
@@ -329,6 +342,44 @@ export async function runLogout(options, io = defaultIo()) {
329
342
  io.log(`Signed out ${before.email}${before.address ? `; ${before.address} is released` : ""}.`);
330
343
  return 0;
331
344
  }
345
+ export async function runBrowser(options, io = defaultIo()) {
346
+ const status = browserEngineStatus({ dataDir: options.dataDir });
347
+ if (options.browserAction === "status") {
348
+ io.log(describeBrowserEngine(status));
349
+ if (status.kind !== "ready" && status.installable)
350
+ io.log("install it with: openmausbot browser install");
351
+ return status.kind === "ready" ? 0 : 1;
352
+ }
353
+ let binary = resolveAgentBrowserBinary({ dataDir: options.dataDir });
354
+ if (binary) {
355
+ io.log(`agent-browser is already here: ${binary}`);
356
+ }
357
+ else {
358
+ if (status.kind !== "ready" && !status.installable) {
359
+ io.error(status.reason);
360
+ return 1;
361
+ }
362
+ try {
363
+ binary = await installAgentBrowserBinary({ dataDir: options.dataDir, log: io.log });
364
+ }
365
+ catch (error) {
366
+ io.error(`could not install agent-browser: ${message(error)}`);
367
+ return 1;
368
+ }
369
+ io.log(`installed ${binary}`);
370
+ }
371
+ try {
372
+ await ensureChrome(binary, { withDeps: options.withDeps === true, log: io.log });
373
+ }
374
+ catch (error) {
375
+ io.error(`Chrome is not ready: ${message(error)}`);
376
+ if (process.platform === "linux" && !options.withDeps)
377
+ io.error("on Linux, Chrome needs system libraries: run `sudo openmausbot browser install --with-deps` once");
378
+ return 1;
379
+ }
380
+ io.log("bots on this server can use a browser now: turn it on under Settings → Experimental, then per bot");
381
+ return 0;
382
+ }
332
383
  /** Where the server bundle lives relative to this file: next to it in the
333
384
  * npm package and the image (dist-server/), or the TypeScript source in a
334
385
  * checkout. */
@@ -485,6 +536,7 @@ export async function runServe(options, log = console.log) {
485
536
  log("");
486
537
  log(`OpenMausBot is running on http://127.0.0.1:${options.port}${publicUrl ? `, reachable at ${publicUrl}` : ""}`);
487
538
  log(`data: ${options.dataDir}`);
539
+ log(describeBrowserEngine(browserEngineStatus({ dataDir: options.dataDir })));
488
540
  if (options.pair) {
489
541
  log("");
490
542
  log(await mintPairing(options.port, { label: options.label ? `${options.label} owner` : undefined, publicUrl: publicUrl ?? undefined }));
@@ -517,6 +569,8 @@ export async function main(argv = process.argv.slice(2)) {
517
569
  return runLogin(options);
518
570
  case "logout":
519
571
  return runLogout(options);
572
+ case "browser":
573
+ return runBrowser(options);
520
574
  default:
521
575
  console.log(USAGE);
522
576
  return 0;
@@ -118,22 +118,42 @@ function connectorAdds(args) {
118
118
  const toolkits = args.toolkits;
119
119
  if (!Array.isArray(toolkits))
120
120
  return [];
121
- return [...new Set(toolkits.flatMap((item) => {
122
- if (typeof item === "string")
123
- return [item.toLowerCase()];
124
- if (!item || typeof item !== "object" || Array.isArray(item))
125
- return [];
126
- const row = item;
127
- const slug = typeof row.toolkit === "string" ? row.toolkit : row.name;
128
- const action = String(row.action ?? "add").toLowerCase();
129
- return typeof slug === "string" && ["add", "connect", "initiate"].includes(action) ? [slug.toLowerCase()] : [];
130
- }))];
121
+ const seen = new Set();
122
+ const requests = [];
123
+ for (const item of toolkits) {
124
+ if (typeof item === "string") {
125
+ const slug = item.trim().toLowerCase();
126
+ const key = JSON.stringify([slug, ""]);
127
+ if (!seen.has(key)) {
128
+ seen.add(key);
129
+ requests.push({ slug });
130
+ }
131
+ continue;
132
+ }
133
+ if (!item || typeof item !== "object" || Array.isArray(item))
134
+ continue;
135
+ const row = item;
136
+ const action = String(row.action ?? "add").toLowerCase();
137
+ if (!["add", "connect", "initiate"].includes(action))
138
+ continue;
139
+ const slug = typeof row.toolkit === "string" ? row.toolkit : row.name;
140
+ if (typeof slug !== "string")
141
+ continue;
142
+ const normalized = slug.trim().toLowerCase();
143
+ const alias = (typeof row.alias === "string" ? row.alias : typeof row.account === "string" ? row.account : "").trim();
144
+ const key = JSON.stringify([normalized, alias.toLowerCase()]);
145
+ if (seen.has(key))
146
+ continue;
147
+ seen.add(key);
148
+ requests.push({ slug: normalized, ...(alias ? { alias } : {}) });
149
+ }
150
+ return requests;
131
151
  }
132
- async function showConnectorCards(slugs) {
152
+ async function showConnectorCards(items) {
133
153
  const response = await fetch(`${HARNESS}/api/internal/connectors/request`, {
134
154
  method: "POST",
135
155
  headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
136
- body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID, slugs, resumeKey: randomUUID() }),
156
+ body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID, items, resumeKey: randomUUID() }),
137
157
  signal: AbortSignal.timeout(30_000),
138
158
  });
139
159
  if (!response.ok) {
@@ -176,10 +196,11 @@ async function handle(message) {
176
196
  if (method === "tools/call") {
177
197
  const params = (message.params ?? {});
178
198
  const name = String(params.name ?? "");
179
- const slugs = /MANAGE_CONNECTIONS$/i.test(name) ? connectorAdds(params.arguments) : [];
180
- if (slugs.length) {
181
- await showConnectorCards(slugs);
182
- send(textResult(id, `OpenMausBot showed the user a secure connection card for ${slugs.join(", ")}. End this turn now. The app will continue the task automatically after the connection finishes.`));
199
+ const requests = /MANAGE_CONNECTIONS$/i.test(name) ? connectorAdds(params.arguments) : [];
200
+ if (requests.length) {
201
+ await showConnectorCards(requests);
202
+ const labels = requests.map((r) => (r.alias ? `${r.slug} (${r.alias})` : r.slug)).join(", ");
203
+ send(textResult(id, `OpenMausBot showed the user a secure connection card for ${labels}. End this turn now. The app will continue the task automatically after the connection finishes.`));
183
204
  return;
184
205
  }
185
206
  if (/WAIT_FOR_CONNECTIONS$/i.test(name)) {
@@ -728,7 +728,10 @@ export function wholeScreenshot(bytes) {
728
728
  mime: "image/jpeg",
729
729
  };
730
730
  }
731
- export async function containerComputerScreenshot(runner = sh, platform = process.platform, target = SHARED_LOCAL_VM_TARGET) {
731
+ /** The raw frame, in the shape the live screen poller broadcasts to every
732
+ * client (server/index.ts). The web panel wants a data URL instead, so
733
+ * containerComputerScreenshot below wraps this one. */
734
+ export async function containerComputerFrame(runner = sh, platform = process.platform, target = SHARED_LOCAL_VM_TARGET) {
732
735
  const cacheable = runner === sh && platform === process.platform;
733
736
  const now = Date.now();
734
737
  const cached = screenshotStatusCache.get(target.key);
@@ -759,7 +762,7 @@ export async function containerComputerScreenshot(runner = sh, platform = proces
759
762
  if (!checked.ok) {
760
763
  throw Object.assign(new Error("Cua Driver returned an incomplete screenshot"), { status: 502 });
761
764
  }
762
- return `data:${checked.mime};base64,${data}`;
765
+ return { png: data, format: checked.mime === "image/jpeg" ? "jpeg" : "png" };
763
766
  }
764
767
  catch (error) {
765
768
  if (cacheable)
@@ -767,6 +770,10 @@ export async function containerComputerScreenshot(runner = sh, platform = proces
767
770
  throw error;
768
771
  }
769
772
  }
773
+ export async function containerComputerScreenshot(runner = sh, platform = process.platform, target = SHARED_LOCAL_VM_TARGET) {
774
+ const { png, format } = await containerComputerFrame(runner, platform, target);
775
+ return `data:image/${format};base64,${png}`;
776
+ }
770
777
  const screenshotStatusCache = new Map();
771
778
  const containerMcpPath = SPAWNED_PROXIES.containerMcp;
772
779
  export function containerComputerMcp(runtime, control, target = SHARED_LOCAL_VM_TARGET) {
@@ -19,6 +19,7 @@
19
19
  // list_routines() → inspect this bot's scheduled work
20
20
  // propose_routine(...) → show a confirmation card for a new routine
21
21
  // propose_routine_action(...) → show a confirmation card for a routine change
22
+ // propose_profile(...) → show a confirmation card for a profile change
22
23
  //
23
24
  // Speaks raw JSON-RPC 2.0 over stdio (no MCP SDK — house style, matches
24
25
  // computer-proxy / permission-proxy). All state comes from env, injected by
@@ -212,6 +213,10 @@ const ROUTINE_FIELDS_SCHEMA = {
212
213
  type: "boolean",
213
214
  description: "Only for updates: set true to remove an existing safety limit. Do not combine with timeout_minutes.",
214
215
  },
216
+ continuity: {
217
+ type: "boolean",
218
+ description: "Opt in to using the latest completed run's bounded report as historical context. Defaults to false; set false in an update to start fresh again. Shown on the confirmation card.",
219
+ },
215
220
  };
216
221
  const TOOLS = [
217
222
  {
@@ -390,6 +395,31 @@ const TOOLS = [
390
395
  required: ["routine_id", "action"],
391
396
  },
392
397
  },
398
+ {
399
+ name: "propose_profile",
400
+ description: "Propose changes to your own name, title, description, standing instructions (SOUL.md), or working folder (cwd). This only creates a confirmation card; nothing changes until the user approves it. After calling it, end the turn and do not claim the change is applied. Keep SOUL.md short — who you are and the rules you never break; put step-by-step procedure into a skill instead. A Chief of Staff may pass for_bot_id (from list_bots) to propose a change for another bot in its section.",
401
+ inputSchema: {
402
+ type: "object",
403
+ additionalProperties: false,
404
+ properties: {
405
+ name: { type: "string", maxLength: 100, description: "New display name." },
406
+ title: { type: "string", maxLength: 200, description: "New role or title." },
407
+ description: { type: "string", maxLength: 4000, description: "New one-line blurb shown in rosters." },
408
+ soul: { type: "string", description: "Full replacement text for SOUL.md, at most 24000 bytes." },
409
+ cwd: {
410
+ type: "string",
411
+ maxLength: 1024,
412
+ description: "Absolute path of the folder your tools read and write in (for example /Users/me/Projects/site). It must already exist. An empty string means your private workspace.",
413
+ },
414
+ reason: { type: "string", minLength: 1, maxLength: 500, description: "One sentence the user will see explaining why." },
415
+ for_bot_id: {
416
+ type: "string",
417
+ description: "Chief of Staff only: the id of another bot in your section whose profile this changes. Omit to change your own.",
418
+ },
419
+ },
420
+ required: ["reason"],
421
+ },
422
+ },
393
423
  {
394
424
  name: "skills_list",
395
425
  description: "List this bot's imported skills (enabled and disabled) and any staged skill writes waiting for the user to confirm. Use this before skill_manage to avoid duplicate names. Listing does not enable anything.",
@@ -475,12 +505,14 @@ function routineFields(args) {
475
505
  fields.timeoutMinutes = null;
476
506
  else if (typeof args.timeout_minutes === "number")
477
507
  fields.timeoutMinutes = args.timeout_minutes;
508
+ if (typeof args.continuity === "boolean")
509
+ fields.continuity = args.continuity;
478
510
  return { fields };
479
511
  }
480
- function confirmationResult(r, fallback) {
512
+ function confirmationResult(r, fallback, noun = "routine") {
481
513
  const summary = typeof r.summary === "string" && r.summary.trim() ? `\n\n${r.summary.trim()}` : "";
482
514
  return {
483
- text: `A confirmation card is now visible to the user for ${fallback}.${summary}\n\nThis change has not been applied yet. End this turn and wait for the user to confirm or deny the card; do not claim the routine was created or changed before confirmation.`,
515
+ text: `A confirmation card is now visible to the user for ${fallback}.${summary}\n\nThis change has not been applied yet. End this turn and wait for the user to confirm or deny the card; do not claim the ${noun} was created or changed before confirmation.`,
484
516
  };
485
517
  }
486
518
  /** Who said a recalled line, as the header of a hit or a read. A user-role
@@ -770,6 +802,35 @@ async function callTool(name, args) {
770
802
  });
771
803
  return confirmationResult(r, `${action.replace("_", " ")} on routine ${routineId}`);
772
804
  }
805
+ if (name === "propose_profile") {
806
+ const changes = {};
807
+ if (typeof args.name === "string")
808
+ changes.name = args.name.trim();
809
+ if (typeof args.title === "string")
810
+ changes.title = args.title.trim();
811
+ if (typeof args.description === "string")
812
+ changes.description = args.description.trim();
813
+ if (typeof args.soul === "string")
814
+ changes.soul = args.soul;
815
+ if (typeof args.cwd === "string")
816
+ changes.cwd = args.cwd.trim();
817
+ if (!Object.keys(changes).length) {
818
+ return { text: "propose_profile needs at least one of name, title, description, soul, or cwd.", isError: true };
819
+ }
820
+ const forBotId = String(args.for_bot_id ?? "").trim();
821
+ const r = await api("/api/internal/profile-requests", {
822
+ method: "POST",
823
+ body: JSON.stringify({
824
+ fromBotId: BOT_ID,
825
+ fromThreadId: THREAD_ID,
826
+ changes,
827
+ reason: args.reason,
828
+ // JSON.stringify drops the key entirely when no target was named
829
+ forBotId: forBotId || undefined,
830
+ }),
831
+ });
832
+ return confirmationResult(r, "the profile change", "profile");
833
+ }
773
834
  if (name === "session_search") {
774
835
  const q = String(args.query ?? "").trim();
775
836
  if (!q)
@@ -48,6 +48,24 @@ function takeLines(buffer) {
48
48
  const rest = parts.pop() ?? "";
49
49
  return { lines: parts.map((line) => line.replace(/\r$/u, "")), rest };
50
50
  }
51
+ /** Only fixed, allowlisted startup hints leave stderr. Native output can
52
+ * contain OAuth codes and credentials in arbitrary formats, so generic
53
+ * text redaction is not enough to safely echo a diagnostic tail. */
54
+ function startupHint(line) {
55
+ if (/no space left|not enough (?:space|disk)|disk (?:is )?full|winerror\s*112/iu.test(line)) {
56
+ return "The runtime reported insufficient disk space while starting.";
57
+ }
58
+ if (/failed to (?:extract|load (?:python|embedded python))|could not load python/iu.test(line)) {
59
+ return "The runtime reported a problem unpacking or loading its bundled Python runtime.";
60
+ }
61
+ if (/permission denied|access is denied|winerror\s*5\b/iu.test(line)) {
62
+ return "The runtime reported a file or process permission failure.";
63
+ }
64
+ if (/address family not supported|failed to create.*(?:socket|listener)|failed to bind/iu.test(line)) {
65
+ return "The runtime reported a local network listener failure.";
66
+ }
67
+ return undefined;
68
+ }
51
69
  /** The sign-in link in a server output line, or null for anything else.
52
70
  * Google announces it two ways and both land on stderr: it hands the link to
53
71
  * $BROWSER — our helper re-emits it JSON-encoded behind a private marker — and
@@ -125,25 +143,41 @@ export class AntigravityAcpClient {
125
143
  pending = new Map();
126
144
  buffer = "";
127
145
  diagnosticBuffer = "";
146
+ initializationComplete = false;
147
+ startupOutputBytes = 0;
148
+ startupDiagnosticBytes = 0;
149
+ nativeStartupHint;
128
150
  closed = false;
129
151
  onAuthorizationUrl;
152
+ /** Settles once the runtime process is gone. On Windows a running
153
+ * executable pins its file and its directory, so an installer must not
154
+ * rename or delete either until this settles. */
155
+ exited;
130
156
  constructor(runtime, profile, cwd, onAuthorizationUrl) {
131
157
  this.onAuthorizationUrl = onAuthorizationUrl;
132
158
  this.child = spawnCli(runtime.executablePath, process.platform === "linux" ? ["--uid="] : [], { cwd, env: profile.environment, stdio: ["pipe", "pipe", "pipe"] });
133
159
  this.child.stdout.setEncoding("utf8");
134
160
  this.child.stdout.on("data", (chunk) => this.consume(chunk));
135
- // Google announces the sign-in link on stderr, not stdout. Scan for that
136
- // one line and drop every other byte: stderr also carries authorization
137
- // codes, so nothing else is retained or surfaced.
161
+ // Keep only sign-in announcements and fixed startup failure categories;
162
+ // never surface raw stderr, which can contain authorization codes.
138
163
  this.child.stderr.setEncoding("utf8");
139
164
  this.child.stderr.on("data", (chunk) => this.consumeDiagnostics(chunk));
140
165
  this.child.once("error", (error) => this.failAll(error));
141
- this.child.once("close", (code) => {
142
- if (!this.closed)
143
- this.failAll(new Error(`Antigravity ACP exited ${code ?? "unexpectedly"}.`));
166
+ this.exited = new Promise((resolve) => {
167
+ this.child.once("close", (code, signal) => {
168
+ this.noteStartupDiagnostic(this.diagnosticBuffer);
169
+ this.diagnosticBuffer = "";
170
+ if (!this.closed)
171
+ this.failAll(new Error(`Antigravity ACP exited ${code ?? signal ?? "unexpectedly"}.${this.nativeStartupHint ? ` ${this.nativeStartupHint}` : ""}`));
172
+ resolve();
173
+ });
174
+ // Failed spawns also emit `close`. An `error` alone can instead mean
175
+ // a failed kill, and is not evidence that the runtime stopped.
144
176
  });
145
177
  }
146
178
  consume(chunk) {
179
+ if (!this.initializationComplete)
180
+ this.startupOutputBytes += Buffer.byteLength(chunk);
147
181
  this.buffer += chunk;
148
182
  if (Buffer.byteLength(this.buffer) > MAX_PROTOCOL_LINE_BYTES) {
149
183
  this.failAll(new Error("Antigravity sent a protocol line that is too large."));
@@ -197,11 +231,19 @@ export class AntigravityAcpClient {
197
231
  * and released immediately and the tail is capped, so no authorization code
198
232
  * is ever held. Draining also keeps the pipe from stalling the server. */
199
233
  consumeDiagnostics(chunk) {
234
+ if (!this.initializationComplete)
235
+ this.startupDiagnosticBytes += Buffer.byteLength(chunk);
200
236
  const { lines, rest } = takeLines(this.diagnosticBuffer + chunk);
201
237
  // A partial line already longer than any legal link cannot become one.
202
238
  this.diagnosticBuffer = rest.length > MAX_DIAGNOSTIC_LINE_CHARS ? "" : rest;
203
- for (const line of lines)
204
- this.announceAuthorizationUrl(line);
239
+ for (const line of lines) {
240
+ if (!this.announceAuthorizationUrl(line))
241
+ this.noteStartupDiagnostic(line);
242
+ }
243
+ }
244
+ noteStartupDiagnostic(line) {
245
+ if (!this.initializationComplete)
246
+ this.nativeStartupHint ??= startupHint(line);
205
247
  }
206
248
  failAll(error) {
207
249
  for (const pending of this.pending.values()) {
@@ -218,7 +260,16 @@ export class AntigravityAcpClient {
218
260
  return new Promise((resolveRequest, reject) => {
219
261
  const timer = setTimeout(() => {
220
262
  this.pending.delete(id);
221
- reject(new Error(`${method} timed out.`));
263
+ if (method === "initialize") {
264
+ this.noteStartupDiagnostic(this.diagnosticBuffer);
265
+ reject(new Error(`Antigravity initialization timed out after ${Math.ceil(timeoutMs / 1_000)} seconds (${process.platform}-${process.arch}). ` +
266
+ "The executable was found, but did not finish starting. " +
267
+ (this.nativeStartupHint ? `${this.nativeStartupHint} ` : "") +
268
+ `Startup output: ${this.startupOutputBytes} bytes; diagnostic output: ${this.startupDiagnosticBytes} bytes. ` +
269
+ "Retry setup. If it still fails, share this error and your OpenMausBot version; do not paste Google sign-in links or tokens."));
270
+ }
271
+ else
272
+ reject(new Error(`${method} timed out.`));
222
273
  }, timeoutMs);
223
274
  timer.unref?.();
224
275
  this.pending.set(id, { resolve: resolveRequest, reject, timer });
@@ -226,11 +277,14 @@ export class AntigravityAcpClient {
226
277
  });
227
278
  }
228
279
  async initialize(timeoutMs = STARTUP_TIMEOUT_MS) {
229
- return this.request("initialize", {
280
+ const initialized = await this.request("initialize", {
230
281
  protocolVersion: 1,
231
282
  clientInfo: { name: "openmausbot", version: "0.0.0" },
232
283
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
233
284
  }, timeoutMs);
285
+ this.initializationComplete = true;
286
+ this.nativeStartupHint = undefined;
287
+ return initialized;
234
288
  }
235
289
  close() {
236
290
  if (this.closed)
@@ -239,6 +293,22 @@ export class AntigravityAcpClient {
239
293
  this.failAll(new Error("Antigravity ACP was closed."));
240
294
  killCliTree(this.child);
241
295
  }
296
+ /** Close, then wait (bounded) for the process to be gone. `killCliTree`
297
+ * on Windows asks taskkill and returns at once; the exit lands later. */
298
+ async closeAndWait(timeoutMs = 5_000) {
299
+ this.close();
300
+ let timer;
301
+ const timedOut = new Promise((resolve) => {
302
+ timer = setTimeout(() => resolve(false), timeoutMs);
303
+ timer.unref?.();
304
+ });
305
+ try {
306
+ return await Promise.race([this.exited.then(() => true), timedOut]);
307
+ }
308
+ finally {
309
+ clearTimeout(timer);
310
+ }
311
+ }
242
312
  }
243
313
  export function parseAntigravityAuthorizationUrl(authorizationUrl) {
244
314
  if (authorizationUrl.length > 16_384 || /\s/u.test(authorizationUrl)) {
@@ -407,26 +477,55 @@ export function isValidAntigravityInitializeResult(initialized, expectedVersion)
407
477
  */
408
478
  export async function validateAntigravityRuntime(runtime, expectedVersion) {
409
479
  const profileDirectory = await mkdtemp(join(tmpdir(), "openmaus-antigravity-verify-"));
480
+ let client;
481
+ let failed = false;
482
+ let failure;
410
483
  try {
411
484
  const profile = await prepareAntigravityProfile({
412
485
  instanceId: `verify-${randomUUID()}`,
413
486
  runtime,
414
487
  profileDirectory,
488
+ // Google's Windows one-file executable expands a large Python runtime
489
+ // into TEMP. Forced shutdown skips its own cleanup. Keep verification's
490
+ // extraction inside the profile we already remove after confirmed close.
491
+ baseEnv: process.platform === "win32"
492
+ ? { ...process.env, TEMP: profileDirectory, TMP: profileDirectory }
493
+ : undefined,
415
494
  });
416
- const client = new AntigravityAcpClient(runtime, profile, profileDirectory);
417
- try {
418
- const initialized = await client.initialize();
419
- if (!isValidAntigravityInitializeResult(initialized, expectedVersion)) {
420
- throw new Error("The download did not identify as the expected Google Antigravity ACP release.");
421
- }
495
+ client = new AntigravityAcpClient(runtime, profile, profileDirectory);
496
+ const initialized = await client.initialize();
497
+ if (!isValidAntigravityInitializeResult(initialized, expectedVersion)) {
498
+ throw new Error("The download did not identify as the expected Google Antigravity ACP release.");
422
499
  }
423
- finally {
424
- client.close();
500
+ }
501
+ catch (error) {
502
+ failed = true;
503
+ failure = error;
504
+ }
505
+ // The caller is about to rename the directory this executable runs from.
506
+ // Neither it nor the profile is safe to touch before confirmed close.
507
+ let stopped = !client;
508
+ try {
509
+ if (client) {
510
+ stopped = await client.closeAndWait();
511
+ if (!stopped)
512
+ throw new Error("Antigravity did not shut down after runtime verification.");
425
513
  }
426
514
  }
427
- finally {
428
- await rm(profileDirectory, { recursive: true, force: true });
515
+ catch (error) {
516
+ if (!failed) {
517
+ failed = true;
518
+ failure = error;
519
+ }
520
+ }
521
+ if (stopped) {
522
+ await rm(profileDirectory, { recursive: true, force: true, maxRetries: 4, retryDelay: 250 })
523
+ .catch((error) => {
524
+ console.warn(`antigravity: could not remove verification profile ${profileDirectory}: ${error instanceof Error ? error.message : String(error)}`);
525
+ });
429
526
  }
527
+ if (failed)
528
+ throw failure;
430
529
  }
431
530
  /** One in-flight personal Google login per provider instance. */
432
531
  export class AntigravityAuthController {
@@ -11,6 +11,33 @@ import { augmentedPath } from "../env-path.js";
11
11
  import { resolveAntigravityReleaseAsset, } from "./antigravity-release.js";
12
12
  const FREE_SPACE_MARGIN = 256 * 1024 * 1024;
13
13
  const DOWNLOAD_TIMEOUT_MS = 45 * 60_000;
14
+ const STAGING_PREFIX = ".install-";
15
+ /** Windows refuses to rename or delete a file something still holds open,
16
+ * with EPERM/EBUSY/EACCES rather than a clear "in use": the runtime the
17
+ * validator just stopped (taskkill is asynchronous) or an antivirus scan of
18
+ * a brand-new executable. Retry briefly; persistent refusals still fail. */
19
+ export function transientWindowsFileError(error) {
20
+ const code = typeof error === "object" && error !== null ? Reflect.get(error, "code") : undefined;
21
+ return code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOTEMPTY";
22
+ }
23
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
24
+ /** Retry a filesystem step on the transient Windows refusals above, with
25
+ * growing delays (default: 8 tries, 0.25 s → 2 s, about 10 s in total).
26
+ * Anything else, and the final failure, surface unchanged. */
27
+ export async function retryOnWindowsFileLock(step, options = {}) {
28
+ const attempts = options.attempts ?? 8;
29
+ const sleep = options.sleep ?? wait;
30
+ for (let attempt = 1;; attempt += 1) {
31
+ try {
32
+ return await step();
33
+ }
34
+ catch (error) {
35
+ if (attempt >= attempts || !transientWindowsFileError(error))
36
+ throw error;
37
+ await sleep(Math.min(2_000, (options.delayMs ?? 250) * 2 ** (attempt - 1)));
38
+ }
39
+ }
40
+ }
14
41
  const INSTALL_RECORD = ".install-complete.json";
15
42
  export class AntigravitySetupError extends Error {
16
43
  status = 409;
@@ -293,7 +320,10 @@ async function installOnce(options) {
293
320
  // statfs is unavailable on a few filesystems. The bounded download and
294
321
  // extraction checks still fail safely if the disk fills.
295
322
  }
296
- const staging = await mkdtemp(join(versions, `.install-${randomUUID()}-`));
323
+ // Another app/server process may be installing into this versions folder.
324
+ // The in-memory coalescing map cannot protect its staging directory.
325
+ const staging = await mkdtemp(join(versions, `${STAGING_PREFIX}${randomUUID()}-`));
326
+ let failed = false;
297
327
  try {
298
328
  const archivePath = join(staging, "download.zip");
299
329
  const runtimeDirectory = join(staging, "runtime");
@@ -364,7 +394,7 @@ async function installOnce(options) {
364
394
  };
365
395
  await writeFile(join(runtimeDirectory, INSTALL_RECORD), `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 });
366
396
  try {
367
- await rename(runtimeDirectory, destination);
397
+ await retryOnWindowsFileLock(() => rename(runtimeDirectory, destination));
368
398
  }
369
399
  catch (error) {
370
400
  const winner = await completedRelease(destination, asset);
@@ -376,8 +406,22 @@ async function installOnce(options) {
376
406
  throw new Error("The installed Antigravity runtime is incomplete.");
377
407
  return installed;
378
408
  }
409
+ catch (error) {
410
+ failed = true;
411
+ throw error;
412
+ }
379
413
  finally {
380
- await rm(staging, { recursive: true, force: true });
414
+ // Cleanup must never replace the real error with its own: a user who
415
+ // saw "EPERM: unlink agy_acp_server.exe" was looking at this line, not
416
+ // at why the install failed. Only clean up this attempt's directory;
417
+ // if it stays locked, log and leave it without touching other attempts.
418
+ try {
419
+ await retryOnWindowsFileLock(() => rm(staging, { recursive: true, force: true }));
420
+ }
421
+ catch (cleanupError) {
422
+ const detail = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
423
+ console.warn(`antigravity: could not remove the install folder ${staging} (${detail})${failed ? "" : "; the install itself succeeded"}`);
424
+ }
381
425
  }
382
426
  }
383
427
  /** Coalesce concurrent clicks so only one large Google artifact is fetched. */